Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add screencast functionality #494

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions lib/ferrum/browser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/ferrum/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
102 changes: 102 additions & 0 deletions lib/ferrum/page/screencast.rb
Original file line number Diff line number Diff line change
@@ -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
85 changes: 85 additions & 0 deletions spec/page/screencast_spec.rb
Original file line number Diff line number Diff line change
@@ -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 eq number_of_frames_after_delay
end
end
end
end
Loading