From 68432eef26da46ed9de7cd3ae37078993b43860d Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Wed, 14 Jul 2021 22:14:04 +0800 Subject: [PATCH 1/3] OptimizedImage - wrapper for CDN-optimized image Objective: - Get appropriately sized images to improve performance and Core Vitals score. - Ensure images using "objectFit=cover" doesn't get stretched out if the source is large enough. - Peg to 100px increments for better caching. Notes: - Skip images hosted in '/public'. If we really want to optimize it, then we'll need to provide the full path in the code, otherwise CDN lookup will fail. --- ui/component/optimizedImage/index.js | 2 + ui/component/optimizedImage/view.jsx | 108 +++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 ui/component/optimizedImage/index.js create mode 100644 ui/component/optimizedImage/view.jsx diff --git a/ui/component/optimizedImage/index.js b/ui/component/optimizedImage/index.js new file mode 100644 index 000000000..0ec9fdc1a --- /dev/null +++ b/ui/component/optimizedImage/index.js @@ -0,0 +1,2 @@ +import OptimizedImage from './view'; +export default OptimizedImage; diff --git a/ui/component/optimizedImage/view.jsx b/ui/component/optimizedImage/view.jsx new file mode 100644 index 000000000..1cc8743f6 --- /dev/null +++ b/ui/component/optimizedImage/view.jsx @@ -0,0 +1,108 @@ +// @flow +import React from 'react'; +import { getThumbnailCdnUrl } from 'util/thumbnail'; + +function scaleToDevicePixelRatio(value: number, window: any) { + const devicePixelRatio = window.devicePixelRatio || 1.0; + return Math.ceil(value * devicePixelRatio); +} + +type Props = { + src: string, + objectFit?: string, +}; + +function OptimizedImage(props: Props) { + const { objectFit, src, ...imgProps } = props; + const [optimizedSrc, setOptimizedSrc] = React.useState(''); + const ref = React.useRef(); + + function getOptimizedImgUrl(url, width, height) { + let optimizedUrl = url; + if (url && !url.startsWith('/public/')) { + optimizedUrl = url.trim().replace(/^http:\/\//i, 'https://'); + + // @if TARGET='web' + if (!optimizedUrl.endsWith('.gif')) { + optimizedUrl = getThumbnailCdnUrl({ thumbnail: optimizedUrl, width, height, quality: 85 }); + } + // @endif + } + return optimizedUrl; + } + + function getOptimumSize(elem) { + if (!elem || !elem.parentElement || !elem.parentElement.clientWidth || !elem.parentElement.clientHeight) { + return null; + } + + let width = elem.parentElement.clientWidth; + let height = elem.parentElement.clientHeight; + + width = scaleToDevicePixelRatio(width, window); + height = scaleToDevicePixelRatio(height, window); + + // Round to next 100px for better caching + width = Math.ceil(width / 100) * 100; + height = Math.ceil(height / 100) * 100; + + // Reminder: CDN expects integers. + return { width, height }; + } + + function adjustOptimizationIfNeeded(elem, objectFit, origSrc) { + if (objectFit === 'cover' && elem) { + const containerSize = getOptimumSize(elem); + if (containerSize) { + // $FlowFixMe + if (elem.naturalWidth < containerSize.width) { + // For 'cover', we don't want to stretch the image. We started off by + // filling up the container height, but the width still has a gap for + // this instance (usually due to aspect ratio mismatch). + // If the original image is much larger, we can request for a larger + // image so that "objectFit=cover" will center it without stretching and + // making it blur. The double fetch might seem wasteful, but on + // average the total transferred bytes is still less than the original. + const probablyMaxedOut = elem.naturalHeight < containerSize.height; + if (!probablyMaxedOut) { + const newOptimizedSrc = getOptimizedImgUrl(origSrc, containerSize.width, 0); + if (newOptimizedSrc && newOptimizedSrc !== optimizedSrc) { + setOptimizedSrc(newOptimizedSrc); + } + } + } + } + } + } + + React.useEffect(() => { + const containerSize = getOptimumSize(ref.current); + if (containerSize) { + const width = 0; // The CDN will fill the zeroed attribute per image's aspect ratio. + const height = containerSize.height; + + const newOptimizedSrc = getOptimizedImgUrl(src, width, height); + + if (newOptimizedSrc !== optimizedSrc) { + setOptimizedSrc(newOptimizedSrc); + } + } else { + setOptimizedSrc(src); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (!src) { + return null; + } + + return ( + adjustOptimizationIfNeeded(ref.current, objectFit, src)} + /> + ); +} + +export default OptimizedImage; From a21b4c5cf32938f68d8259bb9a74a8b63fcf09bb Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Thu, 15 Jul 2021 00:18:34 +0800 Subject: [PATCH 2/3] Optimize banner image --- ui/page/channel/view.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/page/channel/view.jsx b/ui/page/channel/view.jsx index a7bf858e7..6dd52cf26 100644 --- a/ui/page/channel/view.jsx +++ b/ui/page/channel/view.jsx @@ -21,6 +21,7 @@ import HelpLink from 'component/common/help-link'; import ClaimSupportButton from 'component/claimSupportButton'; import ChannelStakedIndicator from 'component/channelStakedIndicator'; import ClaimMenuList from 'component/claimMenuList'; +import OptimizedImage from 'component/optimizedImage'; import Yrbl from 'component/yrbl'; import I18nMessage from 'component/i18nMessage'; // $FlowFixMe cannot resolve ... @@ -220,7 +221,7 @@ function ChannelPage(props: Props) { {cover && } - {cover && } + {cover && }

