diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts index 866fd98bcf42c..5a21a39c85d1c 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudflare_loader.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {PLACEHOLDER_QUALITY} from './constants'; import {createImageLoader, ImageLoaderConfig} from './image_loader'; /** @@ -29,6 +30,12 @@ function createCloudflareUrl(path: string, config: ImageLoaderConfig) { if (config.width) { params += `,width=${config.width}`; } + + // When requesting a placeholder image we ask for a low quality image to reduce the load time. + if (config.isPlaceholder) { + params += `,quality=${PLACEHOLDER_QUALITY}`; + } + // Cloudflare image URLs format: // https://developers.cloudflare.com/images/image-resizing/url-format/ return `${path}/cdn-cgi/image/${params}/${config.src}`; diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts index 76db5918a8797..2c342d9784e21 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/cloudinary_loader.ts @@ -52,9 +52,15 @@ function createCloudinaryUrl(path: string, config: ImageLoaderConfig) { // https://cloudinary.com/documentation/image_transformations#transformation_url_structure // Example of a Cloudinary image URL: // https://res.cloudinary.com/mysite/image/upload/c_scale,f_auto,q_auto,w_600/marketing/tile-topics-m.png - let params = `f_auto,q_auto`; // sets image format and quality to "auto" + + // For a placeholder image, we use the lowest image setting available to reduce the load time + // else we use the auto size + const quality = config.isPlaceholder ? 'q_auto:low' : 'q_auto'; + + let params = `f_auto,${quality}`; if (config.width) { params += `,w_${config.width}`; } + return `${path}/image/upload/${params}/${config.src}`; } diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/constants.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/constants.ts new file mode 100644 index 0000000000000..0c5df6e5f07a1 --- /dev/null +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/constants.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Value (out of 100) of the requested quality for placeholder images. + */ +export const PLACEHOLDER_QUALITY = '20'; diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts index ed50f37bca920..057e052c9f4d2 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imagekit_loader.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {PLACEHOLDER_QUALITY} from './constants'; import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader'; /** @@ -53,5 +54,11 @@ export function createImagekitUrl(path: string, config: ImageLoaderConfig): stri urlSegments = [path, src]; } - return urlSegments.join('/'); + const url = new URL(urlSegments.join('/')); + + // When requesting a placeholder image we ask for a low quality image to reduce the load time. + if (config.isPlaceholder) { + url.searchParams.set('q', PLACEHOLDER_QUALITY); + } + return url.href; } diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts index ab35378363505..23317749e58f2 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/imgix_loader.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {PLACEHOLDER_QUALITY} from './constants'; import {createImageLoader, ImageLoaderConfig, ImageLoaderInfo} from './image_loader'; /** @@ -45,5 +46,10 @@ function createImgixUrl(path: string, config: ImageLoaderConfig) { if (config.width) { url.searchParams.set('w', config.width.toString()); } + + // When requesting a placeholder image we ask a low quality image to reduce the load time. + if (config.isPlaceholder) { + url.searchParams.set('q', PLACEHOLDER_QUALITY); + } return url.href; } diff --git a/packages/common/src/directives/ng_optimized_image/image_loaders/netlify_loader.ts b/packages/common/src/directives/ng_optimized_image/image_loaders/netlify_loader.ts index 29c14ec9e8db2..257929b693e94 100644 --- a/packages/common/src/directives/ng_optimized_image/image_loaders/netlify_loader.ts +++ b/packages/common/src/directives/ng_optimized_image/image_loaders/netlify_loader.ts @@ -16,6 +16,7 @@ import {RuntimeErrorCode} from '../../../errors'; import {isAbsoluteUrl, isValidPath} from '../url'; import {IMAGE_LOADER, ImageLoaderConfig, ImageLoaderInfo} from './image_loader'; +import {PLACEHOLDER_QUALITY} from './constants'; /** * Name and URL tester for Netlify. @@ -90,6 +91,13 @@ function createNetlifyUrl(config: ImageLoaderConfig, path?: string) { url.searchParams.set('w', config.width.toString()); } + // When requesting a placeholder image we ask for a low quality image to reduce the load time. + // If the quality is specified in the loader config - always use provided value. + const configQuality = config.loaderParams?.['quality'] ?? config.loaderParams?.['q']; + if (config.isPlaceholder && !configQuality) { + url.searchParams.set('q', PLACEHOLDER_QUALITY); + } + for (const [param, value] of Object.entries(config.loaderParams ?? {})) { if (validParams.has(param)) { url.searchParams.set(validParams.get(param)!, value.toString()); diff --git a/packages/common/test/image_loaders/image_loader_spec.ts b/packages/common/test/image_loaders/image_loader_spec.ts index 56c3eb369bc12..ed64f17db7a82 100644 --- a/packages/common/test/image_loaders/image_loader_spec.ts +++ b/packages/common/test/image_loaders/image_loader_spec.ts @@ -75,6 +75,13 @@ describe('Built-in image directive loaders', () => { const loader = createImgixLoader(path); expect(() => loader({src})).toThrowError(absoluteUrlError(src, path)); }); + + it('should load a low quality image when a placeholder is requested', () => { + const path = 'https://somesite.imgix.net'; + const loader = createImgixLoader(path); + const config = {src: 'img.png', isPlaceholder: true}; + expect(loader(config)).toBe(`${path}/img.png?auto=format&q=20`); + }); }); describe('Cloudinary loader', () => { @@ -97,6 +104,13 @@ describe('Built-in image directive loaders', () => { ).toBe(`${path}/image/upload/f_auto,q_auto/marketing/img-2.png`); }); + it('should load a low quality image when a placeholder is requested', () => { + const path = 'https://res.cloudinary.com/mysite'; + const loader = createCloudinaryLoader(path); + const config = {src: 'img.png', isPlaceholder: true}; + expect(loader(config)).toBe(`${path}/image/upload/f_auto,q_auto:low/img.png`); + }); + describe('input validation', () => { it('should throw if an absolute URL is provided as a loader input', () => { const path = 'https://res.cloudinary.com/mysite'; @@ -154,6 +168,13 @@ describe('Built-in image directive loaders', () => { ); }); + it('should load a low quality image when a placeholder is requested', () => { + const path = 'https://ik.imageengine.io/imagetest'; + const loader = createImageKitLoader(path); + const config = {src: 'img.png', isPlaceholder: true}; + expect(loader(config)).toBe(`${path}/img.png?q=20`); + }); + describe('input validation', () => { it('should throw if an absolute URL is provided as a loader input', () => { const path = 'https://ik.imageengine.io/imagetest'; @@ -210,6 +231,15 @@ describe('Built-in image directive loaders', () => { const loader = createCloudflareLoader(path); expect(() => loader({src})).toThrowError(absoluteUrlError(src, path)); }); + + it('should load a low quality image when a placeholder is requested', () => { + const path = 'https://mysite.com'; + const loader = createCloudflareLoader(path); + const config = {src: 'img.png', isPlaceholder: true}; + expect(loader(config)).toBe( + 'https://mysite.com/cdn-cgi/image/format=auto,quality=20/img.png', + ); + }); }); describe('Netlify loader', () => { @@ -261,6 +291,20 @@ describe('Built-in image directive loaders', () => { `NG0${RuntimeErrorCode.INVALID_LOADER_ARGUMENTS}: The Netlify image loader has detected an \`\` tag with the unsupported attribute "\`unknown\`".`, ); }); + + it('should load a low quality image when a placeholder is requested', () => { + const path = 'https://mysite.com'; + const loader = createNetlifyLoader(path); + const config = {src: 'img.png', isPlaceholder: true}; + expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png&q=20'); + }); + + it('should not load a low quality image when a placeholder is requested with a quality param', () => { + const path = 'https://mysite.com'; + const loader = createNetlifyLoader(path); + const config = {src: 'img.png', isPlaceholder: true, loaderParams: {quality: 50}}; + expect(loader(config)).toBe('https://mysite.com/.netlify/images?url=%2Fimg.png&q=50'); + }); }); describe('loader utils', () => {