Migrate from @astrojs/image to Astro Assets and Unpic
This commit is contained in:
321
src/utils/images-optimization.ts
Normal file
321
src/utils/images-optimization.ts
Normal file
@ -0,0 +1,321 @@
|
||||
import { getImage } from 'astro:assets';
|
||||
import { transformUrl, parseUrl } from 'unpic';
|
||||
|
||||
import type { ImageMetadata } from 'astro';
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
|
||||
type Layout = 'fixed' | 'constrained' | 'fullWidth' | 'cover' | 'responsive' | 'contained';
|
||||
|
||||
export interface AttributesProps extends HTMLAttributes<'img'> {}
|
||||
|
||||
export interface ImageProps extends Omit<HTMLAttributes<'img'>, 'src'> {
|
||||
src?: string | ImageMetadata | null;
|
||||
width?: string | number | null;
|
||||
height?: string | number | null;
|
||||
alt?: string | null;
|
||||
loading?: 'eager' | 'lazy' | null;
|
||||
decoding?: 'sync' | 'async' | 'auto' | null;
|
||||
style?: string;
|
||||
srcset?: string | null;
|
||||
sizes?: string | null;
|
||||
fetchpriority?: 'high' | 'low' | 'auto' | null;
|
||||
|
||||
layout?: Layout;
|
||||
widths?: number[] | null;
|
||||
aspectRatio?: string | number | null;
|
||||
}
|
||||
|
||||
export type ImagesOptimizer = (
|
||||
image: ImageMetadata | string,
|
||||
breakpoints: number[],
|
||||
width?: number,
|
||||
height?: number
|
||||
) => Promise<Array<{ src: string; width: number }>>;
|
||||
|
||||
/* ******* */
|
||||
const config = {
|
||||
// FIXME: Use this when image.width is minor than deviceSizes
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
|
||||
deviceSizes: [
|
||||
640, // older and lower-end phones
|
||||
750, // iPhone 6-8
|
||||
828, // iPhone XR/11
|
||||
960, // older horizontal phones
|
||||
1080, // iPhone 6-8 Plus
|
||||
1280, // 720p
|
||||
1668, // Various iPads
|
||||
1920, // 1080p
|
||||
2048, // QXGA
|
||||
2560, // WQXGA
|
||||
3200, // QHD+
|
||||
3840, // 4K
|
||||
4480, // 4.5K
|
||||
5120, // 5K
|
||||
6016, // 6K
|
||||
],
|
||||
|
||||
formats: ['image/webp'],
|
||||
};
|
||||
|
||||
const computeHeight = (width: number, aspectRatio: number) => {
|
||||
return Math.floor(width / aspectRatio);
|
||||
};
|
||||
|
||||
const parseAspectRatio = (aspectRatio: number | string | null | undefined): number | undefined => {
|
||||
if (typeof aspectRatio === 'number') return aspectRatio;
|
||||
|
||||
if (typeof aspectRatio === 'string') {
|
||||
const match = aspectRatio.match(/(\d+)\s*[/:]\s*(\d+)/);
|
||||
|
||||
if (match) {
|
||||
const [, num, den] = match.map(Number);
|
||||
if (den && !isNaN(num)) return num / den;
|
||||
} else {
|
||||
const numericValue = parseFloat(aspectRatio);
|
||||
if (!isNaN(numericValue)) return numericValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the `sizes` attribute for an image, based on the layout and width
|
||||
*/
|
||||
export const getSizes = (width?: number, layout?: Layout): string | undefined => {
|
||||
if (!width || !layout) {
|
||||
return undefined;
|
||||
}
|
||||
switch (layout) {
|
||||
// If screen is wider than the max size, image width is the max size,
|
||||
// otherwise it's the width of the screen
|
||||
case `constrained`:
|
||||
return `(min-width: ${width}px) ${width}px, 100vw`;
|
||||
|
||||
// Image is always the same width, whatever the size of the screen
|
||||
case `fixed`:
|
||||
return `${width}px`;
|
||||
|
||||
// Image is always the width of the screen
|
||||
case `fullWidth`:
|
||||
return `100vw`;
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const pixelate = (value?: number) => (value || value === 0 ? `${value}px` : undefined);
|
||||
|
||||
const getStyle = ({
|
||||
width,
|
||||
height,
|
||||
aspectRatio,
|
||||
layout,
|
||||
objectFit = 'cover',
|
||||
objectPosition = 'center',
|
||||
background,
|
||||
}: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
aspectRatio?: number;
|
||||
objectFit?: string;
|
||||
objectPosition?: string;
|
||||
layout?: string;
|
||||
background?: string;
|
||||
}) => {
|
||||
const styleEntries: Array<[prop: string, value: string | undefined]> = [
|
||||
['object-fit', objectFit],
|
||||
['object-position', objectPosition],
|
||||
];
|
||||
|
||||
// If background is a URL, set it to cover the image and not repeat
|
||||
if (background?.startsWith('https:') || background?.startsWith('http:') || background?.startsWith('data:')) {
|
||||
styleEntries.push(['background-image', `url(${background})`]);
|
||||
styleEntries.push(['background-size', 'cover']);
|
||||
styleEntries.push(['background-repeat', 'no-repeat']);
|
||||
} else {
|
||||
styleEntries.push(['background', background]);
|
||||
}
|
||||
if (layout === 'fixed') {
|
||||
styleEntries.push(['width', pixelate(width)]);
|
||||
styleEntries.push(['height', pixelate(height)]);
|
||||
styleEntries.push(['object-position', 'top left']);
|
||||
}
|
||||
if (layout === 'constrained') {
|
||||
styleEntries.push(['max-width', pixelate(width)]);
|
||||
styleEntries.push(['max-height', pixelate(height)]);
|
||||
styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
|
||||
styleEntries.push(['width', '100%']);
|
||||
}
|
||||
if (layout === 'fullWidth') {
|
||||
styleEntries.push(['width', '100%']);
|
||||
styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
|
||||
styleEntries.push(['height', pixelate(height)]);
|
||||
}
|
||||
if (layout === 'responsive') {
|
||||
styleEntries.push(['width', '100%']);
|
||||
styleEntries.push(['height', 'auto']);
|
||||
styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
|
||||
}
|
||||
if (layout === 'contained') {
|
||||
styleEntries.push(['max-width', '100%']);
|
||||
styleEntries.push(['max-height', '100%']);
|
||||
styleEntries.push(['object-fit', 'contain']);
|
||||
styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]);
|
||||
}
|
||||
if (layout === 'cover') {
|
||||
styleEntries.push(['max-width', '100%']);
|
||||
styleEntries.push(['max-height', '100%']);
|
||||
}
|
||||
|
||||
const styles = Object.fromEntries(styleEntries.filter(([, value]) => value));
|
||||
|
||||
return Object.entries(styles)
|
||||
.map(([key, value]) => `${key}: ${value};`)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getBreakpoints = ({
|
||||
width,
|
||||
breakpoints,
|
||||
layout,
|
||||
}: {
|
||||
width?: number;
|
||||
breakpoints?: number[];
|
||||
layout: Layout;
|
||||
}): number[] => {
|
||||
if (layout === 'fullWidth' || layout === 'cover' || layout === 'responsive' || layout === 'contained') {
|
||||
return breakpoints || config.deviceSizes;
|
||||
}
|
||||
if (!width) {
|
||||
return [];
|
||||
}
|
||||
const doubleWidth = width * 2;
|
||||
if (layout === 'fixed') {
|
||||
return [width, doubleWidth];
|
||||
}
|
||||
if (layout === 'constrained') {
|
||||
return [
|
||||
// Always include the image at 1x and 2x the specified width
|
||||
width,
|
||||
doubleWidth,
|
||||
// Filter out any resolutions that are larger than the double-res image
|
||||
...(breakpoints || config.deviceSizes).filter((w) => w < doubleWidth),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/* ** */
|
||||
export const astroAsseetsOptimizer: ImagesOptimizer = async (image, breakpoints) => {
|
||||
if (!image || typeof image === 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
breakpoints.map(async (w: number) => {
|
||||
const url = (await getImage({ src: image, width: w })).src;
|
||||
return {
|
||||
src: url,
|
||||
width: w,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/* ** */
|
||||
export const unpicOptimizer: ImagesOptimizer = async (image, breakpoints, width, height) => {
|
||||
if (!image || typeof image !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const urlParsed = parseUrl(image);
|
||||
if (!urlParsed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
breakpoints.map(async (w: number) => {
|
||||
const url =
|
||||
(await transformUrl({
|
||||
url: image,
|
||||
width: w,
|
||||
height: width && height ? computeHeight(w, width / height) : height,
|
||||
cdn: urlParsed.cdn,
|
||||
})) || image;
|
||||
return {
|
||||
src: String(url),
|
||||
width: w,
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/* ** */
|
||||
export async function getImagesOptimized(
|
||||
image: ImageMetadata | string,
|
||||
{ src: _, width, height, sizes, aspectRatio, widths, layout = 'constrained', style = '', ...rest }: ImageProps,
|
||||
transform: ImagesOptimizer = () => Promise.resolve([])
|
||||
): Promise<{ src: string; attributes: AttributesProps }> {
|
||||
if (typeof image !== 'string') {
|
||||
width ||= Number(image.width) || undefined;
|
||||
height ||= typeof width === 'number' ? computeHeight(width, image.width / image.height) : undefined;
|
||||
}
|
||||
|
||||
width = (width && Number(width)) || undefined;
|
||||
height = (height && Number(height)) || undefined;
|
||||
|
||||
widths ||= config.deviceSizes;
|
||||
sizes ||= getSizes(Number(width) || undefined, layout);
|
||||
aspectRatio = parseAspectRatio(aspectRatio);
|
||||
|
||||
// Calculate dimensions from aspect ratio
|
||||
if (aspectRatio) {
|
||||
if (width) {
|
||||
if (height) {
|
||||
/* empty */
|
||||
} else {
|
||||
height = width / aspectRatio;
|
||||
}
|
||||
} else if (height) {
|
||||
width = Number(height * aspectRatio);
|
||||
} else if (layout !== 'fullWidth') {
|
||||
// Fullwidth images have 100% width, so aspectRatio is applicable
|
||||
console.error('When aspectRatio is set, either width or height must also be set');
|
||||
console.error('Image', image);
|
||||
}
|
||||
} else if (width && height) {
|
||||
aspectRatio = width / height;
|
||||
} else if (layout !== 'fullWidth') {
|
||||
// Fullwidth images don't need dimensions
|
||||
console.error('Either aspectRatio or both width and height must be set');
|
||||
console.error('Image', image);
|
||||
}
|
||||
|
||||
let breakpoints = getBreakpoints({ width: width, breakpoints: widths, layout: layout });
|
||||
breakpoints = [...new Set(breakpoints)].sort((a, b) => a - b);
|
||||
|
||||
const srcset = (await transform(image, breakpoints, Number(width) || undefined, Number(height) || undefined))
|
||||
.map(({ src, width }) => `${src} ${width}w`)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
src: typeof image === 'string' ? image : image.src,
|
||||
attributes: {
|
||||
width: width,
|
||||
height: height,
|
||||
srcset: srcset || undefined,
|
||||
sizes: sizes,
|
||||
style: `${getStyle({
|
||||
width: width,
|
||||
height: height,
|
||||
aspectRatio: aspectRatio,
|
||||
layout: layout,
|
||||
})}${style ?? ''}`,
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user