vercel / next.js

The React Framework
https://nextjs.org
MIT License
127.54k stars 27.05k forks source link

srcset and sizes are not compatible #54371

Open monolithed opened 1 year ago

monolithed commented 1 year ago

Verify canary release

Provide environment information

➜ npx --no-install next info

    Operating System:
      Platform: darwin
      Arch: arm64
      Version: Darwin Kernel Version 22.6.0: Wed Jul  5 22:17:35 PDT 2023; root:xnu-8796.141.3~6/RELEASE_ARM64_T8112
    Binaries:
      Node: 18.17.1
      npm: 9.8.1
      Yarn: N/A
      pnpm: N/A
    Relevant Packages:
      next: 13.4.19
      eslint-config-next: 13.4.3
      react: 18.2.0
      react-dom: 18.2.0
      typescript: 5.1.6
    Next.js Config:
      output: N/A

warn  - Latest canary version not detected, detected: "13.4.19", newest: "13.4.20-canary.1".
        Please try the latest canary version (`npm install next@canary`) to confirm the issue still exists before creating a new issue.
        Read more - https://nextjs.org/docs/messages/opening-an-issue

Which area(s) of Next.js are affected? (leave empty if unsure)

Standalone mode (output: "standalone")

Describe the Bug

I want to generate an srcset based on exactly what I passed to sizes. It's required because the sizes of my images are carefully tailored to match different screen dimensions, and their distortion or arbitrary sizing is unacceptable.

Having set the 'sizes' attribute, I encountered the following issues:

1) The values in the srcset do not match the sizes values 2) Only the largest file size is always delivered 3) The srcset generated is larger than the actual file size. 4) In the standalone directory, I couldn't find images different from the main one. Should they be generated? 5) I tried to set srcSet manually, but it's not allowed. Why?

In additional, I am aware of the existence of image.imageSizes, but I cannot set it because my project has over 50 pages, each with a unique set of image sizes. I really don't understand the purpose of this option, as each image has unique proportions. This option should be generated for each image individually, rather than universally for all, and certainly not have predefined values exceeding the actual image sizes.

Input:

<Image src={image.url}
       quality={100}
       placeholder="blur"
       fill={true}
       sizes="(max-width: 1500px) 460w,
                  (max-width: 992px) 100vw,
                  (max-width: 712px) 642w,
                  (max-width: 600px) 450w,
                  (max-width: 450px) 350w,
                  (max-width: 375px) 310w,
                  (max-width: 320px) 280w,
                  100vw"
       alt=""
/>

Output:

<img alt="" loading="lazy" decoding="async" data-nimg="fill" class="card_image__tpJxJ" 
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" 
sizes="
    (max-width: 1500px) 460w, 
    (max-width: 992px) 100vw, 
    (max-width: 712px) 642w, 
    (max-width: 600px) 450w, 
    (max-width: 450px) 350w, 
    (max-width: 375px) 310w, 
    (max-width: 320px) 280w, 100vw" 
srcset="
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=640&amp;q=100 640w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=750&amp;q=100 750w,
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=828&amp;q=100 828w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=1080&amp;q=100 1080w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=1200&amp;q=100 1200w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=1920&amp;q=100 1920w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=2048&amp;q=100 2048w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=3840&amp;q=100 3840w"
    src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=3840&amp;q=100"
>
>>
Screenshot 2023-08-22 at 21 14 27 Screenshot 2023-08-22 at 21 16 07

Expected Behavior

<img alt="" loading="lazy" decoding="async" data-nimg="fill" class="card_image__tpJxJ" 
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" 
sizes="
    (max-width: 1500px) 460w, 
    (max-width: 992px) 100vw, 
    (max-width: 712px) 642w, 
    (max-width: 600px) 450w, 
    (max-width: 450px) 350w, 
    (max-width: 375px) 310w, 
    (max-width: 320px) 280w, 100vw" 
srcset="
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=460w&amp;q=100 460w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=100vw&amp;q=100 100vw,
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=642w&amp;q=100 642w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=450w&amp;q=100 450w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=350w&amp;q=100 350w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=310w&amp;q=100 310w, 
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=280w&amp;q=100 280w, 
    src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fcommunications.ce88988d.webp&amp;w=100vw&amp;q=100"
>
>>

Why does it matter?

1400px

Screenshot 2023-08-23 at 15 46 58

800px

Screenshot 2023-08-23 at 15 46 27

450px

Screenshot 2023-08-23 at 15 46 41

If you pay attention, you'll notice that for a medium-sized screen (800px), a larger image is being used compared to a larger screen (1400px). And this is a common practice on many websites.

monolithed commented 1 year ago

Duplicate https://github.com/vercel/next.js/issues/27547?

monolithed commented 1 year ago

