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