diff --git a/e2e-tests/visual-regression/src/pages/images/fullWidth.js b/e2e-tests/visual-regression/src/pages/images/fullWidth.js index 010a3a5259131..9609bbd807020 100644 --- a/e2e-tests/visual-regression/src/pages/images/fullWidth.js +++ b/e2e-tests/visual-regression/src/pages/images/fullWidth.js @@ -9,7 +9,7 @@ const Page = () => { query { file(relativePath: { eq: "cornwall.jpg" }) { childImageSharp { - gatsbyImageData(width: 1024, layout: FULL_WIDTH) + gatsbyImageData(layout: FULL_WIDTH) } } } diff --git a/e2e-tests/visual-regression/src/pages/static-images/fullWidth.js b/e2e-tests/visual-regression/src/pages/static-images/fullWidth.js index 99fb1ca0a7834..1657f7415ff8c 100644 --- a/e2e-tests/visual-regression/src/pages/static-images/fullWidth.js +++ b/e2e-tests/visual-regression/src/pages/static-images/fullWidth.js @@ -12,7 +12,6 @@ const Page = () => { src="../../images/cornwall.jpg" loading="eager" layout="fullWidth" - width={1024} alt="cornwall" /> diff --git a/packages/gatsby-plugin-image/README.md b/packages/gatsby-plugin-image/README.md index ce902f9df8a51..ba828df196871 100644 --- a/packages/gatsby-plugin-image/README.md +++ b/packages/gatsby-plugin-image/README.md @@ -316,26 +316,28 @@ The optional helper function `getImage` takes a file node and returns `file?.chi These arguments can be passed to the `gatsbyImageData()` resolver: -- **width**: The display width of the generated image for layout = FIXED, if layout = CONSTRAINED it's the display width of the largest generated image. The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities. -- **height**: If set, the height of the generated image. If omitted, it is calculated from the supplied width, matching the aspect ratio of the source image. +- **width**: The display width of the generated image for layout = FIXED, if layout = CONSTRAINED it's the maximum display width. Ignored for FULL_WIDTH images. +- **height**: If set, the height of the generated image. If omitted, it is calculated from the supplied width, matching the aspect ratio of the source image. Ignored for FULL_WIDTH images. +- **aspectRatio**: Forces an image to the specified aspect ratio, cropping if needed. The value is a number, but can be clearer to express as a fraction, e.g. `aspectRatio={16/9}` - **placeholder**: Format of generated placeholder image. - - `BLURRED`: (default) a blurred, low resolution image, encoded as a base64 data URI - - `TRACED_SVG`: a low-resolution traced SVG of the image. + - `DOMINANT_COLOR`: (default) A solid color, calculated from the dominant color of the image. + - `BLURRED`: a blurred, low resolution image, encoded as a base64 data URI + - `TRACED_SVG`: a single-color traced SVG of the image. - `NONE`: no placeholder. Set "background" to use a fixed background color. - - `DOMINANT_COLOR`: a solid color, calculated from the dominant color of the image. - **layout**: The layout for the image. - `CONSTRAINED`: (default) Resizes to fit its container, up to a maximum width, at which point it will remain fixed in size. - `FIXED`: A static image size, that does not resize according to the screen width - `FULL_WIDTH`: The image resizes to fit its container. Pass a "sizes" option if it isn't going to be the full width of the screen. -- **outputPixelDensities**: A list of image pixel densities to generate, for high-resolution (retina) screens. It will never generate images larger than the source, and will always include a 1x image. - Default is `[ 0.25, 0.5, 1, 2 ]`, for fullWidth/constrained images, and `[ 1, 2 ]` for fixed. - **sizes**: The "[sizes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images)" attribute, passed to the `` tag. This describes the display size of the image. This does not affect the generated images, but is used by the browser to decide which images to download. You can leave this blank for fixed images, or if the responsive image container will be the full width of the screen. In these cases we will generate an appropriate value. If, however, you are generating responsive images that are not the full width of the screen, you should provide a sizes property for best performance. You can alternatively pass this value to the component. - **formats**: an array of file formats to generate. The default is `[AUTO, WEBP]`, which means it will generate images in the same format as the source image, as well as in the next-generation [WebP](https://developers.google.com/speed/webp) format. We strongly recommend you do not change this option, as doing so will affect performance scores. - **quality**: The default quality. This is overridden by any format-specific options -- **blurredOptions**: Options for the low-resolution placeholder image. Set placeholder to "BLURRED" to use this +- **outputPixelDensities**: A list of image pixel densities to generate, for high-resolution (retina) screens. It will never generate images larger than the source, and will always include a 1x image. + Default is `[ 0.25, 0.5, 1, 2 ]`, for `CONSTRAINED` images, and `[ 1, 2 ]` for `FIXED`. Ignored for `FULL_WIDTH`, which uses `breakpoints` instead. +- **breakpoints**: Output widths to generate for full width images. Default is `[750, 1080, 1366, 1920]`, which is suitable for most common device resolutions. It will never generate an image larger than the source image. The browser will automatically choose the most appropriate. +- **blurredOptions**: Options for the low-resolution placeholder image. Set placeholder to `BLURRED` to use this - width - toFormat -- **tracedSVGOptions**: Options for traced placeholder SVGs. You also should set placeholder to "SVG". +- **tracedSVGOptions**: Options for traced placeholder SVGs. You also should set placeholder to `TRACED_SVG`. - **jpgOptions**: Options to pass to sharp when generating JPG images. - quality - progressive diff --git a/packages/gatsby-plugin-image/src/__tests__/image-utils.ts b/packages/gatsby-plugin-image/src/__tests__/image-utils.ts index 879a049a9c803..42551c5bcd452 100644 --- a/packages/gatsby-plugin-image/src/__tests__/image-utils.ts +++ b/packages/gatsby-plugin-image/src/__tests__/image-utils.ts @@ -37,6 +37,11 @@ const args: IGatsbyImageHelperArgs = { const fluidArgs: IGatsbyImageHelperArgs = { ...args, + sourceMetadata: { + width: 2000, + height: 1500, + format: `jpg`, + }, layout: `fullWidth`, } @@ -179,12 +184,14 @@ describe(`the image data helper`, () => { it(`returns URLs for fullWidth`, () => { const data = generateImageData(fluidArgs) expect(data?.images?.fallback?.src).toEqual( - `https://example.com/afile.jpg/400/300/image.jpg` + `https://example.com/afile.jpg/750/563/image.jpg` ) - expect(data.images?.sources?.[0].srcSet).toEqual( - `https://example.com/afile.jpg/100/75/image.webp 100w,\nhttps://example.com/afile.jpg/200/150/image.webp 200w,\nhttps://example.com/afile.jpg/400/300/image.webp 400w,\nhttps://example.com/afile.jpg/800/600/image.webp 800w` - ) + expect(data.images?.sources?.[0].srcSet) + .toEqual(`https://example.com/afile.jpg/750/563/image.webp 750w, +https://example.com/afile.jpg/1080/810/image.webp 1080w, +https://example.com/afile.jpg/1366/1025/image.webp 1366w, +https://example.com/afile.jpg/1920/1440/image.webp 1920w`) }) it(`converts to PNG if requested`, () => { diff --git a/packages/gatsby-plugin-image/src/image-utils.ts b/packages/gatsby-plugin-image/src/image-utils.ts index a1d15ca89d2cc..6fa58f5faa09a 100644 --- a/packages/gatsby-plugin-image/src/image-utils.ts +++ b/packages/gatsby-plugin-image/src/image-utils.ts @@ -4,6 +4,7 @@ import { IGatsbyImageData } from "." import type sharp from "gatsby-plugin-sharp/safe-sharp" const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] +const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] const DEFAULT_FLUID_WIDTH = 800 const DEFAULT_FIXED_WIDTH = 400 @@ -38,6 +39,7 @@ export interface ISharpGatsbyImageArgs { webpOptions?: Record avifOptions?: Record blurredOptions?: { width?: number; toFormat?: ImageFormat } + breakpoints?: Array } export interface IImageSizeArgs { @@ -46,6 +48,7 @@ export interface IImageSizeArgs { layout?: Layout filename: string outputPixelDensities?: Array + breakpoints?: Array fit?: Fit reporter?: IReporter sourceMetadata: { width: number; height: number } @@ -89,6 +92,7 @@ export interface IGatsbyImageHelperArgs { sourceMetadata?: { width: number; height: number; format: ImageFormat } fit?: Fit options?: Record + breakpoints?: Array } const warn = (message: string): void => console.warn(message) @@ -292,8 +296,10 @@ export function calculateImageSizes(args: IImageSizeArgs): IImageSizes { if (layout === `fixed`) { return fixedImageSizes(args) - } else if (layout === `fullWidth` || layout === `constrained`) { + } else if (layout === `constrained`) { return responsiveImageSizes(args) + } else if (layout === `fullWidth`) { + return responsiveImageSizes({ breakpoints: DEFAULT_BREAKPOINTS, ...args }) } else { reporter.warn( `No valid layout was provided for the image at ${filename}. Valid image layouts are fixed, fullWidth, and constrained.` @@ -386,6 +392,8 @@ export function responsiveImageSizes({ height, fit = `cover`, outputPixelDensities = DEFAULT_PIXEL_DENSITIES, + breakpoints, + layout, }: IImageSizeArgs): IImageSizes { let sizes let aspectRatio = imgDimensions.width / imgDimensions.height @@ -430,11 +438,23 @@ export function responsiveImageSizes({ width = Math.round(width) - sizes = densities.map(density => Math.round(density * (width as number))) - sizes = sizes.filter(size => size <= imgDimensions.width) + if (breakpoints?.length > 0) { + sizes = breakpoints.filter(size => size <= imgDimensions.width) + + // If a larger breakpoint has been filtered-out, add the actual image width instead + if ( + sizes.length < breakpoints.length && + !sizes.includes(imgDimensions.width) + ) { + sizes.push(imgDimensions.width) + } + } else { + sizes = densities.map(density => Math.round(density * (width as number))) + sizes = sizes.filter(size => size <= imgDimensions.width) + } // ensure that the size passed in is included in the final output - if (!sizes.includes(width)) { + if (layout === `constrained` && !sizes.includes(width)) { sizes.push(width) } sizes = sizes.sort(sortNumeric) diff --git a/packages/gatsby-plugin-sharp/src/__tests__/utils.js b/packages/gatsby-plugin-sharp/src/__tests__/utils.js index 9df25e69f2be4..9b797d40c6f24 100644 --- a/packages/gatsby-plugin-sharp/src/__tests__/utils.js +++ b/packages/gatsby-plugin-sharp/src/__tests__/utils.js @@ -103,7 +103,7 @@ describe(`calculateImageSizes (fixed)`, () => { imgDimensions, } const { sizes } = calculateImageSizes(args) - expect(sizes).toEqual(expect.arrayContaining([120, 240])) + expect(sizes).toEqual([120, 240]) }) it(`should create images of different sizes based on pixel densities with a given height`, () => { @@ -114,7 +114,7 @@ describe(`calculateImageSizes (fixed)`, () => { imgDimensions, } const { sizes } = calculateImageSizes(args) - expect(sizes).toEqual(expect.arrayContaining([120, 240])) + expect(sizes).toEqual([120, 240]) }) }) @@ -172,7 +172,7 @@ describe(`calculateImageSizes (fullWidth & constrained)`, () => { imgDimensions, } const { sizes } = calculateImageSizes(args) - expect(sizes).toEqual(expect.arrayContaining([80, 160, 320, 640])) + expect(sizes).toEqual([80, 160, 320, 640]) }) it(`should create images of different sizes (0.25x, 0.5x, 1x) without any defined size provided`, () => { @@ -182,63 +182,101 @@ describe(`calculateImageSizes (fullWidth & constrained)`, () => { imgDimensions, } const { sizes } = calculateImageSizes(args) - expect(sizes).toEqual(expect.arrayContaining([200, 400, 800])) + expect(sizes).toEqual([200, 400, 800]) }) - it(`should return sizes of provided srcSetBreakpoints`, () => { - const srcSetBreakpoints = [50, 70, 150, 250, 300] + it(`should return sizes of provided breakpoints in fullWidth`, () => { + const breakpoints = [50, 70, 150, 250, 300] const width = 500 const args = { layout: `fullWidth`, width, - srcSetBreakpoints, + breakpoints, file, imgDimensions, reporter, } const { sizes } = calculateImageSizes(args) - expect(sizes).toEqual(expect.arrayContaining([50, 70, 150, 250, 300, 500])) + expect(sizes).toEqual([50, 70, 150, 250, 300]) }) - it(`should reject any srcSetBreakpoints larger than the original width`, () => { - const srcSetBreakpoints = [ + it(`should include provided width along with breakpoints in constrained`, () => { + const breakpoints = [50, 70, 150, 250, 300] + const width = 500 + const args = { + layout: `constrained`, + width, + breakpoints, + file, + imgDimensions, + reporter, + } + + const { sizes } = calculateImageSizes(args) + expect(sizes).toEqual([50, 70, 150, 250, 300, 500]) + }) + + it(`should reject any breakpoints larger than the original width`, () => { + const breakpoints = [ 50, 70, 150, 250, - 1250, // shouldn't be included, larger than original width + 1200, + 1800, // shouldn't be included, larger than original width ] const width = 1500 // also shouldn't be included const args = { layout: `fullWidth`, width, - srcSetBreakpoints, + breakpoints, + file, + imgDimensions, + reporter, + } + + const { sizes } = calculateImageSizes(args) + expect(sizes).toEqual([50, 70, 150, 250, 1200]) + }) + + it(`should add the original width instead of larger breakpoints`, () => { + const breakpoints = [ + 50, + 70, + 150, + 250, + 1800, // shouldn't be included, larger than original width + ] + const width = 1300 + const args = { + layout: `fullWidth`, + width, + breakpoints, file, imgDimensions, reporter, } const { sizes } = calculateImageSizes(args) - expect(sizes).toEqual(expect.arrayContaining([50, 70, 150, 250])) - expect(sizes).toEqual(expect.not.arrayContaining([1250, 1500])) + expect(sizes).toEqual([50, 70, 150, 250, 1200]) }) - it(`should only uses sizes from srcSetBreakpoints when outputPixelDensities are also passed in`, () => { - const srcSetBreakpoints = [400, 800] // should find these + it(`should ignore outputPixelDensities when breakpoints are passed in`, () => { + const breakpoints = [400, 800] // should find these const width = 500 const args = { layout: `fullWidth`, width, outputPixelDensities: [2, 4], // and ignore these, ie [1000, 2000] - srcSetBreakpoints, + breakpoints, file, imgDimensions, reporter, } const { sizes } = calculateImageSizes(args) - expect(sizes).toEqual(expect.arrayContaining([400, 500, 800])) + expect(sizes).toEqual([400, 800]) }) it(`should adjust fullWidth sizes according to fit type`, () => { diff --git a/packages/gatsby-plugin-sharp/src/image-data.ts b/packages/gatsby-plugin-sharp/src/image-data.ts index 857d43d3cdc3c..e3f1e8e33ddaf 100644 --- a/packages/gatsby-plugin-sharp/src/image-data.ts +++ b/packages/gatsby-plugin-sharp/src/image-data.ts @@ -9,6 +9,8 @@ import { createTransformObject } from "./plugin-options" const DEFAULT_BLURRED_IMAGE_WIDTH = 20 +const DEFAULT_BREAKPOINTS = [750, 1080, 1366, 1920] + type ImageFormat = "jpg" | "png" | "webp" | "avif" | "" | "auto" export type FileNode = Node & { absolutePath?: string @@ -84,7 +86,7 @@ export async function generateImageData({ }: IImageDataArgs): Promise { const { layout = `constrained`, - placeholder = `blurred`, + placeholder = `dominantColor`, tracedSVGOptions = {}, transformOptions = {}, quality, @@ -92,6 +94,12 @@ export async function generateImageData({ args.formats = args.formats || [`auto`, `webp`] + if (layout === `fullWidth`) { + args.breakpoints = args.breakpoints?.length + ? args.breakpoints + : DEFAULT_BREAKPOINTS + } + const { fit = `cover`, cropFocus = sharp.strategy.attention, @@ -103,16 +111,12 @@ export async function generateImageData({ reporter.warn( `Specifying fullWidth images will ignore the width and height arguments, you may want a constrained image instead. Otherwise, use the breakpoints argument.` ) - args.width = undefined + args.width = metadata.width args.height = undefined } if (!args.width && !args.height && metadata.width) { - if (layout === `fullWidth`) { - args.width = Math.round(metadata.width / 2) - } else { - args.width = metadata.width - } + args.width = metadata.width } if (args.aspectRatio) { @@ -206,9 +210,10 @@ export async function generateImageData({ const sizes = args.sizes || getSizes(imageSizes.unscaledWidth, layout) - const primaryIndex = imageSizes.sizes.findIndex( - size => size === imageSizes.unscaledWidth - ) + const primaryIndex = + layout === `fullWidth` + ? imageSizes.sizes.length - 1 // The largest image + : imageSizes.sizes.findIndex(size => size === imageSizes.unscaledWidth) if (primaryIndex === -1) { reporter.error( diff --git a/packages/gatsby-plugin-sharp/src/utils.js b/packages/gatsby-plugin-sharp/src/utils.js index 99a055724aafc..1064afe7bd3fe 100644 --- a/packages/gatsby-plugin-sharp/src/utils.js +++ b/packages/gatsby-plugin-sharp/src/utils.js @@ -128,7 +128,6 @@ export function fixedImageSizes({ height, transformOptions = {}, outputPixelDensities = DEFAULT_PIXEL_DENSITIES, - srcSetBreakpoints, reporter, }) { let aspectRatio = imgDimensions.width / imgDimensions.height @@ -204,7 +203,8 @@ export function responsiveImageSizes({ height, transformOptions = {}, outputPixelDensities = DEFAULT_PIXEL_DENSITIES, - srcSetBreakpoints, + breakpoints, + layout, }) { const { fit = `cover` } = transformOptions @@ -250,24 +250,23 @@ export function responsiveImageSizes({ width = Math.round(width) - // Create sizes (in width) for the image if no custom breakpoints are - // provided. If the max width of the container for the rendered markdown file - // is 800px, the sizes would then be: 200, 400, 800, 1600 if using - // the default outputPixelDensities - // - // This is enough sizes to provide close to the optimal image size for every - // device size / screen resolution while (hopefully) not requiring too much - // image processing time (Sharp has optimizations thankfully for creating - // multiple sizes of the same input file) - if (srcSetBreakpoints?.length > 0) { - sizes = srcSetBreakpoints.filter(size => size <= imgDimensions.width) + if (breakpoints?.length > 0) { + sizes = breakpoints.filter(size => size <= imgDimensions.width) + + // If a larger breakpoint has been filtered-out, add the actual image width instead + if ( + sizes.length < breakpoints.length && + !sizes.includes(imgDimensions.width) + ) { + sizes.push(imgDimensions.width) + } } else { sizes = densities.map(density => Math.round(density * width)) sizes = sizes.filter(size => size <= imgDimensions.width) } // ensure that the size passed in is included in the final output - if (!sizes.includes(width)) { + if (layout === `constrained` && !sizes.includes(width)) { sizes.push(width) } sizes = sizes.sort((a, b) => a - b) diff --git a/packages/gatsby-transformer-sharp/src/customize-schema.js b/packages/gatsby-transformer-sharp/src/customize-schema.js index a7341c716349b..675dde2a07cfe 100644 --- a/packages/gatsby-transformer-sharp/src/customize-schema.js +++ b/packages/gatsby-transformer-sharp/src/customize-schema.js @@ -430,7 +430,7 @@ const imageNodeType = ({ }, placeholder: { type: ImagePlaceholderType, - defaultValue: `blurred`, + defaultValue: `dominantColor`, description: stripIndent` Format of generated placeholder image, displayed while the main image loads. BLURRED: a blurred, low resolution image, encoded as a base64 data URI (default) @@ -444,13 +444,12 @@ const imageNodeType = ({ }, tracedSVGOptions: { type: PotraceType, - defaultValue: false, - description: `Options for traced placeholder SVGs. You also should set placeholder to "SVG".`, + description: `Options for traced placeholder SVGs. You also should set placeholder to "TRACED_SVG".`, }, formats: { type: GraphQLList(ImageFormatType), description: stripIndent` - The image formats to generate. Valid values are "AUTO" (meaning the same format as the source image), "JPG", "PNG" and "WEBP". + The image formats to generate. Valid values are "AUTO" (meaning the same format as the source image), "JPG", "PNG", "WEBP" and "AVIF". The default value is [AUTO, WEBP], and you should rarely need to change this. Take care if you specify JPG or PNG when you do not know the formats of the source images, as this could lead to unwanted results such as converting JPEGs to PNGs. Specifying both PNG and JPG is not supported and will be ignored. @@ -461,15 +460,24 @@ const imageNodeType = ({ type: GraphQLList(GraphQLFloat), description: stripIndent` A list of image pixel densities to generate. It will never generate images larger than the source, and will always include a 1x image. - Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, 3x, and [0.25, 0.5, 1, 2] for fluid. In this case, an image with a fluid layout and width = 400 would generate images at 100, 200, 400 and 800px wide`, + Default is [ 1, 2 ] for FIXED images, meaning 1x and 2x and [0.25, 0.5, 1, 2] for CONSTRAINED. In this case, an image with a constrained layout + and width = 400 would generate images at 100, 200, 400 and 800px wide. Ignored for FULL_WIDTH images, which use breakpoints instead`, + }, + breakpoints: { + type: GraphQLList(GraphQLInt), + description: stripIndent` + Specifies the image widths to generate. For FIXED and CONSTRAINED images it is better to allow these to be determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + `, }, sizes: { type: GraphQLString, - defaultValue: ``, description: stripIndent` The "sizes" property, passed to the img tag. This describes the display size of the image. - This does not affect the generated images, but is used by the browser to decide which images to download. You can leave this blank for fixed images, or if the responsive image - container will be the full width of the screen. In these cases we will generate an appropriate value. + This does not affect the generated images, but is used by the browser to decide which images to download. + You should usually leave this blank, and a suitable value will be calculated. The exception is if a FULL_WIDTH image + does not actually span the full width of the screen, in which case you should pass the correct size here. `, }, quality: {