Skip to content

Commit

Permalink
Upgrade poster image to higher resolution webp if possible (#167)
Browse files Browse the repository at this point in the history
  • Loading branch information
paulirish authored Mar 3, 2024
1 parent 498a4f3 commit eeadc83
Show file tree
Hide file tree
Showing 6 changed files with 342 additions and 7 deletions.
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true

# prettier custom things
quote_type = single
max_line_length = 160

[src/]
indent_size = 4
indent_style = space
Expand Down
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ If you want to provide a custom poster image, just set it as the background-imag
<lite-youtube videoid="ogfYd705cRs" style="background-image: url('https://i.ytimg.com/vi/ogfYd705cRs/hqdefault.jpg');"></lite-youtube>
```

Demo: https://paulirish.github.io/lite-youtube-embed/variants/custom-poster-image.html
Use [poster-image-availability.html](https://paulirish.github.io/lite-youtube-embed/testpage/poster-image-availability.html) to determine what poster images are available for you.

## Add a video title

Expand Down
35 changes: 30 additions & 5 deletions src/lite-yt-embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,13 @@ class LiteYTEmbed extends HTMLElement {
this.dataset.title = this.getAttribute('title') || "";

/**
* Lo, the youtube placeholder image! (aka the thumbnail, poster image, etc)
* Lo, the youtube poster image! (aka the thumbnail, image placeholder, etc)
*
* See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md
*
* TODO: Do the sddefault->hqdefault fallback
* - When doing this, apply referrerpolicy (https://github.com/ampproject/amphtml/pull/3940)
* TODO: Consider using webp if supported, falling back to jpg
*/
if (!this.style.backgroundImage) {
this.style.backgroundImage = `url("https://i.ytimg.com/vi/${this.videoId}/hqdefault.jpg")`;
this.upgradePosterImage();
}

// Set up play button, and its visually hidden label
Expand Down Expand Up @@ -174,6 +171,34 @@ class LiteYTEmbed extends HTMLElement {
// Set focus for a11y
iframeEl.focus();
}

/**
* In the spirit of the `lowsrc` attribute and progressive JPEGs, we'll upgrade the reliable
* poster image to a higher resolution one, if it's available.
* Interestingly this sddefault webp is often smaller in filesize, but we will still attempt it second
* because getting _an_ image in front of the user if our first priority.
*
* See https://github.com/paulirish/lite-youtube-embed/blob/master/youtube-thumbnail-urls.md for more details
*/
upgradePosterImage() {
// Defer to reduce network contention.
setTimeout(() => {
const webpUrl = `https://i.ytimg.com/vi_webp/${this.videoId}/sddefault.webp`;
const img = new Image();
img.fetchPriority = 'low'; // low priority to reduce network contention
img.referrerpolicy = 'origin'; // Not 100% sure it's needed, but https://github.com/ampproject/amphtml/pull/3940
img.src = webpUrl;
img.onload = e => {
// A pretty ugly hack since onerror won't fire on YouTube image 404. This is (probably) due to
// Youtube's style of returning data even with a 404 status. That data is a 120x90 placeholder image.
// … per "annoying yt 404 behavior" in the .md
const noAvailablePoster = e.target.naturalHeight == 90 && e.target.naturalWidth == 120;
if (noAvailablePoster) return;

this.style.backgroundImage = `url("${webpUrl}")`;
}
}, 100);
}
}
// Register custom element
customElements.define('lite-youtube', LiteYTEmbed);
155 changes: 155 additions & 0 deletions testpage/poster-image-availability.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<!doctype html>
<title>poster image availability</title>
<style>
body {
max-width: 1000px;
margin: 0 auto;
}
form {
margin: 25px;
}
input {
font-size: 30px;
}

input[type='text'] {
width: 30ex;
}
img,
picture {
max-height: 120px;
max-width: 120px;
}

thead {
position: sticky;
top: 0;
background: #ffffffd4;
}

