#6504: Optimize channel banner

This commit is contained in:
infinite-persistence 2021-07-20 16:40:19 +08:00
commit 03a218e288
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
4 changed files with 117 additions and 42 deletions

View file

@ -5,12 +5,7 @@ import classnames from 'classnames';
import Gerbil from './gerbil.png'; import Gerbil from './gerbil.png';
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper'; import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
import ChannelStakedIndicator from 'component/channelStakedIndicator'; import ChannelStakedIndicator from 'component/channelStakedIndicator';
import { getThumbnailCdnUrl } from 'util/thumbnail'; import OptimizedImage from 'component/optimizedImage';
const FONT_PX = 16.0;
const IMG_XSMALL_REM = 2.1;
const IMG_SMALL_REM = 3.0;
const IMG_NORMAL_REM = 10.0;
type Props = { type Props = {
thumbnail: ?string, thumbnail: ?string,
@ -53,8 +48,6 @@ function ChannelThumbnail(props: Props) {
const channelThumbnail = thumbnail || thumbnailPreview; const channelThumbnail = thumbnail || thumbnailPreview;
const isGif = channelThumbnail && channelThumbnail.endsWith('gif'); const isGif = channelThumbnail && channelThumbnail.endsWith('gif');
const showThumb = (!obscure && !!thumbnail) || thumbnailPreview; 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 // Generate a random color class based on the first letter of the channel name
const { channelName } = parseURI(uri); const { channelName } = parseURI(uri);
@ -67,20 +60,6 @@ function ChannelThumbnail(props: Props) {
colorClassName = `channel-thumbnail__default--4`; 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(() => { React.useEffect(() => {
if (shouldResolve && uri) { if (shouldResolve && uri) {
doResolveUri(uri); doResolveUri(uri);
@ -94,15 +73,6 @@ function ChannelThumbnail(props: Props) {
</FreezeframeWrapper> </FreezeframeWrapper>
); );
} }
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 ( return (
<div <div
className={classnames('channel-thumbnail', className, { className={classnames('channel-thumbnail', className, {
@ -113,13 +83,10 @@ function ChannelThumbnail(props: Props) {
})} })}
> >
{!showThumb && ( {!showThumb && (
<img <OptimizedImage
ref={thumbnailRef}
alt={__('Channel profile picture')} alt={__('Channel profile picture')}
className="channel-thumbnail__default" className="channel-thumbnail__default"
src={!thumbError && url ? url : Gerbil} src={!thumbError && channelThumbnail ? channelThumbnail : Gerbil}
width={thumbnailSize}
height={thumbnailSize}
loading={noLazyLoad ? undefined : 'lazy'} loading={noLazyLoad ? undefined : 'lazy'}
onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil. onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
/> />
@ -129,13 +96,10 @@ function ChannelThumbnail(props: Props) {
{showDelayedMessage && thumbError ? ( {showDelayedMessage && thumbError ? (
<div className="chanel-thumbnail--waiting">{__('This will be visible in a few minutes.')}</div> <div className="chanel-thumbnail--waiting">{__('This will be visible in a few minutes.')}</div>
) : ( ) : (
<img <OptimizedImage
ref={thumbnailRef}
alt={__('Channel profile picture')} alt={__('Channel profile picture')}
className="channel-thumbnail__custom" className="channel-thumbnail__custom"
src={!thumbError && url ? url : Gerbil} src={!thumbError && channelThumbnail ? channelThumbnail : Gerbil}
width={thumbnailSize}
height={thumbnailSize}
loading={noLazyLoad ? undefined : 'lazy'} loading={noLazyLoad ? undefined : 'lazy'}
onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil. onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
/> />

View file

@ -0,0 +1,2 @@
import OptimizedImage from './view';
export default OptimizedImage;

View file

@ -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<any>();
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 (
<img
ref={ref}
{...imgProps}
src={optimizedSrc}
onLoad={() => adjustOptimizationIfNeeded(ref.current, objectFit, src)}
/>
);
}
export default OptimizedImage;

View file

@ -21,6 +21,7 @@ import HelpLink from 'component/common/help-link';
import ClaimSupportButton from 'component/claimSupportButton'; import ClaimSupportButton from 'component/claimSupportButton';
import ChannelStakedIndicator from 'component/channelStakedIndicator'; import ChannelStakedIndicator from 'component/channelStakedIndicator';
import ClaimMenuList from 'component/claimMenuList'; import ClaimMenuList from 'component/claimMenuList';
import OptimizedImage from 'component/optimizedImage';
import Yrbl from 'component/yrbl'; import Yrbl from 'component/yrbl';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
// $FlowFixMe cannot resolve ... // $FlowFixMe cannot resolve ...
@ -220,7 +221,7 @@ function ChannelPage(props: Props) {
<ClaimMenuList uri={claim.permanent_url} channelUri={claim.permanent_url} inline isChannelPage /> <ClaimMenuList uri={claim.permanent_url} channelUri={claim.permanent_url} inline isChannelPage />
</div> </div>
{cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />} {cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />}
{cover && <img className={classnames('channel-cover__custom')} src={cover} />} {cover && <OptimizedImage className={classnames('channel-cover__custom')} src={cover} objectFit="cover" />}
<div className="channel__primary-info"> <div className="channel__primary-info">
<ChannelThumbnail className="channel__thumbnail--channel-page" uri={uri} allowGifs hideStakedIndicator /> <ChannelThumbnail className="channel__thumbnail--channel-page" uri={uri} allowGifs hideStakedIndicator />
<h1 className="channel__title"> <h1 className="channel__title">