diff --git a/.changeset/brown-pumpkins-clean.md b/.changeset/brown-pumpkins-clean.md
new file mode 100644
index 000000000000..f45c791ed0f1
--- /dev/null
+++ b/.changeset/brown-pumpkins-clean.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fix duplicate images being created in some cases when using densities and/or widths
diff --git a/.changeset/serious-news-yell.md b/.changeset/serious-news-yell.md
new file mode 100644
index 000000000000..b110464258cb
--- /dev/null
+++ b/.changeset/serious-news-yell.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+fix remote srcset images not being resized
diff --git a/packages/astro/components/Picture.astro b/packages/astro/components/Picture.astro
index 1be6371d16a1..f6c7686e8790 100644
--- a/packages/astro/components/Picture.astro
+++ b/packages/astro/components/Picture.astro
@@ -57,14 +57,13 @@ if (fallbackImage.srcSet.values.length > 0) {
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 510365a44fac..cb11269355d8 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -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",
diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts
index 455531dfd753..7d2f6afb9098 100644
--- a/packages/astro/src/assets/services/service.ts
+++ b/packages/astro/src/assets/services/service.ts
@@ -213,6 +213,10 @@ export const baseService: Omit = {
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 = {
},
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;
+
+ // 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;
+ }
- // REFACTOR: Could we merge these two blocks?
+ // 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 = {
}
});
+ // 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);
-
- const { width: transformWidth, height: transformHeight, ...rest } = options;
-
- 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);
- }
+ allWidths.push(
+ ...widths.map((width) => ({
+ maxTargetWidth: Math.min(width, maxWidth),
+ descriptor: `${width}w` as const,
+ }))
+ );
+ }
- if (targetFormat !== options.format) {
- srcSetValue.transform.format = targetFormat;
+ // 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 };
+
+ // 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;
}
+ }
- srcSet.push(srcSetValue);
+ srcSet.push({
+ transform: srcSetTransform,
+ descriptor,
+ attributes: {
+ type: `image/${targetFormat}`,
+ },
});
}
@@ -356,6 +362,17 @@ export const baseService: Omit = {
},
};
+/**
+ * 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;
diff --git a/packages/astro/src/assets/utils/transformToPath.ts b/packages/astro/src/assets/utils/transformToPath.ts
index d9e11f3222fe..82c5cc2793e3 100644
--- a/packages/astro/src/assets/utils/transformToPath.ts
+++ b/packages/astro/src/assets/utils/transformToPath.ts
@@ -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));
}
diff --git a/packages/astro/test/core-image.test.js b/packages/astro/test/core-image.test.js
index 8e007edb4d0f..ade9799186b6 100644
--- a/packages/astro/test/core-image.test.js
+++ b/packages/astro/test/core-image.test.js
@@ -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);
});
});
diff --git a/packages/astro/test/fixtures/core-image/src/pages/srcset.astro b/packages/astro/test/fixtures/core-image/src/pages/srcset.astro
new file mode 100644
index 000000000000..221b1b7c4a00
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image/src/pages/srcset.astro
@@ -0,0 +1,59 @@
+---
+import { Image } from "astro:assets";
+import image from "../assets/penguin2.jpg";
+
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a6f0cedd9338..ef5773e5e189 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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==}