diff --git a/packages/astro/src/assets/internal.ts b/packages/astro/src/assets/internal.ts index da3fedbae818..71aa577ad732 100644 --- a/packages/astro/src/assets/internal.ts +++ b/packages/astro/src/assets/internal.ts @@ -83,7 +83,9 @@ export async function getImage( isRemoteImage(resolvedOptions.src) && isRemotePath(resolvedOptions.src) ) { - const result = await inferRemoteSize(resolvedOptions.src); // Directly probe the image URL + const getRemoteSize = (url: string) => + service.getRemoteSize?.(url, imageConfig) ?? inferRemoteSize(url); + const result = await getRemoteSize(resolvedOptions.src); // Directly probe the image URL resolvedOptions.width ??= result.width; resolvedOptions.height ??= result.height; originalWidth = result.width; diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index 0ce8fbd4c338..e1a2ede30eeb 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -5,11 +5,13 @@ import type { AstroConfig } from '../../types/public/config.js'; import { DEFAULT_HASH_PROPS, DEFAULT_OUTPUT_FORMAT, VALID_SUPPORTED_FORMATS } from '../consts.js'; import type { ImageFit, + ImageMetadata, ImageOutputFormat, ImageTransform, UnresolvedSrcSetValue, } from '../types.js'; import { isESMImportedImage, isRemoteImage } from '../utils/imageKind.js'; +import { inferRemoteSize } from '../utils/remoteProbe.js'; export type ImageService = LocalImageService | ExternalImageService; @@ -77,6 +79,13 @@ interface SharedServiceProps = Record options: ImageTransform, imageConfig: ImageConfig, ) => ImageTransform | Promise; + /** + * Infers the dimensions of a remote image by streaming its data and analyzing it progressively until sufficient metadata is available. + */ + getRemoteSize?: ( + url: string, + imageConfig: ImageConfig, + ) => Omit | Promise>; } export type ExternalImageService = Record> = @@ -401,6 +410,9 @@ export const baseService: Omit = { return transform; }, + getRemoteSize(url, _imageConfig) { + return inferRemoteSize(url); + }, }; /** diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts index 82646ffdeeeb..42e3700ae190 100644 --- a/packages/astro/src/assets/services/sharp.ts +++ b/packages/astro/src/assets/services/sharp.ts @@ -54,6 +54,7 @@ const sharpService: LocalImageService = { parseURL: baseService.parseURL, getHTMLAttributes: baseService.getHTMLAttributes, getSrcSet: baseService.getSrcSet, + getRemoteSize: baseService.getRemoteSize, async transform(inputBuffer, transformOptions, config) { if (!sharp) sharp = await loadSharp(); const transform: BaseServiceTransform = transformOptions as BaseServiceTransform; diff --git a/packages/astro/src/assets/vite-plugin-assets.ts b/packages/astro/src/assets/vite-plugin-assets.ts index 468d7ebec0a0..da0711fdf2a1 100644 --- a/packages/astro/src/assets/vite-plugin-assets.ts +++ b/packages/astro/src/assets/vite-plugin-assets.ts @@ -142,15 +142,18 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl if (id === resolvedVirtualModuleId) { return { code: ` - export { getConfiguredImageService, isLocalService } from "astro/assets"; + import { getConfiguredImageService as _getConfiguredImageService } from "astro/assets"; + export { isLocalService } from "astro/assets"; import { getImage as getImageInternal } from "astro/assets"; export { default as Image } from "astro/components/${imageComponentPrefix}Image.astro"; export { default as Picture } from "astro/components/${imageComponentPrefix}Picture.astro"; - export { inferRemoteSize } from "astro/assets/utils/inferRemoteSize.js"; + import { inferRemoteSize as inferRemoteSizeInternal } from "astro/assets/utils/inferRemoteSize.js"; export { default as Font } from "astro/components/Font.astro"; export * from "astro/assets/fonts/runtime"; + export const getConfiguredImageService = _getConfiguredImageService; + export const viteFSConfig = ${JSON.stringify(resolvedConfig.server.fs ?? {})}; export const safeModulePaths = new Set(${JSON.stringify( @@ -171,6 +174,11 @@ export default function assets({ fs, settings, sync, logger }: Options): vite.Pl enumerable: false, configurable: true, }); + export const inferRemoteSize = async (url) => { + const service = await _getConfiguredImageService() + + return service.getRemoteSize?.(url, imageConfig) ?? inferRemoteSizeInternal(url) + } // This is used by the @astrojs/node integration to locate images. // It's unused on other platforms, but on some platforms like Netlify (and presumably also Vercel) // new URL("dist/...") is interpreted by the bundler as a signal to include that directory diff --git a/packages/astro/test/core-image-layout.test.js b/packages/astro/test/core-image-layout.test.js old mode 100644 new mode 100755 index 6bf7e5a61cfa..16c97eefd596 --- a/packages/astro/test/core-image-layout.test.js +++ b/packages/astro/test/core-image-layout.test.js @@ -15,12 +15,18 @@ describe('astro:image:layout', () => { describe('local image service', () => { /** @type {import('./test-utils').DevServer} */ let devServer; + const walrusImagePath = + 'https://images.unsplash.com/photo-1690941380217-24dfa9a1d21f?q=80&w=1476&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'; + const imageScale = 2; before(async () => { fixture = await loadFixture({ root: './fixtures/core-image-layout/', image: { - service: testImageService({ foo: 'bar' }), + service: testImageService({ + foo: 'bar', + transform: { path: walrusImagePath, scale: imageScale }, + }), domains: ['avatars.githubusercontent.com'], }, }); @@ -224,13 +230,14 @@ describe('astro:image:layout', () => { }); describe('remote images', () => { + let $; + before(async () => { + let res = await fixture.fetch('/remote'); + let html = await res.text(); + $ = cheerio.load(html); + }); + describe('srcset', () => { - let $; - before(async () => { - let res = await fixture.fetch('/remote'); - let html = await res.text(); - $ = cheerio.load(html); - }); it('has srcset', () => { let $img = $('#constrained img'); assert.ok($img.attr('srcset')); @@ -263,6 +270,20 @@ describe('astro:image:layout', () => { assert.deepEqual(widths, [640, 750, 828, 1080, 1280, 1668, 2048, 2560]); }); }); + + describe('inferSize', () => { + it('default inferSize works', () => { + let $img = $('#infer-size img'); + assert.equal($img.attr('width'), '2670'); + assert.equal($img.attr('height'), '1780'); + }); + + it('custom inferSize works', () => { + let $img = $('#infer-size-custom img'); + assert.equal($img.attr('width'), (1476 * imageScale).toString()); + assert.equal($img.attr('height'), (978 * imageScale).toString()); + }); + }); }); describe('picture component', () => { diff --git a/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro b/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro index 60aa916c818e..d0dc0376cf4c 100644 --- a/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro +++ b/packages/astro/test/fixtures/core-image-layout/src/pages/remote.astro @@ -2,6 +2,7 @@ import { Image, Picture } from "astro:assets"; const penguin = "https://images.unsplash.com/photo-1670392957807-b0504fc5160a?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" +const walrus = "https://images.unsplash.com/photo-1690941380217-24dfa9a1d21f?q=80&w=1476&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" --- @@ -23,3 +24,10 @@ const penguin = "https://images.unsplash.com/photo-1670392957807-b0504fc5160a?q= a penguin +
+ a penguin +
+ +
+ a walrus +
diff --git a/packages/astro/test/test-image-service.js b/packages/astro/test/test-image-service.js index 290ba5f193bf..36260f7779d4 100644 --- a/packages/astro/test/test-image-service.js +++ b/packages/astro/test/test-image-service.js @@ -3,7 +3,7 @@ import { baseService } from '../dist/assets/services/service.js'; /** * stub image service that returns images as-is without optimization - * @param {{ foo?: string }} [config] + * @param {{ foo?: string, transform?: { path: string, scale: number } }} [config] */ export function testImageService(config = {}) { return { @@ -32,4 +32,14 @@ export default { format: transform.format, }; }, + async getRemoteSize(url, serviceConfig) { + const baseSize = await baseService.getRemoteSize(url, serviceConfig); + + if (serviceConfig.service.config.transform?.path === url) { + const scale = serviceConfig.service.config.transform.scale; + return { ...baseSize, width: baseSize.width * scale, height: baseSize.height * scale }; + } + + return baseSize; + }, };