From 3b27cb377fdfd8e53833e9ad56e3e6ae21e7fb65 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 13 Sep 2026 22:28:41 +0800 Subject: [PATCH] fix: no upscaling, EXIF rotation, content cache key, encoder tuning --- .changeset/quiet-hats-sit.md | 17 +++++ README.md | 10 ++- src/__tests__/components.test.tsx | 36 ++++++++- src/__tests__/vite-plugin.test.ts | 98 +++++++++++++++++++++++-- src/__tests__/vite-transformers.test.ts | 90 +++++++++++++++++++++++ src/core/index.tsx | 9 +++ src/vite/fs.ts | 13 ++-- src/vite/index.ts | 47 +++++++++--- src/vite/transformers.ts | 47 +++++++----- 9 files changed, 321 insertions(+), 46 deletions(-) create mode 100644 .changeset/quiet-hats-sit.md diff --git a/.changeset/quiet-hats-sit.md b/.changeset/quiet-hats-sit.md new file mode 100644 index 0000000..8b9864f --- /dev/null +++ b/.changeset/quiet-hats-sit.md @@ -0,0 +1,17 @@ +--- +"@solidjs/image": minor +--- + +Images are no longer enlarged. Sizes wider than the source are dropped and the source width is used instead, so `srcset` only lists real widths. + +Photos are turned upright using their EXIF orientation, and their reported width and height match how they display. + +Animated images keep every frame when the output is WebP. Other formats keep the first frame instead of stacking every frame into one image. + +JPEG files are smaller at the same quality, and WebP and PNG are compressed harder. The extra encoding time is only paid once, since results are cached. + +The build cache now keys on file content instead of modification time, so it still hits after a fresh checkout in CI. + +`publicPath` now defaults to Vite's public directory, so processed images are reachable on the dev server without setting it. + +Every `img` now carries its intrinsic `width` and `height`, and the `noscript` copy uses `loading="lazy"`. diff --git a/README.md b/README.md index c60245d..90e6615 100644 --- a/README.md +++ b/README.md @@ -248,15 +248,19 @@ Handles imports ending in `?image`. | `quality` | `number` | `80` | Quality passed to sharp, from 1 to 100. | | `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats to process. Other files are left alone. | | `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. | -| `publicPath` | `string` | `"dist"` | Directory the processed files are written to. | +| `publicPath` | `string` | Vite's `publicDir` | Directory the dev server writes processed files to. | | `placeholder` | `boolean \| { size?: number }` | `true` | Inline preview of the image. Set a `size` in pixels, or `false` to skip it. | - One file is emitted per output format and per size. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image. +- Sizes wider than the source are dropped and replaced by the source width. An image is never enlarged. +- Photos are turned upright using their EXIF orientation. +- Animated images keep every frame in WebP. Other formats keep the first frame. +- JPEG uses mozjpeg and WebP uses its highest effort. PNG is lossless, so `quality` does not apply to it. - On build the files go through the bundler as assets, so `base`, `assetsDir` and the build manifest apply to them. Nothing is written to `publicPath`. - On the dev server the files are written to `/.image/i--.` and served from `/.image/...`. -- `publicPath` should be served at the root of your site. Add `.image` to `.gitignore` when it sits inside a checked in directory such as `public`. +- `publicPath` defaults to Vite's public directory, which the dev server serves at the root of the site. Add `.image` to `.gitignore`. - The `` falls back to the largest size of the last output format. The original file is never imported, so it does not reach the bundle. -- The hash covers the source path, the size and modification time of the source file, the format, the width and the quality. +- The hash covers the content of the source file, the format, the width and the quality. It leaves out the path and the modification time, so a fresh checkout in CI still hits the cache. - An image is encoded once and reused. The dev server reuses the file in `publicPath`. A build reuses its copy in the Vite cache directory. - Editing an image or changing an option produces a new name, so a stale file is never served. diff --git a/src/__tests__/components.test.tsx b/src/__tests__/components.test.tsx index b19573b..baf90f5 100644 --- a/src/__tests__/components.test.tsx +++ b/src/__tests__/components.test.tsx @@ -269,7 +269,8 @@ describe("SolidImage SSR", () => { /> )); - expect(html).not.toContain("loading"); + // Match the fallback element itself. The noscript image has a loading attribute. + expect(html).not.toContain("
loading
"); }); it("gives the img a srcset from the least preferred format", () => { @@ -290,7 +291,8 @@ describe("SolidImage SSR", () => { )); // Without this the browser falls back to the full size original. - expect(html).toContain('srcset="/hero-400.jpg 400w,/hero-800.jpg 800w" alt="hero"'); + const img = /]*data:image)[^>]*>/.exec(html)![0]; + expect(img).toContain('srcset="/hero-400.jpg 400w,/hero-800.jpg 800w"'); }); it("gives the img no srcset when there is no transformer", () => { @@ -345,6 +347,36 @@ describe("SolidImage SSR", () => { expect(noscript).toContain('alt="hero"'); }); + it("gives every img its intrinsic width and height", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + // The lazy placeholder and the noscript copy both carry the size. + expect([...html.matchAll(/]*width="1600"[^>]*height="900"/g)]).toHaveLength(2); + }); + + it("lets the browser defer the noscript image", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + const noscript = /]*>(.*?)<\/noscript>/s.exec(html)![1]!; + expect(noscript).toContain('loading="lazy"'); + + // The visible img is still lazy loaded by the observer, not the browser. + const outsideNoscript = html.replace(/]*>.*?<\/noscript>/gs, ""); + expect(outsideNoscript).not.toContain("loading="); + }); + it("marks the container, aspect ratio box, picture and blocker elements", () => { const html = renderToString(() => ( { imagePath = path.join(dir, "photo.png"); await sharp({ - create: { width: 64, height: 32, channels: 3, background: "#336699" }, + create: { width: 1200, height: 600, channels: 3, background: "#336699" }, }) .png() .toFile(imagePath); @@ -211,8 +216,8 @@ describe("local images", () => { const plugin = createLocalPlugin(); const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source")); - expect(code).toContain("width: 64"); - expect(code).toContain("height: 32"); + expect(code).toContain("width: 1200"); + expect(code).toContain("height: 600"); }); it("points the source at the largest variant of the fallback format", async () => { @@ -425,6 +430,69 @@ describe("local images", () => { expect(first).not.toBe(second); }); + it("drops sizes wider than the source and adds the source width", async () => { + const smallPath = path.join(dir, "small.png"); + await sharp({ create: { width: 600, height: 300, channels: 3, background: "#336699" } }) + .png() + .toFile(smallPath); + + const plugin = createLocalPlugin({ output: ["webp"], sizes: [400, 800, 1200] }); + const code: string = await callLoad(plugin, path.join(dir, "small.png?image-transformer")); + + expect(code).toContain('"./small.png?image-webp-400"'); + expect(code).toContain('"./small.png?image-webp-600"'); + expect(code).not.toContain("image-webp-800"); + expect(code).not.toContain("image-webp-1200"); + }); + + it("points the source at the source width when every size is too wide", async () => { + const smallPath = path.join(dir, "tiny.png"); + await sharp({ create: { width: 300, height: 150, channels: 3, background: "#336699" } }) + .png() + .toFile(smallPath); + + const plugin = createLocalPlugin({ output: ["jpeg"], sizes: [800, 1200] }); + const code: string = await callLoad(plugin, path.join(dir, "tiny.png?image-source")); + + expect(code).toContain('import source from "./tiny.png?image-raw-jpeg-300"'); + }); + + it("gives the same file name to the same content at another path and time", async () => { + const copyPath = path.join(dir, "copy.png"); + await fs.copyFile(path.join(dir, "photo.png"), copyPath); + // A fresh checkout gives every file a new modification time. + await fs.utimes(copyPath, new Date(2001, 0, 1), new Date(2001, 0, 1)); + + const plugin = createLocalPlugin(); + const original: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400")); + const copy: string = await callLoad(plugin, path.join(dir, "copy.png?image-raw-webp-400")); + + expect(copy).toBe(original); + }); + + it("writes to Vite's public directory when no publicPath is given", async () => { + const publicDir = path.join(dir, "vite-public"); + const plugin = createLocalPlugin({ publicPath: undefined }); + callConfigResolved(plugin, "serve", path.join(dir, "cache"), publicDir); + + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400")); + const publicUrl = /export default "(.+)"/.exec(code)![1]!; + + expect((await fs.stat(path.join(publicDir, publicUrl))).isFile()).toBe(true); + }); + + it("keeps an explicit publicPath over Vite's public directory", async () => { + const publicDir = path.join(dir, "ignored-public"); + const plugin = createLocalPlugin(); + callConfigResolved(plugin, "serve", path.join(dir, "cache"), publicDir); + + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-400")); + const publicUrl = /export default "(.+)"/.exec(code)![1]!; + + expect((await fs.stat(path.join(publicPath, publicUrl))).isFile()).toBe(true); + await expect(fs.stat(publicDir)).rejects.toThrow(); + }); + it("ignores a file extension that is not in the input list", async () => { const plugin = createLocalPlugin({ input: ["jpeg"] }); @@ -471,3 +539,21 @@ describe("local images", () => { expect(code).toContain("variant_webp_400"); }); }); + +describe("getEffectiveSizes", () => { + it("keeps every size that fits inside the source", () => { + expect(getEffectiveSizes([400, 800], 1200)).toEqual([400, 800]); + }); + + it("replaces sizes wider than the source with the source width", () => { + expect(getEffectiveSizes([400, 800, 1200], 600)).toEqual([400, 600]); + }); + + it("keeps a size equal to the source width once", () => { + expect(getEffectiveSizes([600, 1200], 600)).toEqual([600]); + }); + + it("keeps the sizes as given when the source width is unknown", () => { + expect(getEffectiveSizes([400, 400, 800], 0)).toEqual([400, 800]); + }); +}); diff --git a/src/__tests__/vite-transformers.test.ts b/src/__tests__/vite-transformers.test.ts index adb7ef4..cddfe74 100644 --- a/src/__tests__/vite-transformers.test.ts +++ b/src/__tests__/vite-transformers.test.ts @@ -7,16 +7,50 @@ import { getImageData, transformImage } from "../vite/transformers"; let dir: string; let imagePath: string; +let rotatedPath: string; +let animatedPath: string; +let noisePath: string; + +// Fills an image with pseudo random pixels. A flat color compresses to almost +// nothing in every encoder, so it cannot show a difference in file size. +function createNoise(width: number, height: number) { + const pixels = Buffer.alloc(width * height * 3); + let seed = 42; + for (let i = 0; i < pixels.length; i += 1) { + seed = (seed * 1103515245 + 12345) % 2147483648; + pixels[i] = seed % 256; + } + return sharp(pixels, { raw: { width, height, channels: 3 } }); +} beforeAll(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-sharp-")); imagePath = path.join(dir, "photo.png"); + rotatedPath = path.join(dir, "rotated.jpg"); + animatedPath = path.join(dir, "animated.webp"); + noisePath = path.join(dir, "noise.png"); await sharp({ create: { width: 800, height: 400, channels: 3, background: "#112233" }, }) .png() .toFile(imagePath); + + // Stored landscape, displayed portrait. This is how phones save photos. + await sharp({ create: { width: 64, height: 32, channels: 3, background: "#112233" } }) + .jpeg() + .withMetadata({ orientation: 6 }) + .toFile(rotatedPath); + + const frame = (background: string) => + sharp({ create: { width: 40, height: 20, channels: 3, background } }).png().toBuffer(); + await sharp([await frame("#ff0000"), await frame("#00ff00"), await frame("#0000ff")], { + join: { animated: true }, + }) + .webp() + .toFile(animatedPath); + + await createNoise(256, 256).png().toFile(noisePath); }); afterAll(async () => { @@ -31,6 +65,10 @@ describe("getImageData", () => { it("rejects for a missing file", async () => { await expect(getImageData(path.join(dir, "missing.png"))).rejects.toThrow(); }); + + it("reports the displayed size of a rotated photo", async () => { + expect(await getImageData(rotatedPath)).toEqual({ width: 32, height: 64 }); + }); }); describe("transformImage", () => { @@ -61,3 +99,55 @@ describe("transformImage", () => { } }); }); + +describe("transformImage output", () => { + it("never enlarges an image past its own width", async () => { + const buffer = await transformImage(imagePath, "webp", 1600, 80).toBuffer(); + const meta = await sharp(buffer).metadata(); + + expect(meta.width).toBe(800); + expect(meta.height).toBe(400); + }); + + it("applies the EXIF orientation", async () => { + const buffer = await transformImage(rotatedPath, "webp", 16, 80).toBuffer(); + const meta = await sharp(buffer).metadata(); + + expect(meta.width).toBe(16); + expect(meta.height).toBe(32); + }); + + it("keeps every frame of an animated image in WebP", async () => { + const buffer = await transformImage(animatedPath, "webp", 20, 80).toBuffer(); + const meta = await sharp(buffer, { animated: true }).metadata(); + + expect(meta.pages).toBe(3); + expect(meta.width).toBe(20); + expect(meta.pageHeight).toBe(10); + }); + + it("keeps only the first frame in formats that cannot animate", async () => { + for (const format of ["avif", "jpeg", "png"] as const) { + const buffer = await transformImage(animatedPath, format, 20, 80).toBuffer(); + const meta = await sharp(buffer).metadata(); + + // A stacked strip of frames would be 30 pixels tall. + expect(meta.width).toBe(20); + expect(meta.height).toBe(10); + } + }); + + it("encodes JPEG smaller than the default encoder at the same quality", async () => { + const tuned = await transformImage(noisePath, "jpeg", 256, 80).toBuffer(); + const plain = await sharp(noisePath).jpeg({ quality: 80 }).toBuffer(); + + expect(tuned.length).toBeLessThan(plain.length); + }); + + it("encodes PNG no larger than the default encoder", async () => { + const tuned = await transformImage(noisePath, "png", 256, 80).toBuffer(); + const plain = await sharp(noisePath).png().toBuffer(); + + expect(tuned.length).toBeLessThanOrEqual(plain.length); + }); +}); diff --git a/src/core/index.tsx b/src/core/index.tsx index a803afc..806c8be 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -157,6 +157,8 @@ export function SolidImage(props: SolidImageProps): JSX.Element { src={serverSrc()} srcset={props.eager ? fallbackSrcSet() : undefined} sizes={props.eager ? props.sizes : undefined} + width={width()} + height={height()} alt={props.alt} crossOrigin={props.crossOrigin} fetchpriority={props.fetchPriority} @@ -170,6 +172,8 @@ export function SolidImage(props: SolidImageProps): JSX.Element { src={props.src.source} srcset={fallbackSrcSet()} sizes={props.sizes} + width={width()} + height={height()} alt={props.alt} onLoad={() => { if (!defer()) { @@ -192,11 +196,16 @@ export function SolidImage(props: SolidImageProps): JSX.Element { + {/* Without JavaScript there is no observer, so let the browser + defer offscreen images itself. */} {props.alt} { } /** - * Returns a string that changes whenever the file changes. - * It is part of the name of a processed image, so an edited source - * is written to a new file instead of reusing a stale one. + * Returns a hash of the file content. + * It is part of the name of a processed image, so an edited source is written + * to a new file instead of reusing a stale one. + * It reads the content rather than the modification time, because a fresh + * checkout gives every file a new time and would never match the cache. */ export async function getFileSignature(filePath: string): Promise { - const stat = await fs.stat(filePath); - return `${stat.size}-${stat.mtimeMs}`; + const content = await fs.readFile(filePath); + return crypto.createHash("sha1").update(content).digest("hex"); } const PATH_FILTER = /[<>:"|?*]/; diff --git a/src/vite/index.ts b/src/vite/index.ts index 7bf31c0..a535af3 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -33,7 +33,7 @@ export interface SolidImageOptions { output?: SolidImageFormat[]; /** Quality passed to sharp, from 1 to 100. Defaults to 80. */ quality?: number; - /** Directory the processed files are written to. Defaults to `dist`. */ + /** Directory the dev server writes processed files to. Defaults to Vite's `publicDir`. */ publicPath?: string; /** * Inline preview shown until the image has loaded. @@ -70,6 +70,26 @@ function isValidFileExtension(extensions: Set, target: string): target i return extensions.has(target); } +/** + * Returns the widths to emit for a source of the given width. + * + * Widths above the source are dropped, since they would only upscale it into a + * larger and blurrier file. The source width takes their place, so the largest + * variant still keeps every pixel of the original. + */ +export function getEffectiveSizes(sizes: number[], sourceWidth: number): number[] { + // sharp could not read the width, so there is nothing to compare against. + if (sourceWidth <= 0) { + return [...new Set(sizes)]; + } + + const result = new Set(sizes.filter(size => size <= sourceWidth)); + if (sizes.some(size => size > sourceWidth)) { + result.add(sourceWidth); + } + return [...result]; +} + /** * Builds the module that carries the image, its intrinsic size and its preview. * @@ -80,13 +100,14 @@ async function getImageSource( imagePath: string, relativePath: string, fallback: SolidImageFormat, - largestSize: number, + sizes: number[], placeholderSize: number | false, ): Promise { const [imageData, placeholder] = await Promise.all([ getImageData(imagePath), placeholderSize === false ? undefined : getPlaceholderData(imagePath, placeholderSize), ]); + const largestSize = Math.max(...getEffectiveSizes(sizes, imageData.width)); const variantPath = `${relativePath}?image-raw-${fallback}-${largestSize}`; return ` @@ -185,7 +206,9 @@ export default { const outputFormat = options.local.output ?? DEFAULT_OUTPUT; const quality = options.local.quality ?? DEFAULT_QUALITY; const sizes = options.local.sizes; - const publicPath = options.local.publicPath ?? "dist"; + const publicPathOption = options.local.publicPath; + // Replaced by Vite's public directory once the config is resolved. + let publicPath = publicPathOption ?? "public"; const placeholder = options.local.placeholder ?? true; const placeholderSize = placeholder === false @@ -196,7 +219,6 @@ export default { // The last output format is the least preferred one, so it is the format // every browser is expected to read. const fallbackFormat = outputFormat[outputFormat.length - 1]!; - const largestSize = Math.max(...sizes); const validInputFileExtensions = getValidFileExtensions(inputFormat); @@ -212,6 +234,11 @@ export default { if (config.cacheDir) { cacheDir = path.join(config.cacheDir, "solid-image"); } + // The dev server serves the public directory at the root of the site, + // so processed files have to land there to be reachable. + if (publicPathOption == null && config.publicDir) { + publicPath = config.publicDir; + } }, resolveId(id, importer) { if (LOCAL_PATH.test(id) && importer) { @@ -240,23 +267,23 @@ export default { originalPath, relativePath, fallbackFormat, - largestSize, + sizes, placeholderSize, ); } // Get the transformer file if (condition.startsWith("image-transformer")) { - return getImageTransformer(relativePath, outputFormat, sizes); + const { width } = await getImageData(originalPath); + return getImageTransformer(relativePath, outputFormat, getEffectiveSizes(sizes, width)); } // Image transformer variant if (condition.startsWith("image-raw")) { const [, , format, size] = condition.split("-"); // The name covers everything that changes the output, so an edited - // image or a changed option never reuses a stale file. + // image or a changed option never reuses a stale file. It leaves out + // the file path, which differs between checkouts. const signature = await getFileSignature(originalPath); - const hash = xxHash32( - `${originalPath}|${signature}|${format}|${size}|${quality}`, - ).toString(16); + const hash = xxHash32(`${signature}|${format}|${size}|${quality}`).toString(16); const filename = `i-${hash}-${size}.${getOutputFileFromFormat(format as SolidImageFormat)}`; const encode = () => transformImage(originalPath, format as SolidImageFormat, +size!, quality).toBuffer(); diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts index ce43d56..0412321 100644 --- a/src/vite/transformers.ts +++ b/src/vite/transformers.ts @@ -11,28 +11,30 @@ export function transformImage( size: number, quality: number, ) { - const input = sharp(originalPath); + // Only WebP can store every frame of an animated source. Other formats would + // get all frames stacked into one tall image, so they keep the first frame. + const input = sharp(originalPath, { animated: targetFormat === "webp" }) + // Apply the EXIF orientation, so photos from a phone are not sideways. + .autoOrient() + // Never enlarge. An upscaled file is larger and has no more detail. + .resize({ width: size, withoutEnlargement: true }); + switch (targetFormat) { case "avif": - return input.resize(size).avif({ - quality, - }); + return input.avif({ quality }); case "jpeg": - return input.resize(size).jpeg({ - quality, - }); + // mozjpeg makes files about a tenth smaller at the same quality. + return input.jpeg({ quality, mozjpeg: true }); case "png": - return input.resize(size).png({ - quality, - }); + // PNG is lossless here, so quality does not apply. Spend more time on + // compression instead, since the result is cached. + return input.png({ compressionLevel: 9, adaptiveFiltering: true }); case "webp": - return input.resize(size).webp({ - quality, - }); + // The highest effort gives the smallest file. The result is cached, so + // the extra encoding time is only paid once. + return input.webp({ quality, effort: 6 }); case "tiff": - return input.resize(size).tiff({ - quality, - }); + return input.tiff({ quality }); } } @@ -54,7 +56,7 @@ export async function getPlaceholderData( originalPath: string, size: number, ): Promise { - const input = sharp(originalPath); + const input = sharp(originalPath).autoOrient(); const [buffer, stats] = await Promise.all([ input.clone().resize(size).webp({ quality: 40 }).toBuffer(), input.clone().stats(), @@ -73,11 +75,16 @@ interface ImageData { height: number; } -/** Reads the intrinsic size of an image. Missing values become 0. */ +/** + * Reads the intrinsic size of an image, as it is displayed. + * A photo with a rotated EXIF orientation reports its width and height swapped. + * Missing values become 0. + */ export async function getImageData(originalPath: string): Promise { const result = await sharp(originalPath).metadata(); + const size = result.autoOrient ?? result; return { - width: result.width || 0, - height: result.height || 0, + width: size.width || 0, + height: size.height || 0, }; }