Commit 023c4400 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Support CDN for NFT images (#2461)

* Support CDN for NFT images

Fixes #2424

* add test

* fix ts

* disable helia in preview env

* [skip ci] enable again helia fetch for review stands
parent e662c19b
...@@ -73,6 +73,7 @@ export const base: TokenInstance = { ...@@ -73,6 +73,7 @@ export const base: TokenInstance = {
name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God', name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God',
}, },
owner: addressMock.withName, owner: addressMock.withName,
thumbnails: null,
}; };
export const withRichMetadata: TokenInstance = { export const withRichMetadata: TokenInstance = {
......
...@@ -175,4 +175,5 @@ export const TOKEN_INSTANCE: TokenInstance = { ...@@ -175,4 +175,5 @@ export const TOKEN_INSTANCE: TokenInstance = {
}, },
owner: ADDRESS_PARAMS, owner: ADDRESS_PARAMS,
holder_address_hash: ADDRESS_HASH, holder_address_hash: ADDRESS_HASH,
thumbnails: null,
}; };
...@@ -51,6 +51,8 @@ export type TokenHoldersPagination = { ...@@ -51,6 +51,8 @@ export type TokenHoldersPagination = {
value: string; value: string;
}; };
export type ThumbnailSize = '60x60' | '250x250' | '500x500' | 'original';
export interface TokenInstance { export interface TokenInstance {
is_unique: boolean; is_unique: boolean;
id: string; id: string;
...@@ -60,6 +62,7 @@ export interface TokenInstance { ...@@ -60,6 +62,7 @@ export interface TokenInstance {
external_app_url: string | null; external_app_url: string | null;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
owner: AddressParam | null; owner: AddressParam | null;
thumbnails: Partial<Record<ThumbnailSize, string>> | null;
} }
export interface TokenInstanceMetadataSocketMessage { export interface TokenInstanceMetadataSocketMessage {
......
...@@ -5,17 +5,19 @@ import { mediaStyleProps } from './utils'; ...@@ -5,17 +5,19 @@ import { mediaStyleProps } from './utils';
interface Props { interface Props {
src: string; src: string;
srcSet?: string;
onLoad: () => void; onLoad: () => void;
onError: () => void; onError: () => void;
onClick?: () => void; onClick?: () => void;
} }
const NftImage = ({ src, onLoad, onError, onClick }: Props) => { const NftImage = ({ src, srcSet, onLoad, onError, onClick }: Props) => {
return ( return (
<Image <Image
w="100%" w="100%"
h="100%" h="100%"
src={ src } src={ src }
srcSet={ srcSet }
alt="Token instance image" alt="Token instance image"
onError={ onError } onError={ onError }
onLoad={ onLoad } onLoad={ onLoad }
......
...@@ -72,6 +72,24 @@ test.describe('image', () => { ...@@ -72,6 +72,24 @@ test.describe('image', () => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
}); });
test('preview with thumbnails', async({ render, page, mockAssetResponse }) => {
const THUMBNAIL_URL = 'https://localhost:3000/my-image-250.jpg';
const data = {
animation_url: MEDIA_URL,
image_url: null,
thumbnails: {
'500x500': THUMBNAIL_URL,
},
} as TokenInstance;
await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg');
await render(
<Box boxSize="250px">
<NftMedia data={ data }/>
</Box>,
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
});
test('preview hover', async({ render, page }) => { test('preview hover', async({ render, page }) => {
const data = { const data = {
animation_url: MEDIA_URL, animation_url: MEDIA_URL,
......
...@@ -64,14 +64,23 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: ...@@ -64,14 +64,23 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
...(withFullscreen ? { onClick: onOpen } : {}), ...(withFullscreen ? { onClick: onOpen } : {}),
}; };
switch (mediaInfo.type) { switch (mediaInfo.mediaType) {
case 'video': { case 'video': {
return <NftVideo { ...props } src={ mediaInfo.src } autoPlay={ autoplayVideo } instance={ data }/>; return <NftVideo { ...props } src={ mediaInfo.src } autoPlay={ autoplayVideo } instance={ data }/>;
} }
case 'html': case 'html':
return <NftHtml { ...props } src={ mediaInfo.src }/>; return <NftHtml { ...props } src={ mediaInfo.src }/>;
case 'image': case 'image': {
if (mediaInfo.srcType === 'url' && data.thumbnails) {
const srcSet = data.thumbnails['250x250'] && data.thumbnails['500x500'] ? `${ data.thumbnails['500x500'] } 2x` : undefined;
const src = (srcSet ? data.thumbnails['250x250'] : undefined) || data.thumbnails['500x500'] || data.thumbnails.original;
if (src) {
return <NftImage { ...props } src={ src } srcSet={ srcSet }/>;
}
}
return <NftImage { ...props } src={ mediaInfo.src }/>; return <NftImage { ...props } src={ mediaInfo.src }/>;
}
default: default:
return null; return null;
} }
...@@ -87,13 +96,15 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }: ...@@ -87,13 +96,15 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
onClose, onClose,
}; };
switch (mediaInfo.type) { switch (mediaInfo.mediaType) {
case 'video': case 'video':
return <NftVideoFullscreen { ...props } src={ mediaInfo.src }/>; return <NftVideoFullscreen { ...props } src={ mediaInfo.src }/>;
case 'html': case 'html':
return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>; return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>;
case 'image': case 'image': {
return <NftImageFullscreen { ...props } src={ mediaInfo.src }/>; const src = mediaInfo.srcType === 'url' && data.thumbnails?.original ? data.thumbnails.original : mediaInfo.src;
return <NftImageFullscreen { ...props } src={ src }/>;
}
default: default:
return null; return null;
} }
......
...@@ -44,8 +44,9 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }: ...@@ -44,8 +44,9 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }:
// otherwise, the skeleton will be shown underneath the element until the video is loaded // otherwise, the skeleton will be shown underneath the element until the video is loaded
onLoad(); onLoad();
} catch (error) { } catch (error) {
if (instance.image_url) { const src = instance.thumbnails?.['500x500'] || instance.thumbnails?.original || instance.image_url;
ref.current.poster = instance.image_url; if (src) {
ref.current.poster = src;
// we want to call onLoad right after the poster is loaded // we want to call onLoad right after the poster is loaded
// otherwise, the skeleton will be shown underneath the element until the video is loaded // otherwise, the skeleton will be shown underneath the element until the video is loaded
...@@ -54,10 +55,10 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }: ...@@ -54,10 +55,10 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }:
poster.onload = onLoad; poster.onload = onLoad;
} }
} }
}, [ instance.image_url, instance.metadata?.image, onLoad ]); }, [ instance.image_url, instance.metadata?.image, instance.thumbnails, onLoad ]);
React.useEffect(() => { React.useEffect(() => {
fetchVideoPoster(); !autoPlay && fetchVideoPoster();
return () => { return () => {
controller.current?.abort(); controller.current?.abort();
}; };
......
...@@ -11,7 +11,7 @@ import config from 'configs/app'; ...@@ -11,7 +11,7 @@ import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
import useFetch from 'lib/hooks/useFetch'; import useFetch from 'lib/hooks/useFetch';
import type { MediaType } from './utils'; import type { MediaType, SrcType } from './utils';
import { getPreliminaryMediaType } from './utils'; import { getPreliminaryMediaType } from './utils';
interface Params { interface Params {
...@@ -28,11 +28,12 @@ type TransportType = 'http' | 'ipfs'; ...@@ -28,11 +28,12 @@ type TransportType = 'http' | 'ipfs';
type ReturnType = type ReturnType =
{ {
type: MediaType;
src: string; src: string;
mediaType: MediaType;
srcType: SrcType;
} | } |
{ {
type: undefined; mediaType: undefined;
} | } |
null; null;
...@@ -42,14 +43,14 @@ export default function useNftMediaInfo({ data, isEnabled }: Params): ReturnType ...@@ -42,14 +43,14 @@ export default function useNftMediaInfo({ data, isEnabled }: Params): ReturnType
const httpPrimaryQuery = useNftMediaTypeQuery(assetsData.http.animationUrl, isEnabled); const httpPrimaryQuery = useNftMediaTypeQuery(assetsData.http.animationUrl, isEnabled);
const ipfsPrimaryQuery = useFetchAssetViaIpfs( const ipfsPrimaryQuery = useFetchAssetViaIpfs(
assetsData.ipfs.animationUrl, assetsData.ipfs.animationUrl,
httpPrimaryQuery.data?.type, httpPrimaryQuery.data?.mediaType,
isEnabled && (httpPrimaryQuery.data === null || Boolean(httpPrimaryQuery.data?.type)), isEnabled && (httpPrimaryQuery.data === null || Boolean(httpPrimaryQuery.data?.mediaType)),
); );
const httpSecondaryQuery = useNftMediaTypeQuery(assetsData.http.imageUrl, isEnabled && !httpPrimaryQuery.data && !ipfsPrimaryQuery); const httpSecondaryQuery = useNftMediaTypeQuery(assetsData.http.imageUrl, isEnabled && !httpPrimaryQuery.data && !ipfsPrimaryQuery);
const ipfsSecondaryQuery = useFetchAssetViaIpfs( const ipfsSecondaryQuery = useFetchAssetViaIpfs(
assetsData.ipfs.imageUrl, assetsData.ipfs.imageUrl,
httpSecondaryQuery.data?.type, httpSecondaryQuery.data?.mediaType,
isEnabled && (httpSecondaryQuery.data === null || Boolean(httpSecondaryQuery.data?.type)), isEnabled && (httpSecondaryQuery.data === null || Boolean(httpSecondaryQuery.data?.mediaType)),
); );
return React.useMemo(() => { return React.useMemo(() => {
...@@ -72,8 +73,8 @@ function composeAssetsData(data: TokenInstance): Record<TransportType, AssetsDat ...@@ -72,8 +73,8 @@ function composeAssetsData(data: TokenInstance): Record<TransportType, AssetsDat
// As of now we fetch only images via IPFS because video streaming has performance issues // As of now we fetch only images via IPFS because video streaming has performance issues
// Also, we don't want to store the entire file content in the ReactQuery cache, so we don't use useQuery hook here // Also, we don't want to store the entire file content in the ReactQuery cache, so we don't use useQuery hook here
function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefined, isEnabled: boolean): ReturnType | null { function useFetchAssetViaIpfs(url: string | undefined, mediaType: MediaType | undefined, isEnabled: boolean): ReturnType | null {
const [ result, setResult ] = React.useState<ReturnType | null>({ type: undefined }); const [ result, setResult ] = React.useState<ReturnType | null>({ mediaType: undefined });
const controller = React.useRef<AbortController | null>(null); const controller = React.useRef<AbortController | null>(null);
const fetchAsset = React.useCallback(async(url: string) => { const fetchAsset = React.useCallback(async(url: string) => {
...@@ -83,7 +84,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin ...@@ -83,7 +84,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin
if (response.status === 200) { if (response.status === 200) {
const blob = await response.blob(); const blob = await response.blob();
const src = URL.createObjectURL(blob); const src = URL.createObjectURL(blob);
setResult({ type: 'image', src }); setResult({ mediaType: 'image', src, srcType: 'blob' });
return; return;
} }
} catch (error) {} } catch (error) {}
...@@ -92,15 +93,15 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin ...@@ -92,15 +93,15 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin
React.useEffect(() => { React.useEffect(() => {
if (isEnabled) { if (isEnabled) {
if (config.UI.views.nft.verifiedFetch.isEnabled && type === 'image' && url && url.includes('ipfs')) { if (config.UI.views.nft.verifiedFetch.isEnabled && mediaType === 'image' && url && url.includes('ipfs')) {
fetchAsset(url); fetchAsset(url);
} else { } else {
setResult(null); setResult(null);
} }
} else { } else {
setResult({ type: undefined }); setResult({ mediaType: undefined });
} }
}, [ fetchAsset, url, type, isEnabled ]); }, [ fetchAsset, url, mediaType, isEnabled ]);
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
...@@ -114,7 +115,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin ...@@ -114,7 +115,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin
function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) { function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
const fetch = useFetch(); const fetch = useFetch();
return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({ return useQuery<ReturnType | null, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ], queryKey: [ 'nft-media-type', url ],
queryFn: async() => { queryFn: async() => {
if (!url) { if (!url) {
...@@ -130,10 +131,10 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) { ...@@ -130,10 +131,10 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
const preliminaryType = getPreliminaryMediaType(url); const preliminaryType = getPreliminaryMediaType(url);
if (preliminaryType) { if (preliminaryType) {
return { type: preliminaryType, src: url }; return { mediaType: preliminaryType, src: url, srcType: 'url' };
} }
const type = await (async() => { const mediaType = await (async() => {
try { try {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } }); const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' }); const response = await fetch<{ type: MediaType | undefined }, ResourceError>(mediaTypeResourceUrl, undefined, { resource: 'media-type' });
...@@ -144,14 +145,14 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) { ...@@ -144,14 +145,14 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
} }
})(); })();
if (!type) { if (!mediaType) {
return null; return null;
} }
return { type, src: url }; return { mediaType, src: url, srcType: 'url' };
}, },
enabled, enabled,
placeholderData: { type: undefined }, placeholderData: { mediaType: undefined },
staleTime: Infinity, staleTime: Infinity,
}); });
} }
export type MediaType = 'image' | 'video' | 'html'; export type MediaType = 'image' | 'video' | 'html';
export type SrcType = 'url' | 'blob';
const IMAGE_EXTENSIONS = [ const IMAGE_EXTENSIONS = [
'.jpg', 'jpeg', '.jpg', 'jpeg',
'.png', '.png',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment