diff --git a/README.md b/README.md index 5d9e051e..ce2888f0 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,54 @@ page.go_to("https://google.com/") page.mhtml(path: "google.mhtml") # => 87742 ``` +## Screencast + +#### start_screencast(\*\*options) {|data, metadata, session_id| block } + +Starts sending each frame to the given block. + +* options `Hash` + * :format `Symbol` `:jpeg` | `:png` The format the image should be returned in. + * :quality `Integer` The image quality. **Note:** 0-100 works for JPEG only. + * :max_width `Integer` Maximum screencast frame width. + * :max_height `Integer` Maximum screencast frame height. + * :every_nth_frame `Integer` Send every n-th frame. + +* Block inputs: + * data `String` Base64-encoded compressed image. + * metadata `Hash` Screencast frame metadata. + * 'offsetTop' `Integer` Top offset in DIP. + * 'pageScaleFactor' `Integer` Page scale factor. + * 'deviceWidth' `Integer` Device screen width in DIP. + * 'deviceHeight' `Integer` Device screen height in DIP. + * 'scrollOffsetX' `Integer` Position of horizontal scroll in CSS pixels. + * 'scrollOffsetY' `Integer` Position of vertical scroll in CSS pixels. + * 'timestamp' `Float` (optional) Frame swap timestamp in seconds since Unix epoch. + * session_id `Integer` Frame number. + +```ruby +require 'base64' + +page.go_to("https://apple.com/ipad") + +page.start_screencast(format: :jpeg, quality: 75) do |data, metadata| + timestamp_ms = metadata['timestamp'] * 1000 + File.binwrite("image_#{timestamp_ms.to_i}.jpg", Base64.decode64(data)) +end + +sleep 10 + +page.stop_screencast +``` + +> ### 📝 NOTE +> +> Chrome only sends new frames while page content is changing. For example, if +> there is an animation or a video on the page, Chrome sends frames at the rate +> requested. On the other hand, if the page is nothing but a wall of static text, +> Chrome sends frames while the page renders. Once Chrome has finished rendering +> the page, it sends no more frames until something changes (e.g., navigating to +> another location). ## Network diff --git a/lib/ferrum/browser.rb b/lib/ferrum/browser.rb index 9626ad1d..bcd81b2e 100644 --- a/lib/ferrum/browser.rb +++ b/lib/ferrum/browser.rb @@ -23,6 +23,7 @@ class Browser headers cookies network downloads mouse keyboard screenshot pdf mhtml viewport_size device_pixel_ratio + start_screencast stop_screencast frames frame_by main_frame evaluate evaluate_on evaluate_async execute evaluate_func add_script_tag add_style_tag bypass_csp diff --git a/lib/ferrum/page.rb b/lib/ferrum/page.rb index c019b831..8526789a 100644 --- a/lib/ferrum/page.rb +++ b/lib/ferrum/page.rb @@ -10,6 +10,7 @@ require "ferrum/network" require "ferrum/downloads" require "ferrum/page/frames" +require "ferrum/page/screencast" require "ferrum/page/screenshot" require "ferrum/page/animation" require "ferrum/page/tracing" @@ -27,6 +28,7 @@ class Page delegate %i[base_url default_user_agent timeout timeout=] => :@options include Animation + include Screencast include Screenshot include Frames include Stream diff --git a/lib/ferrum/page/screencast.rb b/lib/ferrum/page/screencast.rb new file mode 100644 index 00000000..11240d3e --- /dev/null +++ b/lib/ferrum/page/screencast.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Ferrum + class Page + module Screencast + # Starts yielding each frame to the given block. + # + # @param [Hash{Symbol => Object}] opts + # + # @option opts [:jpeg, :png] :format + # The format the image should be returned in. + # + # @option opts [Integer] :quality + # The image quality. **Note:** 0-100 works for JPEG only. + # + # @option opts [Integer] :max_width + # Maximum screencast frame width. + # + # @option opts [Integer] :max_height + # Maximum screencast frame height. + # + # @option opts [Integer] :every_nth_frame + # Send every n-th frame. + # + # @yield [data, metadata, session_id] + # The given block receives the screencast frame along with metadata + # about the frame and the screencast session ID. + # + # @yieldparam data [String] + # Base64-encoded compressed image. + # + # @yieldparam metadata [Hash{String => Object}] + # Screencast frame metadata. + # + # @option metadata [Integer] 'offsetTop' + # Top offset in DIP. + # + # @option metadata [Integer] 'pageScaleFactor' + # Page scale factor. + # + # @option metadata [Integer] 'deviceWidth' + # Device screen width in DIP. + # + # @option metadata [Integer] 'deviceHeight' + # Device screen height in DIP. + # + # @option metadata [Integer] 'scrollOffsetX' + # Position of horizontal scroll in CSS pixels. + # + # @option metadata [Integer] 'scrollOffsetY' + # Position of vertical scroll in CSS pixels. + # + # @option metadata [Float] 'timestamp' + # (optional) Frame swap timestamp in seconds since Unix epoch. + # + # @yieldparam session_id [Integer] + # Frame number. + # + # @example + # require 'base64' + # + # page.go_to("https://apple.com/ipad") + # + # page.start_screencast(format: :jpeg, quality: 75) do |data, metadata| + # timestamp_ms = metadata['timestamp'] * 1000 + # File.binwrite("image_#{timestamp_ms.to_i}.jpg", Base64.decode64(data)) + # end + # + # sleep 10 + # + # page.stop_screencast + # + def start_screencast(**opts) + options = opts.transform_keys { START_SCREENCAST_KEY_CONV.fetch(_1, _1) } + response = command("Page.startScreencast", **options) + + if (error_text = response["errorText"]) # https://cs.chromium.org/chromium/src/net/base/net_error_list.h + raise "Starting screencast failed (#{error_text})" + end + + on("Page.screencastFrame") do |params| + data, metadata, session_id = params.values_at("data", "metadata", "sessionId") + + command("Page.screencastFrameAck", sessionId: session_id) + + yield data, metadata, session_id + end + end + + # Stops sending each frame. + def stop_screencast + command("Page.stopScreencast") + end + + START_SCREENCAST_KEY_CONV = { + max_width: :maxWidth, + max_height: :maxHeight, + every_nth_frame: :everyNthFrame + }.freeze + end + end +end diff --git a/spec/page/screencast_spec.rb b/spec/page/screencast_spec.rb new file mode 100644 index 00000000..db9a0234 --- /dev/null +++ b/spec/page/screencast_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "base64" +require "image_size" +require "pdf/reader" +require "chunky_png" +require "ferrum/rgba" + +describe Ferrum::Page::Screencast do + after(:example) do + browser.stop_screencast + + Dir.glob("#{PROJECT_ROOT}/spec/tmp/screencast_frame*") { File.delete _1 } + end + + describe "#start_screencast" do + context "when the page has no changing content" do + it "should continue screencasting frames" do + browser.go_to "/ferrum/long_page" + + format = :jpeg + count = 0 + browser.start_screencast(format: format) do |data, _metadata, _session_id| + count += 1 + path = "#{PROJECT_ROOT}/spec/tmp/screencast_frame_#{format('%05d', count)}.#{format}" + File.binwrite(path, Base64.decode64(data)) + end + + sleep 5 + + expect(Dir.glob("#{PROJECT_ROOT}/spec/tmp/screencast_frame_*").count).to be_positive.and be < 5 + + browser.stop_screencast + end + end + + context "when the page content continually changes" do + it "should stop screencasting frames when the page has finished rendering" do + browser.go_to "/ferrum/animation" + + format = :jpeg + count = 0 + browser.start_screencast(format: format) do |data, _metadata, _session_id| + count += 1 + path = "#{PROJECT_ROOT}/spec/tmp/screencast_frame_#{format('%05d', count)}.#{format}" + File.binwrite(path, Base64.decode64(data)) + end + + sleep 5 + + expect(Dir.glob("#{PROJECT_ROOT}/spec/tmp/screencast_frame_*").count).to be > 250 + + browser.stop_screencast + end + end + end + + describe "#stop_screencast" do + context "when the page content continually changes" do + it "should stop screencasting frames when the page has finished rendering" do + browser.go_to "/ferrum/animation" + + format = :jpeg + count = 0 + browser.start_screencast(format: format) do |data, _metadata, _session_id| + count += 1 + path = "#{PROJECT_ROOT}/spec/tmp/screencast_frame_#{format('%05d', count)}.#{format}" + File.binwrite(path, Base64.decode64(data)) + end + + sleep 5 + + browser.stop_screencast + + number_of_frames_after_stop = Dir.glob("#{PROJECT_ROOT}/spec/tmp/screencast_frame_*").count + + sleep 2 + + number_of_frames_after_delay = Dir.glob("#{PROJECT_ROOT}/spec/tmp/screencast_frame_*").count + + expect(number_of_frames_after_stop).to be <= number_of_frames_after_delay + end + end + end +end