It's a bit amusing to see such warnings over a non-optimized Image tag:

Screenshot 2023-09-01 at 00 28 45




As a temporary workaround: react-lazy-load-image-component + Squoosh + svg-blur-up or the following script:

import fs from 'node:fs';
import {readdir} from 'node:fs/promises';
import {parse, resolve} from 'node:path';

import sharp, {
    Metadata,
    Sharp
} from 'sharp';

type Options = {
    size?: number;
    extension?: string;
    stdDeviation?: number;
    preserveAspectRatio?: 'none' | 'xMidYMid' | 'xMidYMid slice';
};

class BlurImage {
    constructor(
        protected input: string,
        protected options: Options = {}
    )
    {
        this.options = {
            stdDeviation: 60,
            size: 40,
            extension: '.webp',
            preserveAspectRatio: 'xMidYMid',
            ...options
        };
    }

    getSVGTemplate = (
        image: Buffer,
        {
            width,
            height,
            format
        }: Metadata
    ): Buffer => {
        const content = image.toString('base64');

        const {
            preserveAspectRatio,
            stdDeviation
        } = this.options;

        return Buffer.from(
            `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">
                <filter id="a" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
                    <feGaussianBlur stdDeviation="${stdDeviation}" />
                    <feComponentTransfer>
                        <feFuncA type="discrete" tableValues="1 1"/>
                    </feComponentTransfer>
                </filter>
                <image height="100%"
                       width="100%"
                       x="0"
                       y="0"
                       preserveAspectRatio="${preserveAspectRatio}"
                       filter="url(#a)"
                       href="data:image/${format};base64,${content}"
                />
            </svg>`
        );
    };

    resizeImage = (image: Sharp) => {
        const {size} = this.options;

        return image.resize(size).toBuffer({resolveWithObject: true});
    };

    getImage = async (file: string) => {
        const image = sharp(file, {sequentialRead: true});
        const meta = await image.metadata();
        const {data} = await this.resizeImage(image);

        return this.getSVGTemplate(data, meta);
    }

    convert = async (): Promise<void> => {
        const files = await this.getFiles(this.input);

        for (const file of files) {
            const {name, ext, dir} = parse(file);

            if (ext !== this.options.extension) {
                continue;
            }

            const output = `${resolve(dir, name)}.svg`;
            const image = await this.getImage(file);

            fs.writeFileSync(output, image, {
                encoding: 'utf8',
                flag: 'w'
            });
        }
    };

    getFiles = async (directory: string): Promise<string[]> => {
        const entries = await readdir(directory, {
            withFileTypes: true
        });

        const files = entries.map((entry) => {
            const child = resolve(directory, entry.name);

            if (entry.isDirectory()) {
                return this.getFiles(child);
            } else {
                return child;
            }
        });

        const result = await Promise.all(files);

        return result.flat();
    }
}

const blurImage = new BlurImage('./public');

blurImage.convert(); // converts *.webp images to *.svg
.wrapper {
    position: relative;
}

.placeholder {
    position: absolute;
    height: 100%;
    width: 100%;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
    background-size: cover;
    background-position: 50% 50%;
    background-repeat: no-repeat;
}

.image {
    overflow: hidden;
    display: block;
    position: relative;
    object-fit: cover;
    object-position: center;
    width: 100%;
    height: 100%;
}
import React from 'react';
import cn from 'classnames';
import {LazyLoadImage} from 'react-lazy-load-image-component';
import style from './index.module.css';

type Attributes = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'placeholder' | 'alt'>;

type Props = Attributes & {
    src: string;
    alt?: string;
    placeholder?: boolean;
};

const Image: React.FunctionComponent<Props> = ({
    className,
    placeholder,
    src,
    alt,
    ...props
}) => {
    if (!placeholder) {
        return (
            <img src={src} className={cn([style.image, className])} alt={alt}/>
        )
    }

    const placeholderSrc = src.replace(/[^.]+$/, 'svg');

    return (
        <LazyLoadImage src={src}
                       wrapperClassName={style.wrapper}
                       placeholder={
                           <img src={placeholderSrc}
                                className={
                                    cn([
                                        style.placeholder,
                                        style.image,
                                        className
                                    ])}
                                alt={alt}
                           />
                       }
                       className={cn([
                           style.image,
                           className
                       ])}
                       decoding="async"
                       width="100%"
                       height="100%"
                       alt=""
                       {...props}
        />
    );
};

export {Image};
export type {Props as ImageProps};
<Image src="/test.webp"
       placeholder={true} 
       srcset="x.jpg 480w, y.jpg 800w"
       sizes="(max-width: 600px) 480px, 800px"
/>
karlhorky commented 1 year ago

Wonder if sizes="auto" will help with this problem in future (once more browsers support it):