td {
background: #eee;
}
#results.done {
background: aliceblue;
padding: 20px;
border-radius: 20px;
margin: 20px 0;
}
</style>
<form>
<input type="text" name="videoid" id="videoidorurl" placeholder="Youtube video ID or URL" />
<input type="submit" value="OK" />
</form>

<div id="results"></div>
<script>
// bling dot js
window.$ = document.querySelector.bind(document);
window.$$ = document.querySelectorAll.bind(document);
Node.prototype.on = window.on = function (name, fn) {
this.addEventListener(name, fn);
};

$('form').on('submit', (e) => {
const value = $('input').value;
const videoid = value.length < 20 ? value : new URL(value).searchParams.get('v');
$('input').value = videoid;
});

const sizes = [
'maxresdefault', // 1280px
'sddefault', // 640px
'hqdefault', // 480px (lol, naming is hard)
'mqdefault', // 320px
'default', // 120px
];
const sizePxs = ['1280px', '640px', '480px', '320px', '120px'];

const createChild = (tagName, container) => container.appendChild(document.createElement(tagName));

const videoid = new URL(location.href).searchParams.get('videoid');
document.addEventListener('DOMContentLoaded', (_) => {
if (!videoid) return;

$('#results').innerHTML = '<h2>Loading…</h2>';

const table = createChild('table', document.body);
const thead = createChild('thead', table);
const tbody = createChild('tbody', table);

// headers
const tr = createChild('tr', thead);
createChild('th', tr).textContent = 'video id';
createChild('th', tr).textContent = '';
for (const size of sizes) {
createChild('th', tr).textContent = size;
}
const tr2 = createChild('tr', thead);
createChild('th', tr2);
createChild('th', tr2);
for (const px of sizePxs) {
createChild('th', tr2).textContent = px;
}

// rows
[videoid].forEach((videoid) => {
const tr = createChild('tr', tbody);
createChild('td', tr).textContent = videoid;

const bestEl = createChild('td', tr);
// im very lazy
bestEl.innerHTML = `
webp &rarr;
<br><br><br><br>
jpg &rarr;
`;
const updateBest = (sizeIndex, format, e) => {
// A pretty ugly hack since onerror won't fire on YouTube image 404. This might be due to the fact that
// YouTube returns data to the request even when the status is 404. YouTube returns a placeholder image that is 120x90.
// … per ../youtube-thumbnail-urls.md (annoying yt 404 behavior)
const noAvailableThumbnail = e.target.naturalHeight == 90 && e.target.naturalWidth == 120;
if (noAvailableThumbnail) return;

bestEl.loaded = bestEl.loaded ?? [];
const formatIndex = { webp: 100, jpg: 110 }[format];
const sum = formatIndex + sizeIndex;
bestEl.loaded.push({ sum, format, sizeIndex });
bestEl.loaded.sort((a, b) => a.sum - b.sum);
const bestObj = bestEl.loaded.at(0);
bestEl.bestObj = bestObj;
};

// all combos. verbose
sizes.forEach((size, i) => {
const td = createChild('td', tr);
const imgWebp = createChild('img', td);
imgWebp.src = `https://i.ytimg.com/vi_webp/${videoid}/${size}.webp`;
imgWebp.onload = (e) => updateBest(i, 'webp', e);
createChild('br', td);
const imgJpg = createChild('img', td);
imgJpg.src = `https://i.ytimg.com/vi/${videoid}/${size}.jpg`;
imgJpg.onload = (e) => updateBest(i, 'jpg', e);
});
});
});