From 11b5eb49a0226a0b2c96e88bbdbdc9f124d6ca38 Mon Sep 17 00:00:00 2001 From: infinite-persistence Date: Fri, 16 Jul 2021 11:28:17 +0800 Subject: [PATCH 3/3] Optimize ChannelThumbnail using the new method --- ui/component/channelThumbnail/view.jsx | 46 +++----------------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/ui/component/channelThumbnail/view.jsx b/ui/component/channelThumbnail/view.jsx index 7f3c97b80..19c15fc2e 100644 --- a/ui/component/channelThumbnail/view.jsx +++ b/ui/component/channelThumbnail/view.jsx @@ -5,12 +5,7 @@ import classnames from 'classnames'; import Gerbil from './gerbil.png'; import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper'; import ChannelStakedIndicator from 'component/channelStakedIndicator'; -import { getThumbnailCdnUrl } from 'util/thumbnail'; - -const FONT_PX = 16.0; -const IMG_XSMALL_REM = 2.1; -const IMG_SMALL_REM = 3.0; -const IMG_NORMAL_REM = 10.0; +import OptimizedImage from 'component/optimizedImage'; type Props = { thumbnail: ?string, @@ -53,8 +48,6 @@ function ChannelThumbnail(props: Props) { const channelThumbnail = thumbnail || thumbnailPreview; const isGif = channelThumbnail && channelThumbnail.endsWith('gif'); const showThumb = (!obscure && !!thumbnail) || thumbnailPreview; - const thumbnailRef = React.useRef(null); - const thumbnailSize = calcRenderedImgWidth(); // currently always 1:1 // Generate a random color class based on the first letter of the channel name const { channelName } = parseURI(uri); @@ -67,20 +60,6 @@ function ChannelThumbnail(props: Props) { colorClassName = `channel-thumbnail__default--4`; } - function calcRenderedImgWidth() { - let rem; - if (xsmall) { - rem = IMG_XSMALL_REM; - } else if (small) { - rem = IMG_SMALL_REM; - } else { - rem = IMG_NORMAL_REM; - } - - const devicePixelRatio = window.devicePixelRatio || 1.0; - return Math.ceil(rem * devicePixelRatio * FONT_PX); - } - React.useEffect(() => { if (shouldResolve && uri) { doResolveUri(uri); @@ -94,15 +73,6 @@ function ChannelThumbnail(props: Props) { ); } - - let url = channelThumbnail; - // @if TARGET='web' - // Pass image urls through a compression proxy, except for GIFs. - if (thumbnail && !(isGif && allowGifs)) { - url = getThumbnailCdnUrl({ thumbnail, width: thumbnailSize, height: thumbnailSize, quality: 85 }); - } - // @endif - return (
{!showThumb && ( - {__('Channel setThumbError(true)} // if thumb fails (including due to https replace, show gerbil. /> @@ -129,13 +96,10 @@ function ChannelThumbnail(props: Props) { {showDelayedMessage && thumbError ? (
{__('This will be visible in a few minutes.')}
) : ( - {__('Channel setThumbError(true)} // if thumb fails (including due to https replace, show gerbil. />