0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-01-06 22:10:10 -05:00

fix: remote srcset images not being resized and deduplication not working in certain cases (#8823)

* fix: remote `srcset` images not being resized

* fix: hash keys ordered to reduce duplicate assets

* fix: move to workaround for hashing function

* fix: rework transform logic for densities and widths

* chore: changeset

* test: add tests

* fix: forced base srcset when using widths

* fix: unnecessary coalescing

* refactor: adjust with feedback

---------

Co-authored-by: Matteo Manfredi <matteo@manfredi.io>
This commit is contained in:
Erika 2023-10-16 21:02:01 +02:00 committed by GitHub
parent b405b039a6
commit 8946f2a256
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 212 additions and 64 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Fix duplicate images being created in some cases when using densities and/or widths

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
fix remote srcset images not being resized

View file

@ -57,14 +57,13 @@ if (fallbackImage.srcSet.values.length > 0) {
<picture {...pictureAttributes}>
{
Object.entries(optimizedImages).map(([_, image]) => (
<source
srcset={`${image.src}${
image.srcSet.values.length > 0 ? ' , ' + image.srcSet.attribute : ''
}`}
type={'image/' + image.options.format}
/>
))
Object.entries(optimizedImages).map(([_, image]) => {
const srcsetAttribute =
props.densities || (!props.densities && !props.widths)
? `${image.src}${image.srcSet.values.length > 0 ? ', ' + image.srcSet.attribute : ''}`
: image.srcSet.attribute;
return <source srcset={srcsetAttribute} type={'image/' + image.options.format} />;
})
}
<img src={fallbackImage.src} {...additionalAttributes} {...fallbackImage.attributes} />
</picture>

View file

@ -139,6 +139,7 @@
"common-ancestor-path": "^1.0.1",
"cookie": "^0.5.0",
"debug": "^4.3.4",
"deterministic-object-hash": "^1.3.1",
"devalue": "^4.3.2",
"diff": "^5.1.0",
"es-module-lexer": "^1.3.0",

View file

@ -213,6 +213,10 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
options.format = DEFAULT_OUTPUT_FORMAT;
}
// Sometimes users will pass number generated from division, which can result in floating point numbers
if (options.width) options.width = Math.round(options.width);
if (options.height) options.height = Math.round(options.height);
return options;
},
getHTMLAttributes(options) {
@ -230,16 +234,33 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
},
getSrcSet(options) {
const srcSet: SrcSetValue[] = [];
const { targetWidth, targetHeight } = getTargetDimensions(options);
const { targetWidth } = getTargetDimensions(options);
const { widths, densities } = options;
const targetFormat = options.format ?? DEFAULT_OUTPUT_FORMAT;
const aspectRatio = targetWidth / targetHeight;
const imageWidth = isESMImportedImage(options.src) ? options.src.width : options.width;
const maxWidth = imageWidth ?? Infinity;
// For remote images, we don't know the original image's dimensions, so we cannot know the maximum width
// It is ultimately the user's responsibility to make sure they don't request images larger than the original
let imageWidth = options.width;
let maxWidth = Infinity;
// REFACTOR: Could we merge these two blocks?
// However, if it's an imported image, we can use the original image's width as a maximum width
if (isESMImportedImage(options.src)) {
imageWidth = options.src.width;
maxWidth = imageWidth;
}
// Since `widths` and `densities` ultimately control the width and height of the image,
// we don't want the dimensions the user specified, we'll create those ourselves.
const {
width: transformWidth,
height: transformHeight,
...transformWithoutDimensions
} = options;
// Collect widths to generate from specified densities or widths
const allWidths: { maxTargetWidth: number; descriptor: `${number}x` | `${number}w` }[] = [];
if (densities) {
// Densities can either be specified as numbers, or descriptors (ex: '1x'), we'll convert them all to numbers
const densityValues = densities.map((density) => {
if (typeof density === 'number') {
return density;
@ -248,64 +269,49 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
}
});
// Calculate the widths for each density, rounding to avoid floats.
const densityWidths = densityValues
.sort()
.map((density) => Math.round(targetWidth * density));
densityWidths.forEach((width, index) => {
const maxTargetWidth = Math.min(width, maxWidth);
// If the user passed dimensions, we don't want to add it to the srcset
const { width: transformWidth, height: transformHeight, ...rest } = options;
const srcSetValue = {
transform: {
...rest,
},
descriptor: `${densityValues[index]}x`,
attributes: {
type: `image/${targetFormat}`,
},
};
// Only set width and height if they are different from the original image, to avoid duplicated final images
if (maxTargetWidth !== imageWidth) {
srcSetValue.transform.width = maxTargetWidth;
srcSetValue.transform.height = Math.round(maxTargetWidth / aspectRatio);
}
if (targetFormat !== options.format) {
srcSetValue.transform.format = targetFormat;
}
srcSet.push(srcSetValue);
});
allWidths.push(
...densityWidths.map((width, index) => ({
maxTargetWidth: Math.min(width, maxWidth),
descriptor: `${densityValues[index]}x` as const,
}))
);
} else if (widths) {
widths.forEach((width) => {
const maxTargetWidth = Math.min(width, maxWidth);
allWidths.push(
...widths.map((width) => ({
maxTargetWidth: Math.min(width, maxWidth),
descriptor: `${width}w` as const,
}))
);
}
const { width: transformWidth, height: transformHeight, ...rest } = options;
// Caution: The logic below is a bit tricky, as we need to make sure we don't generate the same image multiple times
// When making changes, make sure to test with different combinations of local/remote images widths, densities, and dimensions etc.
for (const { maxTargetWidth, descriptor } of allWidths) {
const srcSetTransform: ImageTransform = { ...transformWithoutDimensions };
const srcSetValue = {
transform: {
...rest,
},
descriptor: `${width}w`,
attributes: {
type: `image/${targetFormat}`,
},
};
if (maxTargetWidth !== imageWidth) {
srcSetValue.transform.width = maxTargetWidth;
srcSetValue.transform.height = Math.round(maxTargetWidth / aspectRatio);
// Only set the width if it's different from the original image's width, to avoid generating the same image multiple times
if (maxTargetWidth !== imageWidth) {
srcSetTransform.width = maxTargetWidth;
} else {
// If the width is the same as the original image's width, and we have both dimensions, it probably means
// it's a remote image, so we'll use the user's specified dimensions to avoid recreating the original image unnecessarily
if (options.width && options.height) {
srcSetTransform.width = options.width;
srcSetTransform.height = options.height;
}
}
if (targetFormat !== options.format) {
srcSetValue.transform.format = targetFormat;
}
srcSet.push(srcSetValue);
srcSet.push({
transform: srcSetTransform,
descriptor,
attributes: {
type: `image/${targetFormat}`,
},
});
}
@ -356,6 +362,17 @@ export const baseService: Omit<LocalImageService, 'transform'> = {
},
};
/**
* Returns the final dimensions of an image based on the user's options.
*
* For local images:
* - If the user specified both width and height, we'll use those.
* - If the user specified only one of them, we'll use the original image's aspect ratio to calculate the other.
* - If the user didn't specify either, we'll use the original image's dimensions.
*
* For remote images:
* - Widths and heights are always required, so we'll use the user's specified width and height.
*/
function getTargetDimensions(options: ImageTransform) {
let targetWidth = options.width;
let targetHeight = options.height;

View file

@ -1,3 +1,4 @@
import { deterministicString } from 'deterministic-object-hash';
import { basename, extname } from 'node:path';
import { removeQueryString } from '../../core/path.js';
import { shorthash } from '../../runtime/server/shorthash.js';
@ -19,5 +20,5 @@ export function hashTransform(transform: ImageTransform, imageService: string) {
// Extract the fields we want to hash
const { alt, class: className, style, widths, densities, ...rest } = transform;
const hashFields = { ...rest, imageService };
return shorthash(JSON.stringify(hashFields));
return shorthash(deterministicString(hashFields));
}

View file

@ -225,7 +225,61 @@ describe('astro:image', () => {
const srcset2 = parseSrcset($source.attr('srcset'));
expect(srcset2.every((src) => src.url.startsWith('/_image'))).to.equal(true);
expect(srcset2.map((src) => src.w)).to.deep.equal([undefined, 207]);
expect(srcset2.map((src) => src.w)).to.deep.equal([207]);
});
it('properly deduplicate srcset images', async () => {
let res = await fixture.fetch('/srcset');
let html = await res.text();
$ = cheerio.load(html);
let localImage = $('#local-3-images img');
expect(
new Set([
...parseSrcset(localImage.attr('srcset')).map((src) => src.url),
localImage.attr('src'),
]).size
).to.equal(3);
let remoteImage = $('#remote-3-images img');
expect(
new Set([
...parseSrcset(remoteImage.attr('srcset')).map((src) => src.url),
remoteImage.attr('src'),
]).size
).to.equal(3);
let local1x = $('#local-1x img');
expect(
new Set([
...parseSrcset(local1x.attr('srcset')).map((src) => src.url),
local1x.attr('src'),
]).size
).to.equal(1);
let remote1x = $('#remote-1x img');
expect(
new Set([
...parseSrcset(remote1x.attr('srcset')).map((src) => src.url),
remote1x.attr('src'),
]).size
).to.equal(1);
let local2Widths = $('#local-2-widths img');
expect(
new Set([
...parseSrcset(local2Widths.attr('srcset')).map((src) => src.url),
local2Widths.attr('src'),
]).size
).to.equal(2);
let remote2Widths = $('#remote-2-widths img');
expect(
new Set([
...parseSrcset(remote2Widths.attr('srcset')).map((src) => src.url),
remote2Widths.attr('src'),
]).size
).to.equal(2);
});
});

View file

@ -0,0 +1,59 @@
---
import { Image } from "astro:assets";
import image from "../assets/penguin2.jpg";
---
<div id="local-3-images">
<!--
In this example, only three images should be generated :
- The base image
- The 1.5x image
- The 2x image
-->
<Image src={image} width={image.width / 2} densities={[1.5, 2]} alt="" />
</div>
<div id="remote-3-images">
<!--
In this example, only three images should be generated :
- The base image
- The 1.5x image
- The 2x image
-->
<Image src={"https://avatars.githubusercontent.com/u/622227?s=64"} width={32} height={32} densities={[1.5, 2]} alt="" />
</div>
<div id="local-1x">
<!--
In this example, only one image should be generated :
- The base image, 1x density should be the same as the base image
-->
<Image src={image} width={image.width / 2} densities={[1]} alt="" />
</div>
<div id="remote-1x">
<!--
In this example, only one image should be generated :
- The base image, 1x density should be the same as the base image
-->
<Image src={"https://avatars.githubusercontent.com/u/622227?s=64"} width={32} height={32} densities={[1]} alt="" />
</div>
<div id="local-2-widths">
<!--
In this example, only two images should be generated :
- The base image
- The 2x image
-->
<Image src={image} width={image.width / 2} widths={[image.width]} alt="" />
</div>
<div id="remote-2-widths">
<!--
In this example, only two images should be generated :
- The base image
- The 2x image
-->
<Image src={"https://avatars.githubusercontent.com/u/622227?s=64"} width={32} height={32} widths={[64]} alt="" />
</div>

View file

@ -541,6 +541,9 @@ importers:
debug:
specifier: ^4.3.4
version: 4.3.4(supports-color@8.1.1)
deterministic-object-hash:
specifier: ^1.3.1
version: 1.3.1
devalue:
specifier: ^4.3.2
version: 4.3.2
@ -10372,6 +10375,10 @@ packages:
requiresBuild: true
dev: false
/deterministic-object-hash@1.3.1:
resolution: {integrity: sha512-kQDIieBUreEgY+akq0N7o4FzZCr27dPG1xr3wq267vPwDlSXQ3UMcBXHqTGUBaM/5WDS1jwTYjxRhUzHeuiAvw==}
dev: false
/devalue@4.3.2:
resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==}