// Log our winner. Relying on all-images-loaded semantics. Handy.
onload = (_) => {
if (!videoid) return;

const bestEl = document.querySelector('tbody td:nth-child(2)');
const bestObj = bestEl.bestObj;
const size = sizes[bestObj.sizeIndex];
const imgHref = bestObj.format === 'webp' ? `https://i.ytimg.com/vi_webp/${videoid}/${size}.webp` : `https://i.ytimg.com/vi/${videoid}/${size}.jpg`;
results.innerHTML = `
<h2>The highest quality poster image available is:
<code>
${sizes[bestObj.sizeIndex]}.${bestObj.format} (${sizePxs[bestObj.sizeIndex]})
</code>
</h2>
<h3>That URL is: <a href="${imgHref}"><code>${imgHref}</code></a></h3>
`;
results.classList.add('done');
};
</script>
122 changes: 122 additions & 0 deletions testpage/poster-image-summary.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<title>poster image summary</title>

<style>
img,
picture {
max-height: 120px;
max-width: 120px;
}

thead {
position: sticky;
top: 0;
background: #ffffffd4;
}

td {
background: #eee;
}
</style>

<script>
const sizes = [
'maxresdefault', // 1280px
'sddefault', // 640px
'hqdefault', // 480px (lol, naming is hard)
'mqdefault', // 320px
'default', // 120px
];
const sizePxs = ['1280px', '640px', '480px', '320px', '120px'];

const createChild = (tagName, container) => container.appendChild(document.createElement(tagName));


document.addEventListener('DOMContentLoaded', _ => {

const table = createChild('table', document.body);
const thead = createChild('thead', table);
const tbody = createChild('tbody', table);

// headers
const tr = createChild('tr', thead);
createChild('th', tr).textContent = 'video id';
createChild('th', tr).textContent = 'best!?';
for (const size of sizes) {
createChild('th', tr).textContent = size;
}
const tr2 = createChild('tr', thead);
createChild('th', tr2);
createChild('th', tr2);
for (const px of sizePxs) {
createChild('th', tr2).textContent = px;
}

// rows
ids.forEach(videoid => {
const tr = createChild('tr', tbody);
createChild('td', tr).textContent = videoid;


const bestEl = createChild('td', tr);
const updateBest = (sizeIndex, format, e) => {
// A pretty ugly hack since onerror won't fire on YouTube image 404. This might be due to the fact that
// YouTube returns data to the request even when the status is 404. YouTube returns a placeholder image that is 120x90.
// … per ../youtube-thumbnail-urls.md (annoying yt 404 behavior)
const noAvailableThumbnail = e.target.naturalHeight == 90 && e.target.naturalWidth == 120;
if (noAvailableThumbnail) return;

bestEl.loaded = bestEl.loaded ?? [];
const formatIndex = { webp: 100, jpg: 110 }[format];
const sum = formatIndex + sizeIndex;
bestEl.loaded.push({ sum, format, sizeIndex });
bestEl.loaded.sort((a, b) => a.sum - b.sum);
const bestObj = bestEl.loaded.at(0);
bestEl.textContent = `${sizes[bestObj.sizeIndex]}.${bestObj.format} (${sizePxs[bestObj.sizeIndex]})`;
};

// all combos. verbose
sizes.forEach((size, i) => {
const td = createChild('td', tr);
const imgWebp = createChild('img', td);
imgWebp.src = `https://i.ytimg.com/vi_webp/${videoid}/${size}.webp`;
imgWebp.onload = e => { updateBest(i, 'webp', e) };
createChild('br', td)
const imgJpg = createChild('img', td);
imgJpg.src = `https://i.ytimg.com/vi/${videoid}/${size}.jpg`;
imgJpg.onload = e => { updateBest(i, 'jpg', e) };
});

});
});

// Count our winners. Relying on all-images-loaded semantics. Handy.
onload = _ => {
Object.countBy = (items, groupByFn) => Object.fromEntries(
Object.entries(Object.groupBy(items, groupByFn)).map(([k, v]) => [k, v.length])
);

const allBests = Array.from(document.querySelectorAll('tbody td:nth-child(2)')).map(e => e.textContent);
const bestsCounted = Object.countBy(allBests, i => i);
const pre = createChild('pre', document.body);
document.body.prepend(pre)
pre.textContent = JSON.stringify(bestsCounted, null, 2)
}

