From d81eeb0600685120d2edf1951cd146795a4527de Mon Sep 17 00:00:00 2001 From: Victor Fernandez Date: Thu, 13 Aug 2026 17:49:41 -0600 Subject: [PATCH] Add experimental Servo rendering backend --- .github/workflows/servo-compatibility.yml | 119 ++ .gitignore | 1 + CHANGELOG.md | 28 +- README.md | 48 + animate_it.gemspec | 3 +- .../animate_it/render_pages_controller.rb | 31 + config/routes.rb | 3 + lib/animate_it.rb | 3 + lib/animate_it/composition.rb | 14 + lib/animate_it/configuration.rb | 35 +- lib/animate_it/engine.rb | 25 + lib/animate_it/errors.rb | 3 + lib/animate_it/frame_capturers.rb | 435 +++++ lib/animate_it/image_renderer.rb | 105 ++ lib/animate_it/props_schema.rb | 80 + lib/animate_it/render_ticket_store.rb | 35 + lib/animate_it/runtime/runtime.js | 5 +- lib/animate_it/verification.rb | 71 +- lib/animate_it/version.rb | 2 +- lib/animate_it/video_renderer.rb | 52 +- .../install/templates/animate_it.rb | 10 + lib/tasks/animate_it_tasks.rake | 1 + package.json | 2 +- servo-renderer/Cargo.lock | 1656 +++++++++++++++++ servo-renderer/Cargo.toml | 28 + servo-renderer/README.md | 69 + servo-renderer/src/lib.rs | 4 + servo-renderer/src/main.rs | 58 + servo-renderer/src/protocol.rs | 75 + servo-renderer/src/renderer.rs | 392 ++++ servo-renderer/src/server.rs | 629 +++++++ servo-renderer/src/validation.rs | 358 ++++ spec/animate_it/composition_spec.rb | 12 + spec/animate_it/frame_capturers_spec.rb | 83 + spec/animate_it/package_spec.rb | 2 + spec/animate_it/props_schema_spec.rb | 61 + spec/animate_it/verification_spec.rb | 42 + .../app/controllers/embeds_controller.rb | 4 + spec/dummy/config/routes.rb | 1 + spec/requests/image_renderer_spec.rb | 63 + 40 files changed, 4597 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/servo-compatibility.yml create mode 100644 app/controllers/animate_it/render_pages_controller.rb create mode 100644 lib/animate_it/frame_capturers.rb create mode 100644 lib/animate_it/image_renderer.rb create mode 100644 lib/animate_it/render_ticket_store.rb create mode 100644 servo-renderer/Cargo.lock create mode 100644 servo-renderer/Cargo.toml create mode 100644 servo-renderer/README.md create mode 100644 servo-renderer/src/lib.rs create mode 100644 servo-renderer/src/main.rs create mode 100644 servo-renderer/src/protocol.rs create mode 100644 servo-renderer/src/renderer.rs create mode 100644 servo-renderer/src/server.rs create mode 100644 servo-renderer/src/validation.rs create mode 100644 spec/animate_it/frame_capturers_spec.rb create mode 100644 spec/animate_it/props_schema_spec.rb create mode 100644 spec/requests/image_renderer_spec.rb diff --git a/.github/workflows/servo-compatibility.yml b/.github/workflows/servo-compatibility.yml new file mode 100644 index 0000000..8167628 --- /dev/null +++ b/.github/workflows/servo-compatibility.yml @@ -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" diff --git a/.gitignore b/.gitignore index 7a78d8d..7322b0f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ Gemfile.lock gemfiles/*.gemfile.lock node_modules/ +/servo-renderer/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e01fc..b73aca7 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.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 @@ -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 diff --git a/README.md b/README.md index ba978f7..9e9fdf6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/animate_it.gemspec b/animate_it.gemspec index 2d8c7c6..d5ffb8f 100644 --- a/animate_it.gemspec +++ b/animate_it.gemspec @@ -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 diff --git a/app/controllers/animate_it/render_pages_controller.rb b/app/controllers/animate_it/render_pages_controller.rb new file mode 100644 index 0000000..e27d1ef --- /dev/null +++ b/app/controllers/animate_it/render_pages_controller.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 8748c31..04fcd78 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/lib/animate_it.rb b/lib/animate_it.rb index 2f58b26..31d4c61 100644 --- a/lib/animate_it.rb +++ b/lib/animate_it.rb @@ -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" diff --git a/lib/animate_it/composition.rb b/lib/animate_it/composition.rb index 1d9d26a..8f91969 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(:@servo_compatible, false) subclass.instance_variable_set(:@chapters, Chapters.new(subclass)) super end @@ -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. diff --git a/lib/animate_it/configuration.rb b/lib/animate_it/configuration.rb index 06bb864..0e6da76 100644 --- a/lib/animate_it/configuration.rb +++ b/lib/animate_it/configuration.rb @@ -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 . # Compositions that re-use host partials (which expect the host's component @@ -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 diff --git a/lib/animate_it/engine.rb b/lib/animate_it/engine.rb index 43549d0..191ed30 100644 --- a/lib/animate_it/engine.rb +++ b/lib/animate_it/engine.rb @@ -34,5 +34,30 @@ class Engine < ::Rails::Engine include AnimateIt::ChapterNavigationHelper end end + + initializer "animate_it.action_controller_renderer" do + ActiveSupport.on_load(:action_controller) do + ActionController::Renderers.add :animate_it do |options, _| + renderer = AnimateIt::ImageRenderer.new( + composition: options.fetch(:composition), + frame: options.fetch(:frame, 0), + props: options.fetch(:props, {}), + host: request.base_url, + cache: options.fetch(:cache, true) + ) + + response.etag = renderer.etag + if request.fresh?(response) + self.status = :not_modified + self.content_type = "image/png" + "" + else + self.content_type = "image/png" + response.headers["Content-Disposition"] = "inline" + renderer.render + end + end + end + end end end diff --git a/lib/animate_it/errors.rb b/lib/animate_it/errors.rb index 1a9a5cb..6279f9f 100644 --- a/lib/animate_it/errors.rb +++ b/lib/animate_it/errors.rb @@ -1,4 +1,7 @@ module AnimateIt class Error < StandardError; end class CompositionNotFoundError < Error; end + class CaptureError < Error; end + class CaptureOperationalError < CaptureError; end + class RenderPropsError < Error; end end diff --git a/lib/animate_it/frame_capturers.rb b/lib/animate_it/frame_capturers.rb new file mode 100644 index 0000000..85dcdd6 --- /dev/null +++ b/lib/animate_it/frame_capturers.rb @@ -0,0 +1,435 @@ +require "json" +require "fileutils" +require "net/http" +require "pathname" +require "securerandom" +require "uri" + +module AnimateIt + module FrameCapturers + module_function + + def backend_for(composition, configured: AnimateIt.config.capture_backend) + backend = configured.to_sym + return :servo if backend == :servo + return :servo if backend == :auto && composition.servo_compatible? + + :playwright + end + + def build(composition:, host:, frames_dir: nil, playwright_cli: nil, backend: AnimateIt.config.capture_backend) + playwright = lambda do + Playwright.new(composition:, host:, frames_dir:, playwright_cli:) + end + + case backend.to_sym + when :playwright + playwright.call + when :servo + Servo.new(composition:, host:, frames_dir:) + when :auto + return playwright.call unless composition.servo_compatible? + + Fallback.new( + primary: Servo.new(composition:, host:, frames_dir:), + fallback: playwright.call, + frames_dir: + ) + else + raise ArgumentError, "Unknown capture backend: #{backend.inspect}" + end + end + + # Cache identity must never perform I/O: it is evaluated before Rails can + # answer If-None-Match. Hosts can pin ANIMATE_IT_SERVO_VERSION to the + # worker revision they deploy. + def cache_version_for(composition, configured: AnimateIt.config.capture_backend) + if configured.to_sym == :auto && composition.servo_compatible? + playwright = Gem.loaded_specs["playwright-ruby-client"]&.version || "unknown" + return "auto:servo:#{AnimateIt.config.servo_version}:playwright:#{playwright}" + end + + case backend_for(composition, configured:) + when :servo then "servo:#{AnimateIt.config.servo_version}" + when :playwright then "playwright:#{Gem.loaded_specs["playwright-ruby-client"]&.version || "unknown"}" + end + end + + class Playwright + DEFAULT_CLI = "npx playwright".freeze + + def initialize(composition:, host:, frames_dir: nil, playwright_cli: nil) + @composition = composition + @host = host + @frames_dir = Pathname(frames_dir) if frames_dir + @playwright_cli = playwright_cli || ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", DEFAULT_CLI) + end + + def capture_frames(frame_list:, page_url:, on_progress: nil, cancel_check: nil) + cancelled = false + with_page(page_url) do |page| + frame_list.each_with_index do |frame, index| + if cancel_check&.call + cancelled = true + break + end + + seek(page, frame) + page.screenshot(path: frame_path(index).to_s, omitBackground: true) + on_progress&.call(frame + 1, frame_list.size) + end + end + + cancelled || cancel_check&.call ? :cancelled : :complete + end + + def capture_frame(frame:, page_url:) + with_page(page_url) do |page| + seek(page, frame) + return page.screenshot(omitBackground: true) + end + end + + def version + "playwright" + end + + private + + def with_page(page_url) + require "playwright" + + ::Playwright.create(playwright_cli_executable_path: @playwright_cli) do |playwright| + browser = playwright.chromium.launch(headless: true, args: ["--disable-web-security"]) + begin + context = browser.new_context(viewport: { width: @composition.width, height: @composition.height }) + page = context.new_page + response = page.goto(page_url, waitUntil: "networkidle") + unless response&.ok? + status = response ? "#{response.status} #{response.status_text}" : "no HTTP response" + raise CaptureOperationalError, "Could not open AnimateIt page at #{page_url}: #{status}" + end + + wait_for_readiness!(page) + validate_manifest!(page) if @composition.client_driven? + yield(page) + ensure + browser&.close + end + end + rescue CaptureError + raise + rescue StandardError => e + raise CaptureOperationalError, "Playwright capture failed: #{e.message}" + end + + def wait_for_readiness!(page) + page.wait_for_function(<<~JS.squish) + document.documentElement.dataset.animateItReady === "1" || + document.documentElement.dataset.animateItError + JS + message = page.evaluate("() => document.documentElement.dataset.animateItError || null") + raise CaptureError, "AnimateIt player failed readiness: #{message}" if message.present? + end + + def validate_manifest!(page) + manifest = page.evaluate(<<~JS.squish) + () => { + const node = document.querySelector("script[data-animate-it-manifest]"); + return node ? JSON.parse(node.textContent) : null; + } + JS + expected = @composition.player_manifest.as_json + unless manifest.is_a?(Hash) && + manifest.values_at("version", "id", "width", "height", "duration") == + expected.values_at("version", "id", "width", "height", "duration") + raise CaptureError, "AnimateIt player manifest does not match #{@composition.id.inspect}" + end + end + + def seek(page, frame) + page.evaluate("(n) => window.__animateIt.setFrame(n)", arg: frame) + end + + def frame_path(index) + raise CaptureError, "A frames directory is required for batch capture" unless @frames_dir + + @frames_dir.join(format("frame-%05d.png", index)) + end + end + + class Servo + def initialize(composition:, host:, frames_dir: nil) + @composition = composition + @host = host + @frames_dir = Pathname(frames_dir) if frames_dir + endpoint = AnimateIt.config.servo_endpoint + raise CaptureOperationalError, "AnimateIt Servo endpoint is not configured" if endpoint.blank? + + @endpoint = URI(endpoint.delete_suffix("/")) + end + + def capture_frames(frame_list:, page_url:, on_progress: nil, cancel_check: nil) + raise CaptureError, "A frames directory is required for batch capture" unless @frames_dir + + request_id = SecureRandom.uuid + return :cancelled if cancel_check&.call + + validate_page_url!(page_url) + + stream_batch( + payload(request_id, page_url, frame_list).merge(output_dir: @frames_dir.to_s), + frame_list, + request_id, + on_progress:, + cancel_check: + ) + end + + def capture_frame(frame:, page_url:) + validate_page_url!(page_url) + request_id = SecureRandom.uuid + response = post_json("/v1/captures/frame", payload(request_id, page_url, [frame])) + raise_response!(response) unless response.is_a?(Net::HTTPSuccess) && response["Content-Type"].to_s.start_with?("image/png") + + response.body + end + + def version + response = request(Net::HTTP::Get.new(endpoint_uri("/v1/health"))) + return "servo-unavailable" unless response.is_a?(Net::HTTPSuccess) + + data = JSON.parse(response.body) + data["servo_version"] || data["servo_revision"] || "servo" + rescue StandardError + "servo-unavailable" + end + + private + + def payload(request_id, page_url, frames) + { + request_id:, + url: page_url, + composition: @composition.id, + width: @composition.width, + height: @composition.height, + duration: @composition.duration_in_frames, + manifest_version: PlayerManifest::VERSION, + frames:, + transparency: true, + ready_timeout_ms: AnimateIt.config.servo_ready_timeout + } + end + + def stream_batch(payload, frame_list, request_id, on_progress:, cancel_check:) + request = json_request(Net::HTTP::Post, "/v1/captures/frames", payload) + progress_count = 0 + monitor_finished = false + cancel_monitor = start_cancel_monitor(request_id, cancel_check) { monitor_finished } + result = catch(:animate_it_cancelled) do + stream_request(request) do |response, chunks| + raise_response!(response) unless response.is_a?(Net::HTTPSuccess) + + chunks.each do |line| + if cancel_check&.call + cancel(request_id) + throw :animate_it_cancelled, :cancelled + end + + event = JSON.parse(line) + event_status = event["status"] || event["type"] + throw :animate_it_cancelled, :cancelled if event_status == "cancelled" + raise_event_error!(event) if event_status == "error" + + next_count = event["captured"] || event["completed"] + next_count ||= event["index"].to_i + 1 if event.key?("index") + next_count ||= progress_count + 1 if %w[frame progress captured].include?(event_status) + next unless next_count && next_count.to_i > progress_count + + progress_count = [next_count.to_i, frame_list.size].min + frame = frame_list.fetch(progress_count - 1) + on_progress&.call(frame + 1, frame_list.size) + end + end + :complete + end + return result if result == :cancelled + + validate_batch_frames!(frame_list) + while progress_count < frame_list.size + progress_count += 1 + on_progress&.call(frame_list.fetch(progress_count - 1) + 1, frame_list.size) + end + :complete + rescue JSON::ParserError => e + raise CaptureOperationalError, "Servo returned an invalid batch response: #{e.message}" + ensure + monitor_finished = true + cancel_monitor&.join(0.1) + end + + def start_cancel_monitor(request_id, cancel_check, &finished) + return unless cancel_check + + Thread.new do + Thread.current.report_on_exception = false + until finished.call + if cancel_check.call + cancel(request_id) + break + end + sleep(0.05) + end + end + end + + def stream_request(request) + buffer = +"" + response = nil + Net::HTTP.start( + @endpoint.host, + @endpoint.port, + use_ssl: @endpoint.scheme == "https", + open_timeout: 2, + read_timeout: (AnimateIt.config.servo_ready_timeout / 1000.0) + 30 + ) do |http| + http.request(request) do |streamed_response| + response = streamed_response + unless response.is_a?(Net::HTTPSuccess) + streamed_response.read_body { |chunk| buffer << chunk } + response.instance_variable_set(:@body, buffer) + yield(response, []) + next + end + + streamed_response.read_body do |chunk| + buffer << chunk + lines = buffer.split("\n", -1) + buffer = lines.pop + yield(response, lines.compact_blank) if lines.any? + end + end + end + yield(response, [buffer]) if buffer.present? + rescue IOError, SystemCallError, Timeout::Error, SocketError => e + raise CaptureOperationalError, "Servo worker is unavailable: #{e.message}" + end + + def validate_batch_frames!(frame_list) + missing = frame_list.each_index.find do |index| + !@frames_dir.join(format("frame-%05d.png", index)).file? + end + raise CaptureOperationalError, "Servo did not write captured frame #{missing}" if missing + end + + def cancel(request_id) + request(Net::HTTP::Delete.new(endpoint_uri("/v1/captures/#{request_id}"))) + rescue StandardError + nil + end + + def post_json(path, payload) + request(json_request(Net::HTTP::Post, path, payload)) + end + + def json_request(request_class, path, payload) + request = request_class.new(endpoint_uri(path)) + request["Content-Type"] = "application/json" + request.body = JSON.generate(payload) + request + end + + def request(request) + Net::HTTP.start( + @endpoint.host, + @endpoint.port, + use_ssl: @endpoint.scheme == "https", + open_timeout: 2, + read_timeout: (AnimateIt.config.servo_ready_timeout / 1000.0) + 30 + ) { |http| http.request(request) } + rescue IOError, SystemCallError, Timeout::Error, SocketError => e + raise CaptureOperationalError, "Servo worker is unavailable: #{e.message}" + end + + def endpoint_uri(path) + URI.join("#{@endpoint}/", path.delete_prefix("/")) + end + + def raise_response!(response) + payload = JSON.parse(response.body) + code, message = error_details(payload["error"], payload["message"] || response.message) + error_class = response.code.to_i.in?([400, 422]) ? CaptureError : CaptureOperationalError + raise error_class, "Servo capture failed (#{response.code}, #{code}): #{message}" + rescue JSON::ParserError + raise CaptureOperationalError, "Servo capture failed (#{response.code}): #{response.body}" + end + + def raise_event_error!(event) + code, message = error_details(event["error"], event["message"]) + error_class = code.in?(%w[invalid_capture_request invalid_player]) ? CaptureError : CaptureOperationalError + raise error_class, "Servo capture failed (#{code}): #{message}" + end + + def error_details(error, fallback) + if error.is_a?(Hash) + [error["code"] || "render_failed", error["message"] || fallback || "unknown error"] + else + ["render_failed", error.presence || fallback || "unknown error"] + end + end + + def validate_page_url!(page_url) + origin = normalized_origin(page_url) + allowed = AnimateIt.config.servo_allowed_origins.filter_map { |value| normalized_origin(value) } + return if origin && allowed.include?(origin) + + raise CaptureError, "Servo page URL must use a configured allowed origin" + end + + def normalized_origin(value) + uri = URI(value.to_s) + return unless %w[http https].include?(uri.scheme) && uri.host.present? && uri.userinfo.nil? + + default_port = uri.scheme == "https" ? 443 : 80 + port = uri.port == default_port ? nil : ":#{uri.port}" + "#{uri.scheme}://#{uri.host.downcase}#{port}" + rescue URI::InvalidURIError + nil + end + end + + class Fallback + def initialize(primary:, fallback:, frames_dir: nil) + @primary = primary + @fallback = fallback + @frames_dir = Pathname(frames_dir) if frames_dir + end + + def capture_frames(**arguments) + @primary.capture_frames(**arguments) + rescue CaptureOperationalError + clear_partial_frames + @fallback.capture_frames(**arguments) + end + + def capture_frame(**arguments) + @primary.capture_frame(**arguments) + rescue CaptureOperationalError + @fallback.capture_frame(**arguments) + end + + def version + "auto:#{@primary.version}:#{@fallback.version}" + end + + private + + def clear_partial_frames + return unless @frames_dir + + Dir.glob(@frames_dir.join("frame-*.png")).each { |path| FileUtils.rm_f(path) } + end + end + end +end diff --git a/lib/animate_it/image_renderer.rb b/lib/animate_it/image_renderer.rb new file mode 100644 index 0000000..9cce670 --- /dev/null +++ b/lib/animate_it/image_renderer.rb @@ -0,0 +1,105 @@ +require "digest" +require "json" + +module AnimateIt + class ImageRenderer + attr_reader :composition, :frame, :props, :host + + def initialize(composition:, frame:, props:, host:, cache: true, capture_backend: nil) + raise Error, "AnimateIt internal rendering is not enabled" unless AnimateIt.config.internal_rendering? + + AnimateIt.load_compositions! + @composition = resolve_composition(composition) + @frame = integer_frame(frame) + @host = host.to_s.delete_suffix("/") + @cache_enabled = cache == true + @capture_backend = capture_backend || AnimateIt.config.capture_backend + validate_composition! + @props = @composition.props_schema.resolve_for_render( + props, + render_origin: @host, + asset_origins: AnimateIt.config.render_asset_origins + ) + end + + def render + cached = Rails.cache.read(cache_key) if @cache_enabled + return cached if cached + + token = RenderTicketStore.create!(composition:, props:) + bytes = frame_capturer.capture_frame(frame:, page_url: render_page_url(token)) + Rails.cache.write(cache_key, bytes) if @cache_enabled + bytes + ensure + RenderTicketStore.delete(token) if token + end + + def etag + @etag ||= Digest::SHA256.hexdigest(cache_key) + end + + def cache_key + @cache_key ||= begin + payload = { + animate_it: AnimateIt::VERSION, + track_schema: TrackDocumentSchema::CURRENT_VERSION, + player_manifest: PlayerManifest::VERSION, + runtime: Digest::SHA256.hexdigest(Runtime.source), + render_cache_version: AnimateIt.config.render_cache_version, + backend: FrameCapturers.backend_for(composition, configured: @capture_backend), + backend_version: FrameCapturers.cache_version_for(composition, configured: @capture_backend), + composition: composition.id, + width: composition.width, + height: composition.height, + frame:, + props: canonical(props) + } + "animate_it/images/#{Digest::SHA256.hexdigest(JSON.generate(payload))}" + end + end + + private + + def resolve_composition(value) + return value if value.is_a?(Class) && value <= Composition + + AnimateIt.registry.fetch(value.to_s) + end + + def integer_frame(value) + Integer(value) + rescue ArgumentError, TypeError + raise Error, "AnimateIt frame must be an integer" + end + + def validate_composition! + raise Error, "AnimateIt image rendering requires a client-driven composition" unless composition.client_driven? + return if frame.between?(0, composition.duration_in_frames - 1) + + raise Error, "AnimateIt frame #{frame} is outside 0...#{composition.duration_in_frames}" + end + + def frame_capturer + @frame_capturer ||= FrameCapturers.build( + composition:, + host:, + backend: @capture_backend + ) + end + + def render_page_url(token) + "#{host}#{AnimateIt.config.mount_path}/internal/render_pages/#{token}?pp=disable" + end + + def canonical(value) + case value + when Hash + value.sort_by { |key, _| key.to_s }.to_h { |key, item| [key.to_s, canonical(item)] } + when Array + value.map { |item| canonical(item) } + else + value + end + end + end +end diff --git a/lib/animate_it/props_schema.rb b/lib/animate_it/props_schema.rb index d5c68a6..e60f192 100644 --- a/lib/animate_it/props_schema.rb +++ b/lib/animate_it/props_schema.rb @@ -1,3 +1,6 @@ +require "json" +require "uri" + module AnimateIt class PropsSchema Field = Data.define(:name, :type, :default, :options) @@ -40,10 +43,87 @@ def resolve(input) defaults.merge(input.transform_keys(&:to_sym)) end + # Strict resolution for controller-driven image rendering. Studio and CLI + # callers intentionally continue using the permissive `resolve` method. + def resolve_for_render(input, render_origin:, asset_origins: AnimateIt.config.render_asset_origins) + unless input.is_a?(Hash) && input.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } + raise RenderPropsError, "Rendered props must be a hash with string or symbol keys" + end + + values = input.transform_keys(&:to_sym) + unknown = values.keys - fields.map(&:name) + raise RenderPropsError, "Unknown render props: #{unknown.join(", ")}" if unknown.any? + + resolved = defaults.merge(values) + fields.each do |definition| + value = resolved[definition.name] + validate_type!(definition, value) + validate_string_size!(definition, value) + validate_asset!(definition, value, render_origin:, asset_origins:) + end + + serialized = JSON.generate(resolved) + if serialized.bytesize > AnimateIt.config.render_props_max_bytes + raise RenderPropsError, + "Rendered props exceed #{AnimateIt.config.render_props_max_bytes} bytes" + end + + resolved + rescue JSON::GeneratorError => e + raise RenderPropsError, "Rendered props are not JSON-safe: #{e.message}" + end + private def field(name, type, default:, **options) fields << Field.new(name.to_sym, type, default, options) end + + def validate_type!(definition, value) + valid = case definition.type + when :string, :color, :asset then value.nil? || value.is_a?(String) + when :integer then value.nil? || value.is_a?(Integer) + when :number then value.nil? || (value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)) + when :boolean then value.nil? || value == true || value == false + else false + end + return if valid + + raise RenderPropsError, "Render prop #{definition.name.inspect} must be a #{definition.type}" + end + + def validate_string_size!(definition, value) + return unless value.is_a?(String) + return if value.bytesize <= AnimateIt.config.render_prop_string_max_bytes + + raise RenderPropsError, + "Render prop #{definition.name.inspect} exceeds #{AnimateIt.config.render_prop_string_max_bytes} bytes" + end + + def validate_asset!(definition, value, render_origin:, asset_origins:) + return unless definition.type == :asset && value.present? + + uri = URI.parse(value) + return if uri.scheme.nil? && uri.host.nil? && !value.start_with?("//") + + allowed = [render_origin, *asset_origins].filter_map { |origin| normalized_origin(origin) } + return if allowed.include?(normalized_origin(uri)) + + raise RenderPropsError, "Render asset #{definition.name.inspect} must use an allowed origin or relative URL" + rescue URI::InvalidURIError + raise RenderPropsError, "Render asset #{definition.name.inspect} is not a valid URL" + end + + def normalized_origin(value) + uri = value.is_a?(URI) ? value : URI.parse(value.to_s) + return unless %w[http https].include?(uri.scheme) && uri.host.present? + + default_port = uri.scheme == "https" ? 443 : 80 + port = uri.port == default_port ? nil : uri.port + port_suffix = port ? ":#{port}" : nil + "#{uri.scheme}://#{uri.host.downcase}#{port_suffix}" + rescue URI::InvalidURIError + nil + end end end diff --git a/lib/animate_it/render_ticket_store.rb b/lib/animate_it/render_ticket_store.rb new file mode 100644 index 0000000..c65dfd5 --- /dev/null +++ b/lib/animate_it/render_ticket_store.rb @@ -0,0 +1,35 @@ +require "securerandom" + +module AnimateIt + class RenderTicketStore + PREFIX = "animate_it/render_tickets".freeze + + class << self + def create!(composition:, props:) + token = SecureRandom.urlsafe_base64(32, false) + Rails.cache.write( + key(token), + { "composition" => composition.id, "props" => props.stringify_keys }, + expires_in: AnimateIt.config.render_ticket_ttl + ) + raise Error, "AnimateIt internal rendering requires a shared, writable Rails cache" unless Rails.cache.read(key(token)) + + token + end + + def read(token) + Rails.cache.read(key(token)) + end + + def delete(token) + Rails.cache.delete(key(token)) + end + + private + + def key(token) + "#{PREFIX}/#{token}" + end + end + end +end diff --git a/lib/animate_it/runtime/runtime.js b/lib/animate_it/runtime/runtime.js index 908b421..3a1ca50 100644 --- a/lib/animate_it/runtime/runtime.js +++ b/lib/animate_it/runtime/runtime.js @@ -485,11 +485,14 @@ } waitForReady(document) .then(function () { + delete document.documentElement.dataset.animateItError; 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 message = error && error.message ? error.message : "AnimateIt player failed readiness"; + document.documentElement.dataset.animateItError = message; + emit("error", { message: message }); }); } diff --git a/lib/animate_it/verification.rb b/lib/animate_it/verification.rb index c2a8027..eca3453 100644 --- a/lib/animate_it/verification.rb +++ b/lib/animate_it/verification.rb @@ -11,7 +11,8 @@ def psnr end end - attr_reader :composition, :host, :step, :threshold, :alpha_threshold, :output_dir, :props, :ready_timeout + attr_reader :composition, :host, :step, :threshold, :alpha_threshold, :output_dir, :props, :ready_timeout, + :candidate_backend def initialize( composition:, @@ -22,7 +23,8 @@ def initialize( output_dir: nil, playwright_cli: nil, props: {}, - ready_timeout: 30_000 + ready_timeout: 30_000, + candidate_backend: :player ) @composition = composition @host = host.delete_suffix("/") @@ -33,6 +35,9 @@ def initialize( @playwright_cli = playwright_cli || ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", "npx playwright") @props = props.to_h @ready_timeout = [ready_timeout.to_i, 1].max + @candidate_backend = candidate_backend.to_sym + raise ArgumentError, "candidate_backend must be :player or :servo" unless %i[player servo].include?(@candidate_backend) + raise Error, "Servo verification requires servo_compatible!" if @candidate_backend == :servo && !composition.servo_compatible? end def call @@ -52,13 +57,21 @@ def call ) begin context = browser.new_context(viewport: { width: composition.width, height: composition.height }) - legacy = open_page(context, "filmstrip") - candidate = open_page(context, "player") + reference_endpoint = candidate_backend == :servo ? "player" : "filmstrip" + reference = open_page(context, reference_endpoint) + candidate = open_page(context, "player") unless candidate_backend == :servo + ensure_servo_certifiable!(reference) if candidate_backend == :servo + ensure_servo_deterministic!(sample_frames.first) if candidate_backend == :servo sample_frames.each do |frame| - legacy_shot = screenshot(legacy, frame, "legacy") - candidate_shot = screenshot(candidate, frame, "player") - results << result_for(frame, legacy_shot, candidate_shot) + reference_label = candidate_backend == :servo ? "chromium-player" : "legacy" + reference_shot = screenshot(reference, frame, reference_label) + candidate_shot = if candidate_backend == :servo + servo_screenshot(frame) + else + screenshot(candidate, frame, "player") + end + results << result_for(frame, reference_shot, candidate_shot) end ensure browser&.close @@ -76,6 +89,13 @@ def sample_frames frames.push(edge - 1, edge, edge + 1) end end + if composition.respond_to?(:chapters) + composition.chapters.as_json.each do |chapter| + start_frame = chapter.fetch("startFrame") + end_frame = start_frame + chapter.fetch("durationFrames") + frames.push(start_frame - 1, start_frame, start_frame + 1, end_frame - 1, end_frame, end_frame + 1) + end + end frames.grep(0..max).sort.uniq end @@ -104,7 +124,7 @@ def open_page(context, endpoint) def screenshot(page, frame, label) page.evaluate("(n) => window.__animateIt.setFrame(n)", arg: frame) - if label == "player" + if label.include?("player") page.evaluate(<<~JS) document.querySelectorAll(".animate-it-layer.is-active").forEach((el) => { el.classList.remove("is-active"); @@ -120,6 +140,41 @@ def screenshot(page, frame, label) path end + def servo_screenshot(frame) + bytes = servo_capturer.capture_frame(frame:, page_url: page_url("player")) + path = output_dir.join(format("servo-%05d.png", frame)) + path.binwrite(bytes) + path + end + + def servo_capturer + @servo_capturer ||= FrameCapturers.build( + composition:, + host:, + backend: :servo + ) + end + + def ensure_servo_certifiable!(page) + native_animation_count = page.evaluate(<<~JS.squish) + () => typeof document.getAnimations === "function" ? + document.getAnimations({ subtree: true }).length : 0 + JS + return if native_animation_count.to_i.zero? + + raise Error, + "Servo certification cannot verify #{native_animation_count} native CSS/Web Animations deterministically" + end + + def ensure_servo_deterministic!(frame) + captures = 5.times.map do + servo_capturer.capture_frame(frame:, page_url: page_url("player")) + end + return if captures.uniq.one? + + raise Error, "Servo returned different PNG bytes across five captures of frame #{frame}" + end + def page_url(endpoint) query = { pp: "disable" } query[:props_json] = JSON.generate(props) unless props.empty? diff --git a/lib/animate_it/version.rb b/lib/animate_it/version.rb index 4f7d15f..3976427 100644 --- a/lib/animate_it/version.rb +++ b/lib/animate_it/version.rb @@ -1,3 +1,3 @@ module AnimateIt - VERSION = "0.5.0".freeze + VERSION = "0.6.0".freeze end diff --git a/lib/animate_it/video_renderer.rb b/lib/animate_it/video_renderer.rb index 1a41ef0..394d0ff 100644 --- a/lib/animate_it/video_renderer.rb +++ b/lib/animate_it/video_renderer.rb @@ -32,13 +32,14 @@ def self.audio_base attr_reader :composition, :host, :output_path, :frames_dir, :playwright_cli, :output_format - def initialize(composition:, host:, output_path:, frames_dir: nil, playwright_cli: nil, format: nil) + def initialize(composition:, host:, output_path:, frames_dir: nil, playwright_cli: nil, format: nil, capture_backend: nil) @composition = composition @output_format = format || composition.output_format @host = host.delete_suffix("/") @output_path = Pathname(output_path) @frames_dir = Pathname(frames_dir || Rails.root.join("tmp/animate_it/#{composition.id}")) @playwright_cli = playwright_cli || ENV.fetch("PLAYWRIGHT_CLI_EXECUTABLE_PATH", DEFAULT_PLAYWRIGHT_CLI) + @capture_backend = capture_backend || AnimateIt.config.capture_backend end class CancelledError < AnimateIt::Error; end @@ -87,42 +88,23 @@ def frames(frame_range:, every_nth_frame:) range.step(every_nth_frame).to_a end - # One Playwright browser, one navigation, N screenshots — all frames are - # rendered in a single filmstrip page and we just toggle which one is - # visible between captures via window.__animateIt.setFrame(n). def capture_frames(frame_list, props:, on_progress:, cancel_check:) - require "playwright" # development-only gem; lazy-loaded so deploy image boot doesn't fail - cancelled = false - - Playwright.create(playwright_cli_executable_path: playwright_cli) do |pw| - browser = pw.chromium.launch(headless: true, args: ["--disable-web-security"]) - begin - context = browser.new_context( - viewport: { width: composition.width, height: composition.height } - ) - page = context.new_page - - page.goto(page_url(props:), waitUntil: "networkidle") - page.wait_for_function('document.documentElement.dataset.animateItReady === "1"') - - frame_list.each_with_index do |frame, index| - if cancel_check&.call - cancelled = true - break - end - - page.evaluate("(n) => window.__animateIt.setFrame(n)", arg: frame) - screenshot_path = frames_dir.join(format("frame-%05d.png", index)) - page.screenshot(path: screenshot_path.to_s, omitBackground: true) - - on_progress&.call(frame + 1, frame_list.size) - end - ensure - browser&.close - end - end + frame_capturer.capture_frames( + frame_list:, + page_url: page_url(props:), + on_progress:, + cancel_check: + ) + end - cancelled || cancel_check&.call ? :cancelled : :complete + def frame_capturer + @frame_capturer ||= FrameCapturers.build( + composition:, + host:, + frames_dir:, + playwright_cli:, + backend: @capture_backend + ) end def page_url(props:) diff --git a/lib/generators/animate_it/install/templates/animate_it.rb b/lib/generators/animate_it/install/templates/animate_it.rb index 1b7b8df..6cdb6c3 100644 --- a/lib/generators/animate_it/install/templates/animate_it.rb +++ b/lib/generators/animate_it/install/templates/animate_it.rb @@ -5,4 +5,14 @@ # the engine at `AnimateIt.config.mount_path` (development/test only). AnimateIt.configure do |config| config.mount_path = "/animate_it" + + # Experimental alternate frame capture. Chromium remains the default. + # config.capture_backend = :auto + # config.servo_endpoint = "http://127.0.0.1:4178" + # config.servo_allowed_origins = ["http://127.0.0.1:3000"] + + # Required only for controller responses such as `render animate_it: ...`. + # Keep disabled unless the app has a shared, writable Rails cache. + # config.internal_rendering = Rails.env.local? + # config.render_asset_origins = ["https://cdn.example.com"] end diff --git a/lib/tasks/animate_it_tasks.rake b/lib/tasks/animate_it_tasks.rake index aaad197..b0533af 100644 --- a/lib/tasks/animate_it_tasks.rake +++ b/lib/tasks/animate_it_tasks.rake @@ -181,6 +181,7 @@ namespace :animate_it do step:, props:, output_dir:, + candidate_backend: ENV.fetch("ANIMATE_IT_VERIFY_BACKEND", "player").to_sym, ready_timeout: ENV.fetch("ANIMATE_IT_READY_TIMEOUT_MS", 30_000).to_i ) puts "Verifying #{composition.id} variant #{variant_index + 1}/#{props_variants.size}: " \ diff --git a/package.json b/package.json index 000409e..74eefab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "animate-it-skills", - "version": "0.5.0", + "version": "0.6.0", "description": "Claude Code / Agent Skills for the AnimateIt Rails gem — author videos, GIFs, and stills from your Rails app's own components and data. Run `npx animate-it-skills` to install into .claude/skills.", "bin": { "animate-it-skills": "bin/install-skills.js" diff --git a/servo-renderer/Cargo.lock b/servo-renderer/Cargo.lock new file mode 100644 index 0000000..e346716 --- /dev/null +++ b/servo-renderer/Cargo.lock @@ -0,0 +1,1656 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "animate-it-servo" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "clap", + "http", + "http-body-util", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tower", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/servo-renderer/Cargo.toml b/servo-renderer/Cargo.toml new file mode 100644 index 0000000..bde2d1a --- /dev/null +++ b/servo-renderer/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "animate-it-servo" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +license = "MIT" +publish = false + +[dependencies] +async-trait = "0.1" +axum = { version = "0.8", features = ["json"] } +base64 = "0.22" +bytes = "1" +clap = { version = "4.5", features = ["derive", "env"] } +http = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +thiserror = "2" +tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tokio-stream = "0.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2.5" + +[dev-dependencies] +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } diff --git a/servo-renderer/README.md b/servo-renderer/README.md new file mode 100644 index 0000000..10a28ec --- /dev/null +++ b/servo-renderer/README.md @@ -0,0 +1,69 @@ +# AnimateIt Servo worker + +This directory contains the optional, localhost-only rendering worker protocol for AnimateIt. +The HTTP server and its validation are implemented and tested. It can drive a prestarted Servo +`servoshell` via WebDriver. Without a configured WebDriver endpoint, it deliberately reports +`renderer_available: false` and returns `501 renderer_unavailable`; it does not fabricate images +or silently use another browser. + +## Run + +```sh +cargo run -- \ + --allowed-origins http://127.0.0.1:3000 \ + --capture-root /absolute/path/to/rails/tmp/animate_it \ + --webdriver-url http://127.0.0.1:7000 +``` + +The listener is always bound to `127.0.0.1`; there is no option to expose it publicly. + +Endpoints: + +- `GET /v1/health` +- `POST /v1/captures/frame` — returns `image/png` when a renderer is available +- `POST /v1/captures/frames` — writes `frame-%05d.png` beneath the configured capture root +- `DELETE /v1/captures/:request_id` — cancels queued or active work + +Requests are validated before reaching a renderer. Only configured HTTP(S) origins and paths of +the form `.../internal/render_pages/:opaque_ticket` are accepted for private images. Video capture +also permits the exact `.../compositions/:requested_id/player` path. Credentials, file URLs, +unbounded dimensions/frame counts/timeouts, traversal, and batch output outside the capture root +are rejected. A process-wide mutex serializes capture calls; cancellation remains responsive while +a request is queued or active. + +## Servo engine adapter + +Servo 0.4.0 is the current crates.io release. Its public API includes `ServoBuilder`, +`SoftwareRenderingContext`, `WebViewBuilder`, `WebView::evaluate_javascript`, and +`WebView::take_screenshot`. Those objects are intentionally thread-bound. A correct server adapter +therefore needs a dedicated engine thread that owns Servo, its software rendering context, and its +event-loop pumping for the lifetime of the process. Compiling the HTTP server against Servo without +that lifecycle would compile code but would not establish reliable rendering. + +The worker therefore drives the official `servoshell` binary, which already owns that lifecycle +and supports `--headless --webdriver PORT --window-size WxH`. Start it separately, for example: + +```sh +servoshell --headless --webdriver 7000 --window-size 1200x630 about:blank +``` + +Set `ANIMATE_IT_SERVO_WEBDRIVER_URL=http://127.0.0.1:7000`. The adapter creates a fresh session, +sets its viewport, navigates, polls AnimateIt's readiness/error markers, validates the embedded +manifest, calls `setFrame`, obtains real PNG screenshots, and closes the session. + +The integration has been exercised with the official Servo 0.4.0 macOS `servoshell`: health +reported ready, a 240×120 RGBA PNG was captured, and a three-frame batch wrote all PNGs while +streaming NDJSON progress. Servo remains pre-1.0, so pin the tested 0.4.0 binary and checksum in +deployment packaging rather than silently tracking newer releases. + +Remaining production-hardening work: + +1. Pin a Servo/servoshell release and checksum in packaging; optionally add supervised child + process startup via `ANIMATE_IT_SERVO_SHELL_PATH`. +2. Add a servoshell-level resource interception policy + before claiming subresource-origin enforcement; main-document validation alone is insufficient. +3. Terminate and restart the child after timeouts or protocol corruption. Never fall back inside + this worker; the Ruby `:auto` backend owns Playwright fallback. + +An in-process adapter can later replace this without changing the HTTP API because engine access is +isolated behind `Renderer`. diff --git a/servo-renderer/src/lib.rs b/servo-renderer/src/lib.rs new file mode 100644 index 0000000..9bb3f15 --- /dev/null +++ b/servo-renderer/src/lib.rs @@ -0,0 +1,4 @@ +pub mod protocol; +pub mod renderer; +pub mod server; +pub mod validation; diff --git a/servo-renderer/src/main.rs b/servo-renderer/src/main.rs new file mode 100644 index 0000000..7085ddd --- /dev/null +++ b/servo-renderer/src/main.rs @@ -0,0 +1,58 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; + +use animate_it_servo::renderer::{Renderer, UnavailableRenderer, WebDriverRenderer}; +use animate_it_servo::server::{AppState, router}; +use animate_it_servo::validation::{Limits, RequestValidator}; +use clap::Parser; +use tracing_subscriber::EnvFilter; + +#[derive(Debug, Parser)] +#[command(version, about)] +struct Args { + #[arg(long, env = "ANIMATE_IT_SERVO_PORT", default_value_t = 4178)] + port: u16, + #[arg( + long, + env = "ANIMATE_IT_SERVO_ALLOWED_ORIGINS", + value_delimiter = ',', + default_value = "http://127.0.0.1:3000" + )] + allowed_origins: Vec, + #[arg( + long, + env = "ANIMATE_IT_SERVO_CAPTURE_ROOT", + default_value = "/tmp/animate-it-servo" + )] + capture_root: PathBuf, + #[arg(long, env = "ANIMATE_IT_SERVO_WEBDRIVER_URL")] + webdriver_url: Option, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let args = Args::parse(); + let validator = + RequestValidator::new(args.allowed_origins, args.capture_root, Limits::default())?; + let renderer: Arc = match args.webdriver_url { + Some(endpoint) => Arc::new(WebDriverRenderer::new(endpoint)?), + None => Arc::new(UnavailableRenderer), + }; + let app = router(AppState::new(validator, renderer)); + let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), args.port); + let listener = tokio::net::TcpListener::bind(address).await?; + tracing::info!(%address, "AnimateIt Servo protocol worker listening"); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} diff --git a/servo-renderer/src/protocol.rs b/servo-renderer/src/protocol.rs new file mode 100644 index 0000000..32f58e8 --- /dev/null +++ b/servo-renderer/src/protocol.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +pub const PROTOCOL_VERSION: u32 = 1; +pub const PLAYER_MANIFEST_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CaptureRequest { + pub request_id: String, + pub url: String, + pub composition: String, + pub width: u32, + pub height: u32, + pub duration: u32, + pub manifest_version: u32, + pub frames: Vec, + #[serde(default = "default_true")] + pub transparency: bool, + #[serde(default = "default_ready_timeout")] + pub ready_timeout_ms: u64, + #[serde(default)] + pub output_dir: Option, +} + +fn default_true() -> bool { + true +} + +fn default_ready_timeout() -> u64 { + 30_000 +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PlayerManifest { + pub version: u32, + pub id: String, + pub width: u32, + pub height: u32, + pub duration: u32, +} + +#[derive(Debug, Serialize)] +pub struct HealthResponse<'a> { + pub status: &'a str, + pub protocol_version: u32, + pub worker_version: &'a str, + pub renderer: &'a str, + pub renderer_available: bool, + pub servo_version: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +pub struct BatchResponse { + pub request_id: String, + pub status: &'static str, + pub output_dir: String, + pub frames_written: usize, +} + +#[derive(Debug, Serialize)] +pub struct CancelResponse { + pub request_id: String, + pub status: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: ErrorBody, +} + +#[derive(Debug, Serialize)] +pub struct ErrorBody { + pub code: &'static str, + pub message: String, +} diff --git a/servo-renderer/src/renderer.rs b/servo-renderer/src/renderer.rs new file mode 100644 index 0000000..73058b5 --- /dev/null +++ b/servo-renderer/src/renderer.rs @@ -0,0 +1,392 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use async_trait::async_trait; +use base64::Engine; +use bytes::Bytes; +use reqwest::Client; +use serde_json::{Value, json}; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio::time::{Duration, Instant, sleep}; +use url::Url; + +use crate::protocol::{CaptureRequest, PlayerManifest}; +use crate::validation::validate_manifest; + +#[derive(Debug, Error)] +pub enum RenderError { + #[error("Servo renderer is unavailable: {0}")] + Unavailable(String), + #[error("capture was cancelled")] + Cancelled, + #[error("player readiness timed out")] + ReadinessTimeout, + #[error("player reported an error: {0}")] + Player(String), + #[error("player manifest is invalid: {0}")] + Manifest(String), + #[error("render failed: {0}")] + Failed(String), +} + +#[derive(Debug)] +pub struct CapturedFrame { + pub index: usize, + pub png: Bytes, +} + +#[async_trait] +pub trait Renderer: Send + Sync + 'static { + fn name(&self) -> &'static str; + fn available(&self) -> bool; + fn servo_version(&self) -> Option<&'static str>; + async fn healthy(&self) -> bool { + self.available() + } + + async fn capture( + &self, + request: &CaptureRequest, + cancelled: Arc, + frames: Option>, + ) -> Result, RenderError>; +} + +/// Honest placeholder for the HTTP integration when Servo support is not compiled in. +/// It never returns fabricated image data. +pub struct UnavailableRenderer; + +#[async_trait] +impl Renderer for UnavailableRenderer { + fn name(&self) -> &'static str { + "servo" + } + + fn available(&self) -> bool { + false + } + + fn servo_version(&self) -> Option<&'static str> { + None + } + + async fn capture( + &self, + _request: &CaptureRequest, + _cancelled: Arc, + _frames: Option>, + ) -> Result, RenderError> { + Err(RenderError::Unavailable( + "this build contains the protocol server but no Servo engine adapter".into(), + )) + } +} + +/// Drives a prestarted Servo `servoshell` through its W3C WebDriver endpoint. +/// Start Servo with `servoshell --headless --webdriver PORT --window-size WxH about:blank`. +pub struct WebDriverRenderer { + endpoint: String, + client: Client, +} + +impl WebDriverRenderer { + pub fn new(endpoint: impl Into) -> Result { + let endpoint = endpoint.into().trim_end_matches('/').to_owned(); + let parsed = Url::parse(&endpoint) + .map_err(|error| RenderError::Unavailable(format!("invalid WebDriver URL: {error}")))?; + if parsed.scheme() != "http" || !parsed.username().is_empty() || parsed.password().is_some() + { + return Err(RenderError::Unavailable( + "WebDriver URL must be an unauthenticated loopback http URL".into(), + )); + } + if !parsed + .host_str() + .is_some_and(|host| matches!(host, "127.0.0.1" | "localhost" | "::1")) + { + return Err(RenderError::Unavailable( + "WebDriver URL must use a loopback host".into(), + )); + } + Ok(Self { + endpoint, + client: Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .map_err(|error| RenderError::Unavailable(error.to_string()))?, + }) + } + + async fn command( + &self, + method: reqwest::Method, + path: &str, + body: Option, + ) -> Result { + let mut request = self + .client + .request(method, format!("{}{path}", self.endpoint)); + if let Some(body) = body { + request = request.json(&body); + } + let response = request + .send() + .await + .map_err(|error| RenderError::Failed(format!("WebDriver request failed: {error}")))?; + let status = response.status(); + let payload: Value = response + .json() + .await + .map_err(|error| RenderError::Failed(format!("invalid WebDriver response: {error}")))?; + if !status.is_success() { + let message = payload + .pointer("/value/message") + .and_then(Value::as_str) + .unwrap_or("WebDriver command failed"); + return Err(RenderError::Failed(format!("{status}: {message}"))); + } + Ok(payload.get("value").cloned().unwrap_or(payload)) + } + + async fn create_session(&self, request: &CaptureRequest) -> Result { + let payload = self + .command( + reqwest::Method::POST, + "/session", + Some(json!({ "capabilities": { "alwaysMatch": {} } })), + ) + .await?; + let session_id = payload + .get("sessionId") + .and_then(Value::as_str) + .or_else(|| payload.as_str()) + .ok_or_else(|| RenderError::Failed("WebDriver omitted sessionId".into()))? + .to_owned(); + self.command( + reqwest::Method::POST, + &format!("/session/{session_id}/window/rect"), + Some(json!({ "x": 0, "y": 0, "width": request.width, "height": request.height })), + ) + .await?; + Ok(session_id) + } + + async fn execute( + &self, + session_id: &str, + script: &str, + args: Value, + ) -> Result { + self.command( + reqwest::Method::POST, + &format!("/session/{session_id}/execute/sync"), + Some(json!({ "script": script, "args": args })), + ) + .await + } + + async fn wait_until_ready( + &self, + session_id: &str, + request: &CaptureRequest, + cancelled: &AtomicBool, + ) -> Result<(), RenderError> { + let deadline = Instant::now() + Duration::from_millis(request.ready_timeout_ms); + loop { + if cancelled.load(std::sync::atomic::Ordering::Acquire) { + return Err(RenderError::Cancelled); + } + let state = self + .execute( + session_id, + r#"return (function () { + var root = document.documentElement; + var manifest = document.querySelector('script[data-animate-it-manifest]'); + return { + ready: root && root.dataset.animateItReady === '1', + error: root && root.dataset.animateItError || null, + manifest: manifest && manifest.textContent || null, + url: location.href + }; + }());"#, + json!([]), + ) + .await?; + + if let Some(error) = state.get("error").and_then(Value::as_str) + && !error.is_empty() + { + return Err(RenderError::Player(error.to_owned())); + } + if state.get("ready").and_then(Value::as_bool) == Some(true) { + let final_url = state + .get("url") + .and_then(Value::as_str) + .ok_or_else(|| RenderError::Player("player URL is missing".into()))?; + let initial = Url::parse(&request.url) + .map_err(|error| RenderError::Player(error.to_string()))?; + let final_url = Url::parse(final_url) + .map_err(|error| RenderError::Player(error.to_string()))?; + if initial.origin() != final_url.origin() { + return Err(RenderError::Player( + "navigation left the allowed origin".into(), + )); + } + let manifest: PlayerManifest = serde_json::from_str( + state + .get("manifest") + .and_then(Value::as_str) + .ok_or_else(|| RenderError::Manifest("manifest is missing".into()))?, + ) + .map_err(|error| RenderError::Manifest(error.to_string()))?; + validate_manifest(request, &manifest) + .map_err(|error| RenderError::Manifest(error.to_string()))?; + return Ok(()); + } + if Instant::now() >= deadline { + return Err(RenderError::ReadinessTimeout); + } + sleep(Duration::from_millis(25)).await; + } + } + + async fn screenshot( + &self, + session_id: &str, + request: &CaptureRequest, + ) -> Result { + let encoded = self + .command( + reqwest::Method::GET, + &format!("/session/{session_id}/screenshot"), + None, + ) + .await? + .as_str() + .ok_or_else(|| RenderError::Failed("WebDriver screenshot was not base64".into()))? + .to_owned(); + let png = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| RenderError::Failed(format!("invalid screenshot base64: {error}")))?; + if !png.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(RenderError::Failed( + "WebDriver screenshot was not a PNG".into(), + )); + } + if png.len() < 26 || &png[12..16] != b"IHDR" { + return Err(RenderError::Failed("PNG has no valid IHDR".into())); + } + let width = u32::from_be_bytes(png[16..20].try_into().expect("checked PNG length")); + let height = u32::from_be_bytes(png[20..24].try_into().expect("checked PNG length")); + if width != request.width || height != request.height { + return Err(RenderError::Failed(format!( + "screenshot dimensions were {width}x{height}, expected {}x{}", + request.width, request.height + ))); + } + if request.transparency && !matches!(png[25], 4 | 6) { + return Err(RenderError::Failed( + "screenshot PNG does not contain an alpha channel".into(), + )); + } + Ok(Bytes::from(png)) + } + + async fn close_session(&self, session_id: &str) { + let _ = self + .command( + reqwest::Method::DELETE, + &format!("/session/{session_id}"), + None, + ) + .await; + } +} + +#[async_trait] +impl Renderer for WebDriverRenderer { + fn name(&self) -> &'static str { + "servo-webdriver" + } + + fn available(&self) -> bool { + true + } + + fn servo_version(&self) -> Option<&'static str> { + Some("0.4.0") + } + + async fn healthy(&self) -> bool { + matches!( + tokio::time::timeout( + Duration::from_millis(750), + self.command(reqwest::Method::GET, "/status", None) + ) + .await, + Ok(Ok(_)) + ) + } + + async fn capture( + &self, + request: &CaptureRequest, + cancelled: Arc, + progress: Option>, + ) -> Result, RenderError> { + let session_id = self.create_session(request).await?; + let result = async { + self.command( + reqwest::Method::POST, + &format!("/session/{session_id}/url"), + Some(json!({ "url": request.url })), + ) + .await?; + self.wait_until_ready(&session_id, request, &cancelled) + .await?; + + let mut frames = Vec::with_capacity(request.frames.len()); + for (index, frame) in request.frames.iter().enumerate() { + if cancelled.load(std::sync::atomic::Ordering::Acquire) { + return Err(RenderError::Cancelled); + } + self.execute( + &session_id, + "return window.__animateIt.setFrame(arguments[0]);", + json!([frame]), + ) + .await?; + let png = self.screenshot(&session_id, request).await?; + if let Some(progress) = &progress { + progress + .send(CapturedFrame { + index, + png: png.clone(), + }) + .await + .map_err(|_| RenderError::Cancelled)?; + } + frames.push(png); + } + Ok(frames) + } + .await; + self.close_session(&session_id).await; + result + } +} + +#[cfg(test)] +mod webdriver_tests { + use super::*; + + #[test] + fn only_accepts_loopback_http_endpoints() { + assert!(WebDriverRenderer::new("http://127.0.0.1:7000").is_ok()); + assert!(WebDriverRenderer::new("http://localhost:7000/wd").is_ok()); + assert!(WebDriverRenderer::new("https://127.0.0.1:7000").is_err()); + assert!(WebDriverRenderer::new("http://example.com:7000").is_err()); + assert!(WebDriverRenderer::new("http://user:pass@127.0.0.1:7000").is_err()); + } +} diff --git a/servo-renderer/src/server.rs b/servo-renderer/src/server.rs new file mode 100644 index 0000000..a7896ce --- /dev/null +++ b/servo-renderer/src/server.rs @@ -0,0 +1,629 @@ +use std::collections::HashMap; +use std::convert::Infallible; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use tokio::sync::Mutex; +use tokio_stream::StreamExt; +use tokio_stream::wrappers::ReceiverStream; + +use crate::protocol::{ + BatchResponse, CancelResponse, CaptureRequest, ErrorBody, ErrorResponse, HealthResponse, + PROTOCOL_VERSION, +}; +use crate::renderer::{CapturedFrame, RenderError, Renderer}; +use crate::validation::{RequestValidator, ValidationError}; + +#[derive(Clone)] +pub struct AppState { + validator: RequestValidator, + renderer: Arc, + render_lock: Arc>, + requests: Arc>>>, +} + +impl AppState { + pub fn new(validator: RequestValidator, renderer: Arc) -> Self { + Self { + validator, + renderer, + render_lock: Arc::new(Mutex::new(())), + requests: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/v1/health", get(health)) + .route("/v1/captures/frame", post(capture_frame)) + .route("/v1/captures/frames", post(capture_frames)) + .route("/v1/captures/{request_id}", delete(cancel_capture)) + .with_state(state) +} + +async fn health(State(state): State) -> Json> { + let renderer_available = state.renderer.healthy().await; + Json(HealthResponse { + status: if renderer_available { + "ready" + } else { + "unavailable" + }, + protocol_version: PROTOCOL_VERSION, + worker_version: env!("CARGO_PKG_VERSION"), + renderer: state.renderer.name(), + renderer_available, + servo_version: state.renderer.servo_version(), + }) +} + +async fn capture_frame( + State(state): State, + Json(request): Json, +) -> Result { + state.validator.validate(&request, false)?; + if request.frames.len() != 1 { + return Err(ApiError::bad_request( + "invalid_frames", + "single-frame capture requires exactly one frame", + )); + } + + let cancelled = register(&state, &request.request_id).await?; + let images = render_registered(&state, &request, cancelled, None).await?; + let Some(image) = images.into_iter().next() else { + return Err(ApiError::internal( + "invalid_renderer_response", + "renderer returned no frame", + )); + }; + + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/png") + .header(header::CACHE_CONTROL, "no-store") + .body(Body::from(image)) + .expect("static response is valid")) +} + +async fn capture_frames( + State(state): State, + Json(request): Json, +) -> Result { + state.validator.validate(&request, true)?; + let output_dir = state + .validator + .output_dir(&request) + .ok_or_else(|| ApiError::bad_request("invalid_output_dir", "output_dir is required"))?; + let request_id = request.request_id.clone(); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|error| { + ApiError::internal( + "output_write_failed", + format!("could not create output_dir: {error}"), + ) + })?; + let cancelled = register(&state, &request_id).await?; + let (line_tx, line_rx) = tokio::sync::mpsc::channel::(16); + tokio::spawn(run_batch( + state, request, request_id, output_dir, cancelled, line_tx, + )); + let stream = ReceiverStream::new(line_rx).map(Ok::<_, Infallible>); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/x-ndjson") + .header(header::CACHE_CONTROL, "no-store") + .body(Body::from_stream(stream)) + .expect("static response is valid")) +} + +async fn cancel_capture( + State(state): State, + Path(request_id): Path, +) -> Result, ApiError> { + let requests = state.requests.lock().await; + let Some(cancelled) = requests.get(&request_id) else { + return Err(ApiError::not_found( + "capture_not_found", + "capture is not queued or active", + )); + }; + cancelled.store(true, Ordering::Release); + Ok(Json(CancelResponse { + request_id, + status: "cancelling", + })) +} + +async fn register(state: &AppState, request_id: &str) -> Result, ApiError> { + let cancelled = Arc::new(AtomicBool::new(false)); + let mut requests = state.requests.lock().await; + if requests + .insert(request_id.to_owned(), cancelled.clone()) + .is_some() + { + return Err(ApiError::conflict( + "duplicate_request_id", + "request_id is already queued or active", + )); + } + Ok(cancelled) +} + +async fn render_registered( + state: &AppState, + request: &CaptureRequest, + cancelled: Arc, + progress: Option>, +) -> Result, ApiError> { + let result = async { + let _guard = state.render_lock.lock().await; + if cancelled.load(Ordering::Acquire) { + return Err(RenderError::Cancelled); + } + state.renderer.capture(request, cancelled, progress).await + } + .await; + state.requests.lock().await.remove(&request.request_id); + result.map_err(ApiError::from) +} + +async fn run_batch( + state: AppState, + request: CaptureRequest, + request_id: String, + output_dir: PathBuf, + cancelled: Arc, + lines: tokio::sync::mpsc::Sender, +) { + let (frame_tx, mut frame_rx) = tokio::sync::mpsc::channel::(2); + let render_state = state.clone(); + let render_request = request.clone(); + let render = tokio::spawn(async move { + render_registered(&render_state, &render_request, cancelled, Some(frame_tx)).await + }); + let mut frames_written = 0usize; + while let Some(frame) = frame_rx.recv().await { + let path = output_dir.join(format!("frame-{:05}.png", frame.index)); + if let Err(error) = tokio::fs::write(&path, frame.png).await { + let _ = send_batch_error( + &lines, + &request_id, + "output_write_failed", + &format!("could not write {}: {error}", path.display()), + ) + .await; + return; + } + frames_written += 1; + let line = serde_json::json!({ + "request_id": request_id, + "status": "progress", + "captured": frames_written, + "total": request.frames.len() + }); + if lines.send(format!("{line}\n")).await.is_err() { + return; + } + } + + match render.await { + Ok(Ok(images)) + if images.len() == request.frames.len() && frames_written == images.len() => + { + let complete = BatchResponse { + request_id, + status: "complete", + output_dir: output_dir.to_string_lossy().into_owned(), + frames_written, + }; + if let Ok(line) = serde_json::to_string(&complete) { + let _ = lines.send(format!("{line}\n")).await; + } + } + Ok(Ok(_)) => { + let _ = send_batch_error( + &lines, + &request_id, + "invalid_renderer_response", + "renderer returned the wrong number of frames", + ) + .await; + } + Ok(Err(error)) => { + let _ = send_batch_error(&lines, &request_id, error.code, &error.message).await; + } + Err(error) => { + let _ = send_batch_error( + &lines, + &request_id, + "render_task_failed", + &error.to_string(), + ) + .await; + } + } +} + +async fn send_batch_error( + lines: &tokio::sync::mpsc::Sender, + request_id: &str, + code: &'static str, + message: &str, +) -> Result<(), tokio::sync::mpsc::error::SendError> { + let line = serde_json::json!({ + "request_id": request_id, + "status": "error", + "error": { "code": code, "message": message } + }); + lines.send(format!("{line}\n")).await +} + +pub struct ApiError { + status: StatusCode, + code: &'static str, + message: String, +} + +impl ApiError { + fn bad_request(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, code, message) + } + + fn not_found(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, code, message) + } + + fn conflict(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, code, message) + } + + fn internal(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, code, message) + } + + fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + } + } +} + +impl From for ApiError { + fn from(error: ValidationError) -> Self { + Self::bad_request("invalid_capture_request", error.to_string()) + } +} + +impl From for ApiError { + fn from(error: RenderError) -> Self { + match error { + RenderError::Unavailable(message) => { + Self::new(StatusCode::NOT_IMPLEMENTED, "renderer_unavailable", message) + } + RenderError::Cancelled => Self::new( + StatusCode::CONFLICT, + "capture_cancelled", + "capture was cancelled", + ), + RenderError::ReadinessTimeout => Self::new( + StatusCode::GATEWAY_TIMEOUT, + "readiness_timeout", + error.to_string(), + ), + RenderError::Player(_) | RenderError::Manifest(_) => Self::new( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_player", + error.to_string(), + ), + RenderError::Failed(_) => { + Self::new(StatusCode::BAD_GATEWAY, "render_failed", error.to_string()) + } + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(ErrorResponse { + error: ErrorBody { + code: self.code, + message: self.message, + }, + }), + ) + .into_response() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::atomic::AtomicBool; + use std::time::{SystemTime, UNIX_EPOCH}; + + use async_trait::async_trait; + use axum::body::Body; + use bytes::Bytes; + use http::{Request, StatusCode}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + use super::*; + use crate::renderer::{CapturedFrame, UnavailableRenderer}; + use crate::validation::Limits; + + fn app() -> Router { + let validator = RequestValidator::new( + ["http://127.0.0.1:3000".into()], + PathBuf::from("/tmp/animate-it-servo-tests"), + Limits::default(), + ) + .unwrap(); + router(AppState::new(validator, Arc::new(UnavailableRenderer))) + } + + struct FakeRenderer; + + #[async_trait] + impl Renderer for FakeRenderer { + fn name(&self) -> &'static str { + "fake" + } + + fn available(&self) -> bool { + true + } + + fn servo_version(&self) -> Option<&'static str> { + Some("test") + } + + async fn capture( + &self, + request: &CaptureRequest, + _cancelled: Arc, + progress: Option>, + ) -> Result, RenderError> { + let mut images = Vec::new(); + for index in 0..request.frames.len() { + let png = Bytes::from_static(b"test-png"); + if let Some(progress) = &progress { + progress + .send(CapturedFrame { + index, + png: png.clone(), + }) + .await + .unwrap(); + } + images.push(png); + } + Ok(images) + } + } + + struct CancellableRenderer; + + #[async_trait] + impl Renderer for CancellableRenderer { + fn name(&self) -> &'static str { + "cancellable-fake" + } + + fn available(&self) -> bool { + true + } + + fn servo_version(&self) -> Option<&'static str> { + Some("test") + } + + async fn capture( + &self, + _request: &CaptureRequest, + cancelled: Arc, + _progress: Option>, + ) -> Result, RenderError> { + while !cancelled.load(Ordering::Acquire) { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + Err(RenderError::Cancelled) + } + } + + fn capture_json() -> serde_json::Value { + serde_json::json!({ + "request_id": "request-1", + "url": "http://127.0.0.1:3000/motion/internal/render_pages/ticket-1", + "composition": "profile-card", + "width": 1200, + "height": 630, + "duration": 60, + "manifest_version": 1, + "frames": [0], + "transparency": true, + "ready_timeout_ms": 30000 + }) + } + + #[tokio::test] + async fn health_discloses_unavailable_renderer() { + let response = app() + .oneshot(Request::get("/v1/health").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["renderer_available"], false); + assert_eq!(json["status"], "unavailable"); + } + + #[tokio::test] + async fn valid_capture_returns_explicit_not_implemented() { + let response = app() + .oneshot( + Request::post("/v1/captures/frame") + .header("content-type", "application/json") + .body(Body::from(capture_json().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "renderer_unavailable"); + } + + #[tokio::test] + async fn rejects_external_url_before_renderer() { + let mut request = capture_json(); + request["url"] = "https://example.com/motion/internal/render_pages/ticket".into(); + let response = app() + .oneshot( + Request::post("/v1/captures/frame") + .header("content-type", "application/json") + .body(Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn batch_requires_output_directory() { + let response = app() + .oneshot( + Request::post("/v1/captures/frames") + .header("content-type", "application/json") + .body(Body::from(capture_json().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn batch_streams_progress_after_writing_each_frame() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("animate-it-servo-{suffix}")); + std::fs::create_dir_all(&root).unwrap(); + let output = root.join("capture"); + let validator = RequestValidator::new( + ["http://127.0.0.1:3000".into()], + root.clone(), + Limits::default(), + ) + .unwrap(); + let app = router(AppState::new(validator, Arc::new(FakeRenderer))); + let mut request = capture_json(); + request["frames"] = serde_json::json!([0, 1]); + request["output_dir"] = output.to_string_lossy().into_owned().into(); + + let response = app + .oneshot( + Request::post("/v1/captures/frames") + .header("content-type", "application/json") + .body(Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let lines = std::str::from_utf8(&body) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0]["status"], "progress"); + assert_eq!(lines[0]["captured"], 1); + assert_eq!(lines[1]["captured"], 2); + assert_eq!(lines[2]["status"], "complete"); + assert_eq!( + std::fs::read(output.join("frame-00000.png")).unwrap(), + b"test-png" + ); + assert_eq!( + std::fs::read(output.join("frame-00001.png")).unwrap(), + b"test-png" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test] + async fn delete_cancels_an_active_streamed_batch() { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("animate-it-servo-cancel-{suffix}")); + std::fs::create_dir_all(&root).unwrap(); + let validator = RequestValidator::new( + ["http://127.0.0.1:3000".into()], + root.clone(), + Limits::default(), + ) + .unwrap(); + let app = router(AppState::new(validator, Arc::new(CancellableRenderer))); + let mut request = capture_json(); + request["output_dir"] = root.join("capture").to_string_lossy().into_owned().into(); + + let batch = app + .clone() + .oneshot( + Request::post("/v1/captures/frames") + .header("content-type", "application/json") + .body(Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(batch.status(), StatusCode::OK); + + let cancellation = app + .oneshot( + Request::delete("/v1/captures/request-1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(cancellation.status(), StatusCode::OK); + + let body = batch.into_body().collect().await.unwrap().to_bytes(); + let final_line: serde_json::Value = serde_json::from_slice( + std::str::from_utf8(&body) + .unwrap() + .lines() + .last() + .unwrap() + .as_bytes(), + ) + .unwrap(); + assert_eq!(final_line["status"], "error"); + assert_eq!(final_line["error"]["code"], "capture_cancelled"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/servo-renderer/src/validation.rs b/servo-renderer/src/validation.rs new file mode 100644 index 0000000..b60b3b2 --- /dev/null +++ b/servo-renderer/src/validation.rs @@ -0,0 +1,358 @@ +use std::path::{Component, Path, PathBuf}; + +use thiserror::Error; +use url::{Origin, Url}; + +use crate::protocol::{CaptureRequest, PLAYER_MANIFEST_VERSION, PlayerManifest}; + +const MAX_REQUEST_ID_LEN: usize = 128; +const MAX_COMPOSITION_LEN: usize = 200; + +#[derive(Clone, Debug)] +pub struct Limits { + pub max_width: u32, + pub max_height: u32, + pub max_pixels: u64, + pub max_frames: usize, + pub max_frame: u32, + pub min_timeout_ms: u64, + pub max_timeout_ms: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_width: 4096, + max_height: 4096, + max_pixels: 16_777_216, + max_frames: 10_000, + max_frame: 1_000_000, + min_timeout_ms: 100, + max_timeout_ms: 120_000, + } + } +} + +#[derive(Debug, Error, PartialEq)] +pub enum ValidationError { + #[error("request_id must contain 1 to {MAX_REQUEST_ID_LEN} URL-safe characters")] + RequestId, + #[error("composition must contain 1 to {MAX_COMPOSITION_LEN} safe characters")] + Composition, + #[error("URL must use http or https")] + Scheme, + #[error("URL contains credentials")] + Credentials, + #[error("URL origin is not allowed")] + Origin, + #[error("URL path is not an AnimateIt internal render page")] + RenderPath, + #[error("width and height must be non-zero and within configured limits")] + Dimensions, + #[error("frames must be non-empty and within configured limits")] + Frames, + #[error("ready_timeout_ms is outside configured limits")] + Timeout, + #[error("batch capture requires output_dir")] + MissingOutputDirectory, + #[error("output_dir must be an absolute, traversal-free path inside the capture root")] + OutputDirectory, + #[error("player manifest does not match the capture request: {0}")] + Manifest(&'static str), + #[error("invalid URL: {0}")] + InvalidUrl(String), +} + +#[derive(Clone, Debug)] +pub struct RequestValidator { + allowed_origins: Vec, + capture_root: PathBuf, + limits: Limits, +} + +impl RequestValidator { + pub fn new( + allowed_origins: impl IntoIterator, + capture_root: PathBuf, + limits: Limits, + ) -> Result { + let allowed_origins = allowed_origins + .into_iter() + .map(|origin| { + Url::parse(&origin) + .map(|url| url.origin()) + .map_err(|error| ValidationError::InvalidUrl(error.to_string())) + }) + .collect::, _>>()?; + + if allowed_origins.is_empty() || !capture_root.is_absolute() { + return Err(ValidationError::Origin); + } + + Ok(Self { + allowed_origins, + capture_root: normalize_absolute(&capture_root) + .ok_or(ValidationError::OutputDirectory)?, + limits, + }) + } + + pub fn validate(&self, request: &CaptureRequest, batch: bool) -> Result { + if !is_safe_token(&request.request_id, MAX_REQUEST_ID_LEN) { + return Err(ValidationError::RequestId); + } + if !is_safe_token(&request.composition, MAX_COMPOSITION_LEN) { + return Err(ValidationError::Composition); + } + + let url = Url::parse(&request.url) + .map_err(|error| ValidationError::InvalidUrl(error.to_string()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(ValidationError::Scheme); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ValidationError::Credentials); + } + if !self.allowed_origins.contains(&url.origin()) { + return Err(ValidationError::Origin); + } + if !is_render_path(url.path(), &request.composition) { + return Err(ValidationError::RenderPath); + } + + let pixels = u64::from(request.width) * u64::from(request.height); + if request.width == 0 + || request.height == 0 + || request.width > self.limits.max_width + || request.height > self.limits.max_height + || pixels > self.limits.max_pixels + { + return Err(ValidationError::Dimensions); + } + if request.frames.is_empty() + || request.frames.len() > self.limits.max_frames + || request.duration == 0 + || request.manifest_version != PLAYER_MANIFEST_VERSION + || request + .frames + .iter() + .any(|frame| *frame > self.limits.max_frame || *frame >= request.duration) + { + return Err(ValidationError::Frames); + } + if !(self.limits.min_timeout_ms..=self.limits.max_timeout_ms) + .contains(&request.ready_timeout_ms) + { + return Err(ValidationError::Timeout); + } + + match (&request.output_dir, batch) { + (None, true) => return Err(ValidationError::MissingOutputDirectory), + (Some(path), _) => { + let path = + normalize_absolute(Path::new(path)).ok_or(ValidationError::OutputDirectory)?; + if path == self.capture_root || !path.starts_with(&self.capture_root) { + return Err(ValidationError::OutputDirectory); + } + } + (None, false) => {} + } + + Ok(url) + } + + pub fn validate_manifest( + &self, + request: &CaptureRequest, + manifest: &PlayerManifest, + ) -> Result<(), ValidationError> { + validate_manifest(request, manifest) + } + + pub fn output_dir(&self, request: &CaptureRequest) -> Option { + request + .output_dir + .as_deref() + .and_then(|path| normalize_absolute(Path::new(path))) + } +} + +pub fn validate_manifest( + request: &CaptureRequest, + manifest: &PlayerManifest, +) -> Result<(), ValidationError> { + if manifest.version != PLAYER_MANIFEST_VERSION { + return Err(ValidationError::Manifest("unsupported version")); + } + if manifest.id != request.composition { + return Err(ValidationError::Manifest("composition id")); + } + if manifest.width != request.width || manifest.height != request.height { + return Err(ValidationError::Manifest("dimensions")); + } + if manifest.duration == 0 + || request + .frames + .iter() + .any(|frame| *frame >= manifest.duration) + { + return Err(ValidationError::Manifest("frame range")); + } + Ok(()) +} + +fn is_safe_token(value: &str, max_len: usize) -> bool { + !value.is_empty() + && value.len() <= max_len + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn is_render_path(path: &str, composition: &str) -> bool { + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + let internal = segments + .iter() + .position(|segment| *segment == "internal") + .is_some_and(|internal_index| { + matches!( + segments.get(internal_index + 1..), + Some(["render_pages", token]) if is_safe_token(token, MAX_REQUEST_ID_LEN) + ) + }); + let player = segments + .iter() + .position(|segment| *segment == "compositions") + .is_some_and( + |index| matches!(segments.get(index + 1..), Some([id, "player"]) if *id == composition), + ); + internal || player +} + +fn normalize_absolute(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::Normal(part) => normalized.push(part), + Component::CurDir | Component::ParentDir => return None, + } + } + Some(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> CaptureRequest { + CaptureRequest { + request_id: "request-1".into(), + url: "http://127.0.0.1:3000/motion/internal/render_pages/ticket-1".into(), + composition: "profile-card".into(), + width: 1200, + height: 630, + duration: 10, + manifest_version: 1, + frames: vec![0, 5], + transparency: true, + ready_timeout_ms: 30_000, + output_dir: None, + } + } + + fn validator() -> RequestValidator { + RequestValidator::new( + ["http://127.0.0.1:3000".into()], + PathBuf::from("/tmp/animate-it-servo"), + Limits::default(), + ) + .unwrap() + } + + #[test] + fn accepts_internal_render_page() { + assert!(validator().validate(&request(), false).is_ok()); + } + + #[test] + fn accepts_only_the_requested_compositions_player() { + let mut request = request(); + request.url = + "http://127.0.0.1:3000/motion/compositions/profile-card/player?props_json=%7B%7D" + .into(); + assert!(validator().validate(&request, false).is_ok()); + + request.url = "http://127.0.0.1:3000/motion/compositions/other/player".into(); + assert_eq!( + validator().validate(&request, false), + Err(ValidationError::RenderPath) + ); + + request.url = "http://127.0.0.1:3000/motion/compositions/profile-card/filmstrip".into(); + assert_eq!( + validator().validate(&request, false), + Err(ValidationError::RenderPath) + ); + } + + #[test] + fn rejects_file_and_unlisted_origins() { + let mut request = request(); + request.url = "file:///etc/passwd".into(); + assert_eq!( + validator().validate(&request, false), + Err(ValidationError::Scheme) + ); + + request.url = "http://localhost:3000/motion/internal/render_pages/ticket".into(); + assert_eq!( + validator().validate(&request, false), + Err(ValidationError::Origin) + ); + } + + #[test] + fn rejects_public_and_traversal_paths() { + let mut request = request(); + request.url = "http://127.0.0.1:3000/motion/compositions/demo/player".into(); + assert_eq!( + validator().validate(&request, false), + Err(ValidationError::RenderPath) + ); + + request.url = "http://127.0.0.1:3000/motion/internal/render_pages/ticket-1".into(); + request.output_dir = Some("/tmp/animate-it-servo/../escaped".into()); + assert_eq!( + validator().validate(&request, true), + Err(ValidationError::OutputDirectory) + ); + } + + #[test] + fn validates_manifest_identity_dimensions_and_frames() { + let request = request(); + let manifest = PlayerManifest { + version: 1, + id: "profile-card".into(), + width: 1200, + height: 630, + duration: 10, + }; + assert!(validator().validate_manifest(&request, &manifest).is_ok()); + + let mut wrong = manifest; + wrong.duration = 5; + assert_eq!( + validator().validate_manifest(&request, &wrong), + Err(ValidationError::Manifest("frame range")) + ); + } +} diff --git a/spec/animate_it/composition_spec.rb b/spec/animate_it/composition_spec.rb index 74368b4..2316a5c 100644 --- a/spec/animate_it/composition_spec.rb +++ b/spec/animate_it/composition_spec.rb @@ -53,6 +53,18 @@ def render(_view_context) expect(public_composition.public_player_options).to eq(autoplay: true, loop: false) end + it "requires client-driven rendering before opting into Servo" do + composition = Class.new(described_class) + + expect { composition.servo_compatible! } + .to raise_error(ArgumentError, /requires client_driven/) + + composition.client_driven! + composition.servo_compatible! + + expect(composition).to be_servo_compatible + end + it "builds local frame context for active timeline segments" do segment = TestMotionComposition.timeline.active_segments(35).first context = TestMotionComposition.frame_context(frame: 35, props: {}, segment:) diff --git a/spec/animate_it/frame_capturers_spec.rb b/spec/animate_it/frame_capturers_spec.rb new file mode 100644 index 0000000..6e509b8 --- /dev/null +++ b/spec/animate_it/frame_capturers_spec.rb @@ -0,0 +1,83 @@ +require "rails_helper" + +RSpec.describe AnimateIt::FrameCapturers do + let(:composition) do + Class.new(AnimateIt::Composition) do + id "capturer-spec" + client_driven! + servo_compatible! + end + end + + after { AnimateIt.reset! } + + it "selects Servo in auto mode only for certified compositions" do + expect(described_class.backend_for(composition, configured: :auto)).to eq(:servo) + + legacy = Class.new(AnimateIt::Composition) + expect(described_class.backend_for(legacy, configured: :auto)).to eq(:playwright) + end + + it "falls back only for operational capture failures and clears partial frames" do + frames_dir = Pathname(Dir.mktmpdir) + partial = frames_dir.join("frame-00000.png") + partial.binwrite("partial") + primary = instance_double("primary") + fallback = instance_double("fallback") + allow(primary).to receive(:capture_frames).and_raise(AnimateIt::CaptureOperationalError, "offline") + allow(fallback).to receive(:capture_frames).and_return(:complete) + capturer = described_class::Fallback.new(primary:, fallback:, frames_dir:) + + expect(capturer.capture_frames(frame_list: [0], page_url: "http://example.test")).to eq(:complete) + expect(partial).not_to exist + ensure + FileUtils.remove_entry(frames_dir) if frames_dir&.exist? + end + + it "does not hide deterministic render failures behind Chromium fallback" do + primary = instance_double("primary") + fallback = instance_double("fallback") + allow(primary).to receive(:capture_frame).and_raise(AnimateIt::CaptureError, "manifest mismatch") + allow(fallback).to receive(:capture_frame) + capturer = described_class::Fallback.new(primary:, fallback:) + + expect { capturer.capture_frame(frame: 0, page_url: "http://example.test") } + .to raise_error(AnimateIt::CaptureError, /manifest mismatch/) + expect(fallback).not_to have_received(:capture_frame) + end + + it "reports streamed Servo progress and cancels an active request" do + frames_dir = Pathname(Dir.mktmpdir) + previous_endpoint = AnimateIt.config.servo_endpoint + previous_origins = AnimateIt.config.servo_allowed_origins + AnimateIt.config.servo_endpoint = "http://127.0.0.1:4178" + AnimateIt.config.servo_allowed_origins = ["http://example.test"] + servo = described_class::Servo.new(composition:, host: "http://example.test", frames_dir:) + allow(servo).to receive(:stream_request) do |_request, &block| + response = Net::HTTPOK.new("1.1", "200", "OK") + block.call(response, ['{"status":"progress","captured":1}']) + block.call(response, ['{"status":"progress","captured":2}']) + end + allow(servo).to receive(:cancel) + checks = 0 + progress = [] + + status = servo.capture_frames( + frame_list: [3, 4], + page_url: "http://example.test/player", + on_progress: ->(frame, total) { progress << [frame, total] }, + cancel_check: lambda { + checks += 1 + checks >= 3 + } + ) + + expect(status).to eq(:cancelled) + expect(progress).to eq([[4, 2]]) + expect(servo).to have_received(:cancel).with(kind_of(String)) + ensure + AnimateIt.config.servo_endpoint = previous_endpoint + AnimateIt.config.servo_allowed_origins = previous_origins + FileUtils.remove_entry(frames_dir) if frames_dir&.exist? + end +end diff --git a/spec/animate_it/package_spec.rb b/spec/animate_it/package_spec.rb index 223951b..a00e1de 100644 --- a/spec/animate_it/package_spec.rb +++ b/spec/animate_it/package_spec.rb @@ -18,6 +18,8 @@ "app/views/animate_it/frames/player.html.haml", "lib/animate_it/runtime/runtime.js", "lib/animate_it/verification.rb", + "servo-renderer/Cargo.toml", + "servo-renderer/src/main.rs", "README.md", "CHANGELOG.md", "MIT-LICENSE" diff --git a/spec/animate_it/props_schema_spec.rb b/spec/animate_it/props_schema_spec.rb new file mode 100644 index 0000000..3c01e38 --- /dev/null +++ b/spec/animate_it/props_schema_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +RSpec.describe AnimateIt::PropsSchema do + subject(:schema) do + described_class.new.tap do |props| + props.string :title, default: "Hello" + props.integer :count, default: 1 + props.number :ratio, default: 0.5 + props.boolean :enabled, default: true + props.asset :avatar, default: "/avatar.png" + end + end + + it "strictly resolves declared render props without changing regular resolution" do + expect(schema.resolve("unknown" => "accepted")).to include(unknown: "accepted") + + expect( + schema.resolve_for_render( + { "title" => "Rendered", "count" => 2, "ratio" => 1.25, "enabled" => false }, + render_origin: "https://example.test" + ) + ).to include(title: "Rendered", count: 2, ratio: 1.25, enabled: false) + end + + it "rejects unknown props and incorrect declared scalar types" do + expect { schema.resolve_for_render("title", render_origin: "https://example.test") } + .to raise_error(AnimateIt::RenderPropsError, /must be a hash/) + expect { schema.resolve_for_render({ surprise: true }, render_origin: "https://example.test") } + .to raise_error(AnimateIt::RenderPropsError, /Unknown render props/) + expect { schema.resolve_for_render({ count: "2" }, render_origin: "https://example.test") } + .to raise_error(AnimateIt::RenderPropsError, /count.*integer/) + end + + it "restricts absolute assets to the render or configured asset origins" do + expect( + schema.resolve_for_render( + { avatar: "https://cdn.example.test/avatar.png" }, + render_origin: "https://example.test", + asset_origins: ["https://cdn.example.test"] + ) + ).to include(avatar: "https://cdn.example.test/avatar.png") + + expect do + schema.resolve_for_render( + { avatar: "https://untrusted.example/avatar.png" }, + render_origin: "https://example.test" + ) + end.to raise_error(AnimateIt::RenderPropsError, /allowed origin/) + end + + it "enforces individual and total serialized size limits" do + allow(AnimateIt.config).to receive(:render_prop_string_max_bytes).and_return(3) + expect { schema.resolve_for_render({ title: "four" }, render_origin: "https://example.test") } + .to raise_error(AnimateIt::RenderPropsError, /title.*exceeds/) + + allow(AnimateIt.config).to receive(:render_prop_string_max_bytes).and_return(100) + allow(AnimateIt.config).to receive(:render_props_max_bytes).and_return(10) + expect { schema.resolve_for_render({}, render_origin: "https://example.test") } + .to raise_error(AnimateIt::RenderPropsError, /Rendered props exceed/) + end +end diff --git a/spec/animate_it/verification_spec.rb b/spec/animate_it/verification_spec.rb index 4c5bb80..43cf69a 100644 --- a/spec/animate_it/verification_spec.rb +++ b/spec/animate_it/verification_spec.rb @@ -149,4 +149,46 @@ def rgba(*pixels) .with("(n) => window.__animateIt.setFrame(n)", arg: 3).twice end end + + describe "Servo certification" do + let(:composition) do + class_double( + AnimateIt::Composition, + id: "servo-motion", + width: 100, + height: 100, + duration_in_frames: 10, + structure_layers: [], + servo_compatible?: true, + chapters: instance_double(AnimateIt::Chapters, as_json: []) + ) + end + + it "handles browser documents without the native animation API" do + servo_verification = described_class.new( + composition:, + host: "http://localhost:3000", + candidate_backend: :servo + ) + page = instance_double(Playwright::Page) + allow(page).to receive(:evaluate).and_return(0) + + expect { servo_verification.send(:ensure_servo_certifiable!, page) }.not_to raise_error + expect(page).to have_received(:evaluate).with(/typeof document\.getAnimations/) + end + + it "rejects nondeterministic Servo PNG bytes" do + servo_verification = described_class.new( + composition:, + host: "http://localhost:3000", + candidate_backend: :servo + ) + capturer = instance_double(AnimateIt::FrameCapturers::Servo) + allow(servo_verification).to receive(:servo_capturer).and_return(capturer) + allow(capturer).to receive(:capture_frame).and_return("same", "same", "changed", "same", "same") + + expect { servo_verification.send(:ensure_servo_deterministic!, 0) } + .to raise_error(AnimateIt::Error, /different PNG bytes/) + end + end end diff --git a/spec/dummy/app/controllers/embeds_controller.rb b/spec/dummy/app/controllers/embeds_controller.rb index 3870092..a87bbb4 100644 --- a/spec/dummy/app/controllers/embeds_controller.rb +++ b/spec/dummy/app/controllers/embeds_controller.rb @@ -8,4 +8,8 @@ def broken; end def headless_erb; end def headless_haml; end + + def image + render animate_it: { composition: "client-runtime-spec", frame: 3, props: {}, cache: true } + end end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index b910fa1..99fc94a 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -3,5 +3,6 @@ get "embed-broken-spec", to: "embeds#broken" get "embed-headless-erb", to: "embeds#headless_erb" get "embed-headless-haml", to: "embeds#headless_haml" + get "render-image-spec", to: "embeds#image" mount AnimateIt::Engine, at: AnimateIt.config.mount_path end diff --git a/spec/requests/image_renderer_spec.rb b/spec/requests/image_renderer_spec.rb new file mode 100644 index 0000000..81c6997 --- /dev/null +++ b/spec/requests/image_renderer_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe "AnimateIt image rendering", type: :request do + let(:cache) { ActiveSupport::Cache::MemoryStore.new } + let(:capturer) { instance_double("capturer", version: "test-browser", capture_frame: "png-bytes") } + + around do |example| + previous = AnimateIt.config.internal_rendering + AnimateIt.config.internal_rendering = true + example.run + ensure + AnimateIt.config.internal_rendering = previous + end + + before do + allow(Rails).to receive(:cache).and_return(cache) + allow(AnimateIt::FrameCapturers).to receive(:build).and_return(capturer) + end + + it "returns an inline PNG and reuses its cached bytes" do + allow(AnimateIt::RenderTicketStore).to receive(:delete).and_call_original + get "/render-image-spec" + + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq("image/png") + expect(response.headers["Content-Disposition"]).to eq("inline") + expect(response.body).to eq("png-bytes") + expect(AnimateIt::RenderTicketStore).to have_received(:delete).with(kind_of(String)) + etag = response.headers.fetch("ETag") + + get "/render-image-spec", headers: { "If-None-Match" => etag } + + expect(response).to have_http_status(:not_modified) + expect(capturer).to have_received(:capture_frame).once + end + + it "serves opaque render tickets only while internal rendering is enabled" do + ticket = AnimateIt::RenderTicketStore.create!(composition: ClientRuntimeSpecVideo, props: {}) + + get "#{AnimateIt.config.mount_path}/internal/render_pages/#{ticket}" + expect(response).to have_http_status(:ok) + expect(response.parsed_body.at_css("script[data-animate-it-manifest]")).to be_present + + # Auto fallback may load the same ticket in Servo and then Playwright. + get "#{AnimateIt.config.mount_path}/internal/render_pages/#{ticket}" + expect(response).to have_http_status(:ok) + + AnimateIt::RenderTicketStore.delete(ticket) + get "#{AnimateIt.config.mount_path}/internal/render_pages/#{ticket}" + expect(response).to have_http_status(:not_found) + + AnimateIt.config.internal_rendering = false + get "#{AnimateIt.config.mount_path}/internal/render_pages/#{ticket}" + expect(response).to have_http_status(:not_found) + end + + it "fails early when Rails cache cannot retain render tickets" do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::NullStore.new) + + expect { AnimateIt::RenderTicketStore.create!(composition: ClientRuntimeSpecVideo, props: {}) } + .to raise_error(AnimateIt::Error, /shared, writable Rails cache/) + end +end