Skip to content

Commit

Permalink
Support CDN for NFT images (#2461)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tom2drum authored Dec 13, 2024
1 parent e662c19 commit 023c440
Show file tree
Hide file tree
Showing 10 changed files with 69 additions and 29 deletions.
1 change: 1 addition & 0 deletions mocks/tokens/tokenInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const base: TokenInstance = {
name: 'GENESIS #188848, 22a5f8bbb1602995. Blockchain pixel PFP NFT + "on music video" trait inspired by God',
},
owner: addressMock.withName,
thumbnails: null,
};

export const withRichMetadata: TokenInstance = {
Expand Down
1 change: 1 addition & 0 deletions stubs/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,5 @@ export const TOKEN_INSTANCE: TokenInstance = {
},
owner: ADDRESS_PARAMS,
holder_address_hash: ADDRESS_HASH,
thumbnails: null,
};
3 changes: 3 additions & 0 deletions types/api/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type TokenHoldersPagination = {
value: string;
};

export type ThumbnailSize = '60x60' | '250x250' | '500x500' | 'original';

export interface TokenInstance {
is_unique: boolean;
id: string;
Expand All @@ -60,6 +62,7 @@ export interface TokenInstance {
external_app_url: string | null;
metadata: Record<string, unknown> | null;
owner: AddressParam | null;
thumbnails: Partial<Record<ThumbnailSize, string>> | null;
}

export interface TokenInstanceMetadataSocketMessage {
Expand Down
4 changes: 3 additions & 1 deletion ui/shared/nft/NftImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ import { mediaStyleProps } from './utils';

interface Props {
src: string;
srcSet?: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}

const NftImage = ({ src, onLoad, onError, onClick }: Props) => {
const NftImage = ({ src, srcSet, onLoad, onError, onClick }: Props) => {
return (
<Image
w="100%"
h="100%"
src={ src }
srcSet={ srcSet }
alt="Token instance image"
onError={ onError }
onLoad={ onLoad }
Expand Down
18 changes: 18 additions & 0 deletions ui/shared/nft/NftMedia.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ test.describe('image', () => {
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 }) => {
const data = {
animation_url: MEDIA_URL,
Expand Down
21 changes: 16 additions & 5 deletions ui/shared/nft/NftMedia.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,23 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
...(withFullscreen ? { onClick: onOpen } : {}),
};

switch (mediaInfo.type) {
switch (mediaInfo.mediaType) {
case 'video': {
return <NftVideo { ...props } src={ mediaInfo.src } autoPlay={ autoplayVideo } instance={ data }/>;
}
case 'html':
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 }/>;
}
default:
return null;
}
Expand All @@ -87,13 +96,15 @@ const NftMedia = ({ data, className, isLoading, withFullscreen, autoplayVideo }:
onClose,
};

switch (mediaInfo.type) {
switch (mediaInfo.mediaType) {
case 'video':
return <NftVideoFullscreen { ...props } src={ mediaInfo.src }/>;
case 'html':
return <NftHtmlFullscreen { ...props } src={ mediaInfo.src }/>;
case 'image':
return <NftImageFullscreen { ...props } src={ mediaInfo.src }/>;
case 'image': {
const src = mediaInfo.srcType === 'url' && data.thumbnails?.original ? data.thumbnails.original : mediaInfo.src;
return <NftImageFullscreen { ...props } src={ src }/>;
}
default:
return null;
}
Expand Down
9 changes: 5 additions & 4 deletions ui/shared/nft/NftVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
onLoad();
} catch (error) {
if (instance.image_url) {
ref.current.poster = instance.image_url;
const src = instance.thumbnails?.['500x500'] || instance.thumbnails?.original || instance.image_url;
if (src) {
ref.current.poster = src;

// 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
Expand All @@ -54,10 +55,10 @@ const NftVideo = ({ src, instance, autoPlay = true, onLoad, onError, onClick }:
poster.onload = onLoad;
}
}
}, [ instance.image_url, instance.metadata?.image, onLoad ]);
}, [ instance.image_url, instance.metadata?.image, instance.thumbnails, onLoad ]);

React.useEffect(() => {
fetchVideoPoster();
!autoPlay && fetchVideoPoster();
return () => {
controller.current?.abort();
};
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 20 additions & 19 deletions ui/shared/nft/useNftMediaInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import config from 'configs/app';
import type { ResourceError } from 'lib/api/resources';
import useFetch from 'lib/hooks/useFetch';

import type { MediaType } from './utils';
import type { MediaType, SrcType } from './utils';
import { getPreliminaryMediaType } from './utils';

interface Params {
Expand All @@ -28,11 +28,12 @@ type TransportType = 'http' | 'ipfs';

type ReturnType =
{
type: MediaType;
src: string;
mediaType: MediaType;
srcType: SrcType;
} |
{
type: undefined;
mediaType: undefined;
} |
null;

Expand All @@ -42,14 +43,14 @@ export default function useNftMediaInfo({ data, isEnabled }: Params): ReturnType
const httpPrimaryQuery = useNftMediaTypeQuery(assetsData.http.animationUrl, isEnabled);
const ipfsPrimaryQuery = useFetchAssetViaIpfs(
assetsData.ipfs.animationUrl,
httpPrimaryQuery.data?.type,
isEnabled && (httpPrimaryQuery.data === null || Boolean(httpPrimaryQuery.data?.type)),
httpPrimaryQuery.data?.mediaType,
isEnabled && (httpPrimaryQuery.data === null || Boolean(httpPrimaryQuery.data?.mediaType)),
);
const httpSecondaryQuery = useNftMediaTypeQuery(assetsData.http.imageUrl, isEnabled && !httpPrimaryQuery.data && !ipfsPrimaryQuery);
const ipfsSecondaryQuery = useFetchAssetViaIpfs(
assetsData.ipfs.imageUrl,
httpSecondaryQuery.data?.type,
isEnabled && (httpSecondaryQuery.data === null || Boolean(httpSecondaryQuery.data?.type)),
httpSecondaryQuery.data?.mediaType,
isEnabled && (httpSecondaryQuery.data === null || Boolean(httpSecondaryQuery.data?.mediaType)),
);

return React.useMemo(() => {
Expand All @@ -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
// 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 {
const [ result, setResult ] = React.useState<ReturnType | null>({ type: undefined });
function useFetchAssetViaIpfs(url: string | undefined, mediaType: MediaType | undefined, isEnabled: boolean): ReturnType | null {
const [ result, setResult ] = React.useState<ReturnType | null>({ mediaType: undefined });
const controller = React.useRef<AbortController | null>(null);

const fetchAsset = React.useCallback(async(url: string) => {
Expand All @@ -83,7 +84,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin
if (response.status === 200) {
const blob = await response.blob();
const src = URL.createObjectURL(blob);
setResult({ type: 'image', src });
setResult({ mediaType: 'image', src, srcType: 'blob' });
return;
}
} catch (error) {}
Expand All @@ -92,15 +93,15 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin

React.useEffect(() => {
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);
} else {
setResult(null);
}
} else {
setResult({ type: undefined });
setResult({ mediaType: undefined });
}
}, [ fetchAsset, url, type, isEnabled ]);
}, [ fetchAsset, url, mediaType, isEnabled ]);

React.useEffect(() => {
return () => {
Expand All @@ -114,7 +115,7 @@ function useFetchAssetViaIpfs(url: string | undefined, type: MediaType | undefin
function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
const fetch = useFetch();

return useQuery<unknown, ResourceError<unknown>, ReturnType | null>({
return useQuery<ReturnType | null, ResourceError<unknown>, ReturnType | null>({
queryKey: [ 'nft-media-type', url ],
queryFn: async() => {
if (!url) {
Expand All @@ -130,10 +131,10 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
const preliminaryType = getPreliminaryMediaType(url);

if (preliminaryType) {
return { type: preliminaryType, src: url };
return { mediaType: preliminaryType, src: url, srcType: 'url' };
}

const type = await (async() => {
const mediaType = await (async() => {
try {
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' });
Expand All @@ -144,14 +145,14 @@ function useNftMediaTypeQuery(url: string | undefined, enabled: boolean) {
}
})();

if (!type) {
if (!mediaType) {
return null;
}

return { type, src: url };
return { mediaType, src: url, srcType: 'url' };
},
enabled,
placeholderData: { type: undefined },
placeholderData: { mediaType: undefined },
staleTime: Infinity,
});
}
2 changes: 2 additions & 0 deletions ui/shared/nft/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type MediaType = 'image' | 'video' | 'html';

export type SrcType = 'url' | 'blob';

const IMAGE_EXTENSIONS = [
'.jpg', 'jpeg',
'.png',
Expand Down

0 comments on commit 023c440

Please sign in to comment.