/**
* Get a bunch of ids from a YT page with:
* copy(Array.from(new Set(Array.from(document.querySelectorAll('a.ytd-thumbnail')).map(e => e.href).filter(Boolean).map(h => { const u = new URL(h); return u.searchParams.get('v'); }))))
*/
const ids = [
"ogfYd705cRs",
"3zaq56QsX28",
"ajGX7odA87k",
"-hKK4_gvOS0",
"5BueilOHIws",
"saaTXQ7E3Lw",
"bJcTWr8-mFo",
"jWur1VrxNUg",
"b52cfb6lweU",
"svEuG_ekNT0",
]
</script>
31 changes: 30 additions & 1 deletion youtube-thumbnail-urls.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,33 @@ I've started with using hqdefault from `i.ytimg` to optimize for origin reuse.

## Annoying Youtube 404 behavior

When YouTube serves a response for a 404 thumbnail, they serve a true 404 response code (good), however they still serve [`content-type: image/jpeg` and valid JPEG data in the response body](https://stackoverflow.com/questions/58560120/why-do-image-and-picture-elements-display-images-despite-http-status-404). ([example](https://img.youtube.com/vi/lXMskKTw3Bc/maxresdefault.jpg)). I assume they do this to avoid people seeing broken image icons, but unfortunately this also means you can't rely on `img.onerror`. The linked SO post and [`amp-youtube` both](https://github.com/ampproject/amphtml/blob/186d10a0adadcc8367aaa047b58598b587958946/extensions/amp-youtube/0.1/amp-youtube.js#L519-L528) use `onload` plus a `naturalWidth` check to check instead.
When YouTube serves a response for a 404 thumbnail, they serve a true 404 response code (good), however they still serve [`content-type: image/jpeg` and valid JPEG data in the response body](https://stackoverflow.com/questions/58560120/why-do-image-and-picture-elements-display-images-despite-http-status-404). ([example](https://img.youtube.com/vi/lXMskKTw3Bc/maxresdefault.jpg)). I assume they do this to avoid people seeing broken image icons, but unfortunately this also means you can't rely on `img.onerror`. The linked SO post and [`amp-youtube` both](https://github.com/ampproject/amphtml/blob/186d10a0adadcc8367aaa047b58598b587958946/extensions/amp-youtube/0.1/amp-youtube.js#L519-L528) use `onload` plus a `naturalWidth` check to check instead.

## Aspect ratios

`maxresdefault` and `mqdefault` are 30:17 (very close to 16:9, the HD standard aspect ratio).

`sddefault`, `hqdefault`, `default` are 4:3.

This difference ends up being mostly unimportant, in practice. The YT iframe is a 16:9 size by default. Using `background-position: center` ensures the (possible) black bars in the 4:3 images are hidden.

## WebP

`https://i.ytimg.com/vi_webp/${videoid}/${width}.webp`

I tested across some old and new videos and here's the best image they had available, counted:

```
"maxresdefault.webp (1280px)": 178,
"sddefault.webp (640px)" : 21,
"hqdefault.webp (480px)" : 8,
"maxresdefault.jpg (1280px)" : 6,
"sddefault.jpg (640px)" : 2,
"hqdefault.jpg (480px)" : 89,
```

I found no cases where it a smaller size was not available. For example, if they have the maxresdefault webp, then they definitely have the sddefault webp.

All this means, it'd be very reasonable and efficient to try first for the `maxresdefault.webp`. If it isn't available (see annoying 404 behavior above), then fall back to `hqdefault.jpg`.

In lite-youtube-embed's case though, we'll default to trying the `sddefault.webp` first, as that resolution is plenty for our uses.

0 comments on commit eeadc83

Please sign in to comment.