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

Lazy load videos and video posters #1596

Merged
merged 18 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 10 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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
* @access private
*/
final class Image_Prioritizer_Video_Tag_Visitor extends Image_Prioritizer_Tag_Visitor {
/**
* Whether the lazy-loading script was added to the body.
*
* @var bool
*/
protected $added_lazy_script = false;

/**
* Visits a tag.
Expand All @@ -35,18 +41,16 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool {
return false;
}

// TODO: If $context->url_metric_group_collection->get_element_max_intersection_ratio( $xpath ) is 0.0, then the video is not in any initial viewport and the VIDEO tag could get the preload=none attribute added.

$poster = $this->get_poster( $context );

if ( null !== $poster ) {
$this->reduce_poster_image_size( $poster, $context );
$this->preload_poster_image( $poster, $context );

return true;
}

return false;
$this->lazy_load_videos( $poster, $context );

return true;
}

/**
Expand Down Expand Up @@ -129,7 +133,7 @@ private function preload_poster_image( string $poster, OD_Tag_Visitor_Context $c

$xpath = $processor->get_xpath();

// If this element is the LCP (for a breakpoint group), add a preload link for it.
// If this element is the LCP (for a breakpoint group), add a preload link for the poster image.
foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) {
$link_attributes = array(
'rel' => 'preload',
Expand All @@ -151,4 +155,56 @@ private function preload_poster_image( string $poster, OD_Tag_Visitor_Context $c
);
}
}

/**
* Adjusts `autoplay` and `preload` values for videos outside initial viewport.
*
* @since n.e.x.t
*
* @param non-empty-string|null $poster Poster image URL.
* @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at an embed block.
*/
private function lazy_load_videos( ?string $poster, OD_Tag_Visitor_Context $context ): void {
$processor = $context->processor;

$xpath = $processor->get_xpath();

$intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $xpath );

// Set preload="auto" if the video is the LCP element among all viewports.
$common_lcp_element = $context->url_metric_group_collection->get_common_lcp_element();
if ( null !== $common_lcp_element && $xpath === $common_lcp_element['xpath'] ) {
$processor->set_attribute( 'preload', 'auto' );
return;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todo: this should happen even if it's not the LCP element but still in the viewport. But then only if that's the case across all viewports.

Something like this perhaps:

Suggested change
// Set preload="auto" if the video is the LCP element among all viewports.
$common_lcp_element = $context->url_metric_group_collection->get_common_lcp_element();
if ( null !== $common_lcp_element && $xpath === $common_lcp_element['xpath'] ) {
$processor->set_attribute( 'preload', 'auto' );
return;
}
// Set preload="auto" if the video is in the initial viewport among all breakpoints.
if ( $context->url_metric_group_collection->is_every_group_populated() && $intersection_ratio > 0 ) {
$processor->set_attribute( 'preload', 'auto' );
return;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with $context->url_metric_group_collection->is_every_group_populated() is often tablets or phablets don't end up getting URL Metrics gathered for them. So I worry this would never end up returning true. Similar to #1404.

So maybe this should use $context->url_metrics_group_collection->is_any_group_populated() as well?

Maybe it should be either of those two conditions. Either:

  • it is the common LCP element among gathered URL metrics, or
  • there are URL Metrics gathered for any group and the max intersection ratio is greater than 0.

Copy link
Member

@felixarntz felixarntz Oct 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@westonruter Given that this problem is now becoming relevant in a few places and the relatively low usage of tablet devices appears to be the root of the issue, maybe we should provide an additional helper method in OD? Maybe something like is_every_critical_group_populated() (where only certain breakpoints would be considered critical, could be filtered)? Or, we could consider giving the breakpoints more human-friendly names like phone, tablet, desktop for instance, and then have a method like is_every_group_populated( array $groups ) where you could pass those you care about as a string?

is_every_group_populated() has the problem we're seeing here, but is_any_group_populated() doesn't sound like an adequate solution either because why would we arbitrarily consider it fine to have either desktop or mobile data fully populated?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that idea. In #1602 I've proposed adding a get_first_group() and get_last_group() to OD_URL_Metric_Group_Collection which would return the mobile and desktop URL metric groups respectively. Based on the low usage of tablets relative to desktop and mobile, I think in reality the critical groups will always be the first and last, representing the outer bounds of the viewport widths.

So with that PR, the result would be to determine whether the mobile and desktop groups are populated, this logic could be employed:

(
    $context->url_metric_group_collection->get_first_group()->count() > 0 
    && 
    $context->url_metric_group_collection->get_last_group()->count() > 0
)

I think this could be used here instead of $context->url_metric_group_collection->is_every_group_populated() and $context->url_metric_group_collection->is_any_group_populated().

Caveat: It could be that for a particular site the traffic is almost exclusively coming by tablet visitors. If this is the case, a site owner could indicate this by making it so that there is only one group:

add_filter( 'od_breakpoint_max_widths', '__return_empty_array' );

In this case the first and last group are the same group.


// TODO: What if URL metrics aren't available for all viewports yet?
if ( $intersection_ratio > 0 ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if $intersection_ratio > 0 is the right condition here because it could very well be that the URL Metrics are not fully populated. For example, it could be that if URL Metrics were gathered for mobile visitors in which the video is not in the viewport. For desktop visitors, the video could be in the viewport but no URL Metrics have been collected yet for it. Here the $intersection_ratio would be 0.0 since mobile the video is not visible on any mobile visitors but this would result in the video being lazy-loaded which would end up hurting desktop visitors for whom the video is visible in the viewport.

I realize this may be a problem for lazy-loaded images and embeds as well.

To deal with this, the lazy-loading optimization should perhaps depend on the smallest viewport group (mobile) being populated as well as the largest group (desktop). But if a site only ever gets mobile or desktop visitors, then this could indefinitely delay the optimization from being applied, which isn't good. Since this is a wholistic problem/concern/question for videos, images, and embeds, it probably isn't something needing to be addressed in this PR and we can open an issue to consider further.

Related: #1595 (comment)

For prioritizing the loading of LCP images the situation is a bit different since we aren't dependent on the one fetchpriority=high attribute being on the IMG tag. Instead, we're able to add preload links with fetchpriority=high that also have media queries to ensure that the populated URL Metrics will ensure the image gets loaded properly.

Also related #1404

return;
}

$preload = $processor->get_attribute( 'preload' );
if ( 'none' !== $preload ) {
$processor->set_attribute( 'data-original-preload', null !== $preload ? $preload : 'default' );
$processor->set_attribute( 'preload', 'none' );
swissspidy marked this conversation as resolved.
Show resolved Hide resolved
$processor->add_class( 'wp-lazy-video' );
}

if ( null !== $processor->get_attribute( 'autoplay' ) ) {
$processor->set_attribute( 'data-original-autoplay', true );
$processor->remove_attribute( 'autoplay' );
swissspidy marked this conversation as resolved.
Show resolved Hide resolved
$processor->add_class( 'wp-lazy-video' );
}

if ( null !== $poster ) {
$processor->set_attribute( 'data-original-poster', $poster );
$processor->remove_attribute( 'poster' );
swissspidy marked this conversation as resolved.
Show resolved Hide resolved
$processor->add_class( 'wp-lazy-video' );
}

if ( ! $this->added_lazy_script ) {
$processor->append_body_html( wp_get_inline_script_tag( image_prioritizer_get_lazy_load_script(), array( 'type' => 'module' ) ) );
$this->added_lazy_script = true;
}
}
}
52 changes: 52 additions & 0 deletions plugins/image-prioritizer/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,55 @@
}

add_action( 'od_init', 'image_prioritizer_init' );

/**
* Gets the script to lazy-load videos.
*
* Load a video and its poster image when it approaches the viewport using an IntersectionObserver.
*
* Handles 'autoplay' and 'preload' attributes accordingly.
*
* @since n.e.x.t
*/
function image_prioritizer_get_lazy_load_script(): string {
return <<<JS
swissspidy marked this conversation as resolved.
Show resolved Hide resolved
const lazyVideoObserver = new IntersectionObserver(
( entries ) => {
for ( const entry of entries ) {
if ( entry.isIntersecting ) {
/** @type {HTMLVideoElement} */
const video = entry.target;

if ( video.hasAttribute( 'data-original-poster' ) ) {
video.setAttribute( 'poster', video.getAttribute( 'data-original-poster' ) );
}

if ( video.hasAttribute( 'data-original-autoplay' ) ) {
video.setAttribute( 'autoplay', 'autoplay' );
}

if ( video.hasAttribute( 'data-original-preload' ) ) {
const preload = video.getAttribute( 'data-original-poster' );
if ( 'default' === preload ) {
video.removeAttribute( 'preload' );
} else {
video.setAttribute( 'preload', preload );
}
}

lazyVideoObserver.unobserve( video );
}
}
},
{
rootMargin: '100% 0% 100% 0%',
threshold: 0
}
);

const videos = document.querySelectorAll( 'video.wp-lazy-video' );
for ( const video of videos ) {
lazyVideoObserver.observe( video );
}
JS;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
return array(
'set_up' => static function ( Test_Image_Prioritizer_Helper $test_case ): void {
$breakpoint_max_widths = array( 480, 600, 782 );

add_filter(
'od_breakpoint_max_widths',
static function () use ( $breakpoint_max_widths ) {
return $breakpoint_max_widths;
}
);

foreach ( array_merge( $breakpoint_max_widths, array( 1000 ) ) as $viewport_width ) {
OD_URL_Metrics_Post_Type::store_url_metric(
od_get_url_metrics_slug( od_get_normalized_query_vars() ),
$test_case->get_sample_url_metric(
array(
'viewport_width' => $viewport_width,
'elements' => array(
array(
'isLCP' => true,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 1.0,
),
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 0.1,
),
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 0.0,
),
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 0.0,
),
),
)
)
);
}
},
'buffer' => '
<html lang="en">
<head>
<meta charset="utf-8">
<title>...</title>
</head>
<body>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="none" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="auto" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="auto" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="metadata" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" autoplay></video>
</body>
</html>
',
'expected' => '
<html lang="en">
<head>
<meta charset="utf-8">
<title>...</title>
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/poster.jpg" media="screen">
</head>
<body>
<video data-od-replaced-preload="none" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]" class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="auto" autoplay></video>
<video data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]" class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="auto" autoplay></video>
<video data-od-added-data-original-autoplay data-od-added-data-original-poster data-od-added-data-original-preload data-od-removed-autoplay data-od-removed-poster="https://example.com/poster.jpg" data-od-replaced-class="desktop" data-od-replaced-preload="auto" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::VIDEO]" data-original-autoplay data-original-poster="https://example.com/poster.jpg" data-original-preload="auto" class="desktop wp-lazy-video" width="1200" height="500" src="https://example.com/header.mp4" preload="none" ></video>
<video data-od-added-data-original-autoplay data-od-added-data-original-poster data-od-added-data-original-preload data-od-removed-autoplay data-od-removed-poster="https://example.com/poster.jpg" data-od-replaced-class="desktop" data-od-replaced-preload="metadata" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[4][self::VIDEO]" data-original-autoplay data-original-poster="https://example.com/poster.jpg" data-original-preload="metadata" class="desktop wp-lazy-video" width="1200" height="500" src="https://example.com/header.mp4" preload="none" ></video>
<video data-od-added-data-original-autoplay data-od-added-data-original-poster data-od-added-data-original-preload data-od-added-preload data-od-removed-autoplay data-od-removed-poster="https://example.com/poster.jpg" data-od-replaced-class="desktop" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[5][self::VIDEO]" data-original-autoplay data-original-poster="https://example.com/poster.jpg" data-original-preload="default" preload="none" class="desktop wp-lazy-video" width="1200" height="500" src="https://example.com/header.mp4" ></video>
<script type="module">/* const lazyVideoObserver ... */</script>
<script type="module">/* import detect ... */</script>
</body>
</html>
',
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
return array(
'set_up' => static function ( Test_Image_Prioritizer_Helper $test_case ): void {
$breakpoint_max_widths = array( 480, 600, 782 );

add_filter(
'od_breakpoint_max_widths',
static function () use ( $breakpoint_max_widths ) {
return $breakpoint_max_widths;
}
);

foreach ( $breakpoint_max_widths as $non_desktop_viewport_width ) {
OD_URL_Metrics_Post_Type::store_url_metric(
od_get_url_metrics_slug( od_get_normalized_query_vars() ),
$test_case->get_sample_url_metric(
array(
'viewport_width' => $non_desktop_viewport_width,
'elements' => array(
array(
'isLCP' => true,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 1.0,
),
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 0.1,
),
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 0.0,
),
array(
'isLCP' => false,
'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::VIDEO]',
'boundingClientRect' => $test_case->get_sample_dom_rect(),
'intersectionRatio' => 0.0,
),
),
)
)
);
}
},
'buffer' => '
<html lang="en">
<head>
<meta charset="utf-8">
<title>...</title>
</head>
<body>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="none" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="auto" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="auto" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="metadata" autoplay></video>
<video class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" autoplay></video>
</body>
</html>
',
'expected' => '
<html lang="en">
<head>
<meta charset="utf-8">
<title>...</title>
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/poster.jpg" media="screen and (max-width: 782px)">
</head>
<body>
<video data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]" class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="none" autoplay></video>
<video data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]" class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4" preload="auto" autoplay></video>
<video data-od-added-data-original-autoplay data-od-added-data-original-poster data-od-added-data-original-preload data-od-removed-autoplay data-od-removed-poster="https://example.com/poster.jpg" data-od-replaced-class="desktop" data-od-replaced-preload="auto" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[3][self::VIDEO]" data-original-autoplay data-original-poster="https://example.com/poster.jpg" data-original-preload="auto" class="desktop wp-lazy-video" width="1200" height="500" src="https://example.com/header.mp4" preload="none" ></video>
<video data-od-added-data-original-autoplay data-od-added-data-original-poster data-od-added-data-original-preload data-od-removed-autoplay data-od-removed-poster="https://example.com/poster.jpg" data-od-replaced-class="desktop" data-od-replaced-preload="metadata" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[4][self::VIDEO]" data-original-autoplay data-original-poster="https://example.com/poster.jpg" data-original-preload="metadata" class="desktop wp-lazy-video" width="1200" height="500" src="https://example.com/header.mp4" preload="none" ></video>
<video data-od-added-data-original-autoplay data-od-added-data-original-poster data-od-added-data-original-preload data-od-added-preload data-od-removed-autoplay data-od-removed-poster="https://example.com/poster.jpg" data-od-replaced-class="desktop" data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[5][self::VIDEO]" data-original-autoplay data-original-poster="https://example.com/poster.jpg" data-original-preload="default" preload="none" class="desktop wp-lazy-video" width="1200" height="500" src="https://example.com/header.mp4" ></video>
<script type="module">/* const lazyVideoObserver ... */</script>
<script type="module">/* import detect ... */</script>
</body>
</html>
',
);
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ static function () use ( $breakpoint_max_widths ) {
<link data-od-added-tag rel="preload" fetchpriority="high" as="image" href="https://example.com/poster.jpg" media="screen">
</head>
<body>
<video data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]" class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4"></video>
<video data-od-added-preload data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]" preload="auto" class="desktop" poster="https://example.com/poster.jpg" width="1200" height="500" src="https://example.com/header.mp4"></video>
<script type="module">/* import detect ... */</script>
</body>
</html>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ static function () use ( $breakpoint_max_widths ) {
</head>
<body>
<img data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]" class="mobile" src="https://example.com/mobile-header.jpg" alt="Mobile Logo" width="800" height="600">
<video class="desktop" width="1200" height="500">
<video data-od-xpath="/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]" class="desktop" width="1200" height="500">
<source src="https://example.com/header.webm" type="video/webm">
<source src="https://example.com/header.mp4" type="video/mp4">
</video>
Expand Down
17 changes: 13 additions & 4 deletions plugins/image-prioritizer/tests/test-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,19 @@ public function test_image_prioritizer_register_tag_visitors( callable $set_up,
$set_up( $this, $this::factory() );

$buffer = is_string( $buffer ) ? $buffer : $buffer();
$buffer = preg_replace(
':<script type="module">.+?</script>:s',
'<script type="module">/* import detect ... */</script>',
od_optimize_template_output_buffer( $buffer )
$buffer = od_optimize_template_output_buffer( $buffer );
$buffer = preg_replace_callback(
':(<script type="module">)(.+?)(</script>):s',
static function ( $matches ) {
array_shift( $matches );
if ( false !== strpos( $matches[1], 'import detect' ) ) {
$matches[1] = '/* import detect ... */';
} elseif ( false !== strpos( $matches[1], 'const lazyVideoObserver' ) ) {
$matches[1] = '/* const lazyVideoObserver ... */';
}
return implode( '', $matches );
},
$buffer
);

$expected = is_string( $expected ) ? $expected : $expected();
Expand Down