Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support CDN for NFT images #2461

Merged
merged 5 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deploy/values/review/values.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ frontend:
NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES: "['/apps']"
PROMETHEUS_METRICS_ENABLED: true
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED: true
NEXT_PUBLIC_HELIA_VERIFIED_FETCH_ENABLED: false
envFromSecret:
NEXT_PUBLIC_AUTH0_CLIENT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_AUTH0_CLIENT_ID
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID
Expand Down
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
Loading