summaryrefslogtreecommitdiff
path: root/src/utils/images-optimization.ts
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2025-07-21 21:56:55 +0300
committerDawid Rycerz <dawid@rycerz.xyz>2025-07-21 21:56:55 +0300
commitc735556726e75428550a3d28a2cf58e2c8490b7d (patch)
treefd0ae29d1636b825abeedff6b99d3376bb383135 /src/utils/images-optimization.ts
Initial template
Diffstat (limited to 'src/utils/images-optimization.ts')
-rw-r--r--src/utils/images-optimization.ts351
1 files changed, 351 insertions, 0 deletions
diff --git a/src/utils/images-optimization.ts b/src/utils/images-optimization.ts
new file mode 100644
index 0000000..67eea8b
--- /dev/null
+++ b/src/utils/images-optimization.ts
@@ -0,0 +1,351 @@
+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 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;
+ objectPosition?: string;
+
+ format?: string;
+}
+
+export type ImagesOptimizer = (
+ image: ImageMetadata | string,
+ breakpoints: number[],
+ width?: number,
+ height?: number,
+ format?: string
+) => 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 astroAssetsOptimizer: ImagesOptimizer = async (
+ image,
+ breakpoints,
+ _width,
+ _height,
+ format = undefined
+) => {
+ if (!image) {
+ return [];
+ }
+
+ return Promise.all(
+ breakpoints.map(async (w: number) => {
+ const result = await getImage({ src: image, width: w, inferSize: true, ...(format ? { format: format } : {}) });
+
+ return {
+ src: result?.src,
+ width: result?.attributes?.width ?? w,
+ height: result?.attributes?.height,
+ };
+ })
+ );
+};
+
+export const isUnpicCompatible = (image: string) => {
+ return typeof parseUrl(image) !== 'undefined';
+};
+
+/* ** */
+export const unpicOptimizer: ImagesOptimizer = async (image, breakpoints, width, height, format = undefined) => {
+ if (!image || typeof image !== 'string') {
+ return [];
+ }
+
+ const urlParsed = parseUrl(image);
+ if (!urlParsed) {
+ return [];
+ }
+
+ return Promise.all(
+ breakpoints.map(async (w: number) => {
+ const _height = width && height ? computeHeight(w, width / height) : height;
+ const url =
+ transformUrl({
+ url: image,
+ width: w,
+ height: _height,
+ cdn: urlParsed.cdn,
+ ...(format ? { format: format } : {}),
+ }) || image;
+ return {
+ src: String(url),
+ width: w,
+ height: _height,
+ };
+ })
+ );
+};
+
+/* ** */
+export async function getImagesOptimized(
+ image: ImageMetadata | string,
+ {
+ src: _,
+ width,
+ height,
+ sizes,
+ aspectRatio,
+ objectPosition,
+ widths,
+ layout = 'constrained',
+ style = '',
+ format,
+ ...rest
+ }: ImageProps,
+ transform: ImagesOptimizer = () => Promise.resolve([])
+): Promise<{ src: string; attributes: HTMLAttributes<'img'> }> {
+ 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, format))
+ .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,
+ objectPosition: objectPosition,
+ layout: layout,
+ })}${style ?? ''}`,
+ ...rest,
+ },
+ };
+}