From 2d0d7b98fd06bbd5cda6711c1c3b854c8a39e3e8 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 10 Sep 2026 00:28:36 +0800 Subject: [PATCH] add: LQIP, optional fallback, sizes prop --- .changeset/olive-pugs-invite.md | 11 +++ README.md | 36 +++++++-- src/__tests__/browser/solid-image.test.tsx | 91 ++++++++++++++++++++++ src/__tests__/components.test.tsx | 78 +++++++++++++++++++ src/__tests__/vite-plugin.test.ts | 91 ++++++++++++++++++++++ src/core/index.tsx | 51 +++++++++--- src/core/styles.css | 5 ++ src/core/types.ts | 13 ++++ src/core/utils.ts | 14 ++++ src/vite/fs.ts | 10 +++ src/vite/index.ts | 58 +++++++++++--- src/vite/transformers.ts | 32 ++++++++ 12 files changed, 460 insertions(+), 30 deletions(-) create mode 100644 .changeset/olive-pugs-invite.md diff --git a/.changeset/olive-pugs-invite.md b/.changeset/olive-pugs-invite.md new file mode 100644 index 0000000..f79122a --- /dev/null +++ b/.changeset/olive-pugs-invite.md @@ -0,0 +1,11 @@ +--- +"@solidjs/image": minor +--- + +Local images now ship an inline preview. The plugin emits a downscaled copy as a data URL plus the dominant color, and the component paints it behind the image until it loads. Turn it off with `placeholder: false`. + +`SolidImage` takes a `sizes` prop, which is forwarded to every `source`. Without it the browser assumes the image spans the full viewport width and downloads a larger variant than it needs. + +The `fallback` prop is now optional. Leave it out and the image is revealed as soon as it loads. + +Processed images are cached. The file name now covers the source file, the format, the width and the quality, and an existing file is reused instead of encoded again. Changing the quality no longer serves a stale image. diff --git a/README.md b/README.md index ed52ebb..7c1a66b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Optimized image components and Vite tooling for [Solid](https://solidjs.com). - `SolidImage` renders a responsive `` that reserves the image's aspect ratio, so the page does not shift while the image loads. - The image only loads once it scrolls into view, using `IntersectionObserver`. +- A tiny preview of the image is inlined in the page and painted behind it, so there is something to look at from the first frame. - Your own placeholder is rendered while the image loads, and fades out when the image is ready. - The Vite plugin turns a local image import into a set of resized and reformatted files at build time. - Remote images go through your own URL mapping, so a CDN can serve the variants instead. @@ -44,6 +45,7 @@ export default defineConfig({ sizes: [480, 800, 1200], quality: 80, publicPath: "public", + placeholder: { size: 20 }, }, }), ], @@ -168,8 +170,9 @@ The component does not depend on the plugin. Pass `src` and an optional `transfo | --- | --- | --- | --- | | `src` | `SolidImageSource` | yes | The image source, its intrinsic size and any options your transformer needs. | | `alt` | `string` | yes | Alternative text for the image. | -| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | yes | Placeholder shown while the image loads. See below. | +| `fallback` | `(visible: () => boolean, onLoad: () => void) => JSX.Element` | no | Placeholder shown while the image loads. See below. | | `transformer` | `SolidImageTransformer` | no | Produces the responsive variants for `src`. | +| `sizes` | `string` | no | Value of the `sizes` attribute, such as `50vw`. | | `onLoad` | `() => void` | no | Called once the image has loaded and the placeholder is hidden. | | `crossOrigin` | `JSX.HTMLCrossorigin` | no | Forwarded to the ``. | | `fetchPriority` | `"high" \| "low" \| "auto"` | no | Forwarded to the ``. | @@ -180,7 +183,15 @@ The `fallback` callback receives two arguments. - `visible` is a signal that is `true` while the placeholder should be shown. It turns `false` once the image has loaded. - `onLoad` tells the component that your placeholder is on screen. Call it once your placeholder has mounted. The image is only revealed after this call, so the placeholder is never skipped by an image that loads instantly. -The `fallback` only renders on the client, and only after the container has scrolled into view. +The `fallback` only renders on the client, and only after the container has scrolled into view. Leave it out and the image is revealed as soon as it loads. + +### Picking the right variant + +Width descriptors alone do not tell the browser how wide the image will be on the page, so it assumes the full viewport width and downloads a larger variant than it needs. Pass `sizes` whenever the image is not full width. + +```tsx + +``` ### Types @@ -192,6 +203,11 @@ interface SolidImageSource { options: T; } +interface SolidImagePlaceholder { + url: string; + color: string; +} + interface SolidImageVariant { path: string; width: number; @@ -230,10 +246,13 @@ Handles imports ending in `?image`. | `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats the plugin will process. Other files are left alone. | | `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. | | `publicPath` | `string` | `"dist"` | Directory the processed files are written 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, so `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image. -Files are written to `/.image/i--.`, where the hash is an xxHash32 of the source path. The module exports the public URL `/.image/i--.`, so `publicPath` should be a directory that is served at the root of your site. Add `.image` to `.gitignore` if it lives inside a checked in directory such as `public`. +Files are written to `/.image/i--.`. The module exports the public URL `/.image/i--.`, so `publicPath` should be a directory that is served at the root of your site. Add `.image` to `.gitignore` if it lives inside a checked in directory such as `public`. + +The hash covers the source path, the size and modification time of the source file, the format, the width and the quality. A file that already exists is left alone, so images are encoded once and reused on later builds and dev server restarts. Editing an image or changing an option produces a new name, so a stale file is never served. #### `options.remote` @@ -243,17 +262,18 @@ Handles imports starting with `image:`. | --- | --- | --- | | `transformURL` | `(url: string) => MaybePromise<{ src, variants }>` | Maps the text after `image:` to a source and its variants. | -`src` is `{ source, width, height }`. `variants` is one `SolidImageVariant` or an array of them. +`src` is `{ source, width, height }`, and may carry a `placeholder` of `{ url, color }`. `variants` is one `SolidImageVariant` or an array of them. Both option groups are optional. Passing neither returns no plugin. ## How it works 1. `SolidImage` renders a container with a padding based aspect ratio box, so the layout is stable before the image arrives. -2. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport. -3. Once visible, the `` and your placeholder are rendered. The image starts fully transparent. -4. Your placeholder calls `onLoad` to say it is on screen. When the image finishes loading after that, the placeholder is hidden, the image fades in and the `onLoad` prop is called. -5. On the server, the `` renders with a blank SVG of the same size, so the browser does not fetch the image before it is in view. The placeholder and the loading logic are client only. +2. The box is painted with the inline preview and the dominant color of the image, when the source carries a placeholder. The preview is a few pixels wide, so the browser scales it up into a blur. +3. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport. +4. Once visible, the `` and your placeholder are rendered. The image starts fully transparent. +5. Your placeholder calls `onLoad` to say it is on screen. When the image finishes loading after that, the placeholder is hidden, the image fades in over the preview, and the `onLoad` prop is called. +6. On the server, the `` renders with a blank SVG of the same size, so the browser does not fetch the image before it is in view. The placeholder and the loading logic are client only. The rendered elements carry a `data-solid-image` attribute you can style. The values are `container`, `aspect-ratio`, `picture`, `image` and `blocker`. The shipped stylesheet uses the same attribute. diff --git a/src/__tests__/browser/solid-image.test.tsx b/src/__tests__/browser/solid-image.test.tsx index 97c09de..a0a4e70 100644 --- a/src/__tests__/browser/solid-image.test.tsx +++ b/src/__tests__/browser/solid-image.test.tsx @@ -170,6 +170,97 @@ describe("SolidImage in the browser", () => { expect(sources[0]!.srcset).toBe(`${PIXEL} 400w,${PIXEL} 800w`); }); + it("drops the inline placeholder once the image has loaded", async () => { + const { host, scrollIntoView } = mount(() => ( + ( + + + + )} + /> + )); + + const box = host.querySelector('[data-solid-image="aspect-ratio"]')!; + + // The preview is painted before anything is fetched. + expect(box.style.backgroundImage).toContain(PIXEL); + expect(box.style.backgroundColor).toBe("rgb(51, 102, 153)"); + + scrollIntoView(); + + await expect.poll(() => findImage(host)?.style.opacity).toBe("1"); + + expect(box.style.backgroundImage).toBe(""); + }); + + it("keeps the inline placeholder while the image is still loading", async () => { + const { host } = mount(() => ( +
Loading...
} + /> + )); + + await new Promise(resolve => setTimeout(resolve, 100)); + + const box = host.querySelector('[data-solid-image="aspect-ratio"]')!; + expect(box.style.backgroundImage).toContain(PIXEL); + }); + + it("reveals the image with no fallback at all", async () => { + const { host, scrollIntoView } = mount(() => ( + + )); + + scrollIntoView(); + + await expect.poll(() => findImage(host)?.style.opacity).toBe("1"); + }); + + it("passes sizes to the browser so it picks a variant", async () => { + const { host, scrollIntoView } = mount(() => ( + [ + { path: PIXEL, width: 400, type: "image/webp" }, + { path: PIXEL, width: 800, type: "image/webp" }, + ], + }} + fallback={(visible, show) => ( + + + + )} + /> + )); + + scrollIntoView(); + + await expect.poll(() => findImage(host)).not.toBe(null); + + expect(host.querySelector("source")!.sizes).toBe("50vw"); + }); + it("reserves the aspect ratio before the image loads", () => { const { host } = mount(() => ( { expect(html).toContain(encodeURIComponent('width="800"')); }); + it("renders without a fallback", () => { + const html = renderToString(() => ( + + )); + + expect(html).toContain('data-solid-image="container"'); + expect(html).toContain('data-solid-image="blocker"'); + }); + + it("puts the sizes attribute on every source", () => { + const html = renderToString(() => ( + [ + { path: "/hero-400.webp", width: 400, type: "image/webp" }, + { path: "/hero-400.jpg", width: 400, type: "image/jpeg" }, + ], + }} + fallback={() =>
loading
} + /> + )); + + expect([...html.matchAll(/sizes="\(max-width: 600px\) 100vw, 50vw"/g)]).toHaveLength(2); + }); + + it("omits the sizes attribute when no value is given", () => { + const html = renderToString(() => ( + [{ path: "/hero-400.webp", width: 400, type: "image/webp" }], + }} + fallback={() =>
loading
} + /> + )); + + expect(html).not.toContain("sizes="); + }); + + it("paints the inline placeholder behind the image", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + expect(html).toContain("background-color:#336699"); + expect(html).toContain("background-image:url("data:image/webp;base64,AAA")"); + expect(html).toContain("background-size:cover"); + }); + + it("renders no placeholder background when the source has none", () => { + const html = renderToString(() => ( +
loading
} + /> + )); + + expect(html).not.toContain("background-image"); + }); + it("renders one per MIME type with a srcset", () => { const html = renderToString(() => ( { expect(code).toContain('import source from "./photo.png"'); }); + it("inlines a placeholder preview and the dominant color", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source")); + + const placeholder = JSON.parse(/placeholder: (\{.+\}),/.exec(code)![1]!); + + expect(placeholder.url.startsWith("data:image/webp;base64,")).toBe(true); + + // sharp picks the dominant color from a quantized histogram, so it lands + // near the fill color rather than exactly on it. + expect(placeholder.color).toMatch(/^#[0-9a-f]{6}$/); + const channels = [1, 3, 5].map(at => parseInt(placeholder.color.slice(at, at + 2), 16)); + for (const [index, expected] of [0x33, 0x66, 0x99].entries()) { + expect(Math.abs(channels[index]! - expected)).toBeLessThan(16); + } + + const preview = Buffer.from(placeholder.url.split(",")[1]!, "base64"); + const meta = await sharp(preview).metadata(); + + expect(meta.format).toBe("webp"); + expect(meta.width).toBe(20); + expect(preview.byteLength).toBeLessThan(1024); + }); + + it("uses the configured placeholder size", async () => { + const plugin = createLocalPlugin({ placeholder: { size: 8 } }); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source")); + + const placeholder = JSON.parse(/placeholder: (\{.+\}),/.exec(code)![1]!); + const meta = await sharp(Buffer.from(placeholder.url.split(",")[1]!, "base64")).metadata(); + + expect(meta.width).toBe(8); + }); + + it("skips the placeholder when it is turned off", async () => { + const plugin = createLocalPlugin({ placeholder: false }); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-source")); + + expect(code).toContain("placeholder: undefined"); + }); + it("loads a transformer that imports one variant per format and size", async () => { const plugin = createLocalPlugin(); const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-transformer")); @@ -264,6 +305,56 @@ describe("local images", () => { expect(first).toBe(second); }); + it("reuses the file it already emitted instead of encoding again", async () => { + const plugin = createLocalPlugin(); + const code: string = await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-800")); + const emitted = path.join(publicPath, /export default "(.+)"/.exec(code)![1]!); + + const before = await fs.stat(emitted); + await fs.writeFile(emitted, "not an image"); + await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-800")); + const after = await fs.readFile(emitted, "utf8"); + + // The plugin left the file alone, so nothing was encoded a second time. + expect(after).toBe("not an image"); + + await fs.rm(emitted); + await callLoad(plugin, path.join(dir, "photo.png?image-raw-webp-800")); + + expect((await fs.stat(emitted)).size).toBe(before.size); + }); + + it("gives a different file name when the quality changes", async () => { + const first: string = await callLoad( + createLocalPlugin({ quality: 80 }), + path.join(dir, "photo.png?image-raw-webp-400"), + ); + const second: string = await callLoad( + createLocalPlugin({ quality: 20 }), + path.join(dir, "photo.png?image-raw-webp-400"), + ); + + expect(first).not.toBe(second); + }); + + it("gives a different file name when the source image changes", async () => { + const editedPath = path.join(dir, "edited.png"); + await sharp({ create: { width: 64, height: 32, channels: 3, background: "#336699" } }) + .png() + .toFile(editedPath); + + const plugin = createLocalPlugin(); + const first: string = await callLoad(plugin, path.join(dir, "edited.png?image-raw-webp-400")); + + await sharp({ create: { width: 64, height: 32, channels: 3, background: "#993366" } }) + .png() + .toFile(editedPath); + + const second: string = await callLoad(plugin, path.join(dir, "edited.png?image-raw-webp-400")); + + expect(first).not.toBe(second); + }); + it("ignores a file extension that is not in the input list", async () => { const plugin = createLocalPlugin({ input: ["jpeg"] }); diff --git a/src/core/index.tsx b/src/core/index.tsx index 6163aac..1fd8085 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -8,7 +8,7 @@ import { mergeImageVariantsToSrcSet, } from "./transformer.ts"; import type { SolidImageSource, SolidImageTransformer, SolidImageVariant } from "./types.ts"; -import { getAspectRatioBoxStyle, getEmptyImageURL } from "./utils.ts"; +import { getAspectRatioBoxStyle, getEmptyImageURL, getPlaceholderStyle } from "./utils.ts"; import "./styles.css"; @@ -29,8 +29,19 @@ export interface SolidImageProps { * `visible` is true while the placeholder should be shown. * Call `onLoad` once the placeholder has mounted. The image is only * revealed after that call, so a fast image never skips the placeholder. + * + * Leave it out to reveal the image as soon as it loads. + */ + fallback?: (visible: () => boolean, onLoad: () => void) => JSX.Element; + + /** + * Value of the `sizes` attribute, such as `50vw` or + * `(max-width: 600px) 100vw, 50vw`. + * + * Without it the browser assumes the image spans the full viewport width and + * downloads a larger variant than it needs. */ - fallback: (visible: () => boolean, onLoad: () => void) => JSX.Element; + sizes?: string | undefined; crossOrigin?: JSX.HTMLCrossorigin | undefined; fetchPriority?: "high" | "low" | "auto" | undefined; @@ -55,7 +66,9 @@ function SolidImageSources(props: SolidImageSourcesProps): JSX.Element { }); return ( - {([type, srcset]) => } + + {([type, srcset]) => } + ); } @@ -67,7 +80,9 @@ function SolidImageSources(props: SolidImageSourcesProps): JSX.Element { export function SolidImage(props: SolidImageProps): JSX.Element { const [showPlaceholder, setShowPlaceholder] = createSignal(true); const laze = createLazyRender(); - const [defer, setDefer] = createSignal(true); + // Without a fallback there is nothing to wait for, so the image + // is revealed as soon as it loads. + const [defer, setDefer] = createSignal(props.fallback != null); function onPlaceholderLoad() { setDefer(false); @@ -76,15 +91,25 @@ export function SolidImage(props: SolidImageProps): JSX.Element { const width = createMemo(() => props.src.width); const height = createMemo(() => props.src.height); + const boxStyle = createMemo(() => { + const style = getAspectRatioBoxStyle({ + width: width(), + height: height(), + }); + + const placeholder = props.src.placeholder; + // Drop the preview once the image is on screen, so a transparent + // image does not show it through. + if (!placeholder || !showPlaceholder()) { + return style; + } + + return { ...style, ...getPlaceholderStyle(placeholder) }; + }); + return (
-
+
{cb => } @@ -130,7 +155,9 @@ export function SolidImage(props: SolidImageProps): JSX.Element {
- {props.fallback(showPlaceholder, onPlaceholderLoad)} + + {cb => cb()(showPlaceholder, onPlaceholderLoad)} +
diff --git a/src/core/styles.css b/src/core/styles.css index a484a6c..5ff9b90 100644 --- a/src/core/styles.css +++ b/src/core/styles.css @@ -17,6 +17,11 @@ user-select: none; } +/* Fades the image in over the inline preview once it has loaded. */ +[data-solid-image="image"] { + transition: opacity 200ms ease-in-out; +} + [data-solid-image="container"] { width: 100%; height: 100%; diff --git a/src/core/types.ts b/src/core/types.ts index c891710..30e8afc 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -35,6 +35,17 @@ export interface SolidImageVariant { type: SolidImageMIME; } +/** + * A tiny version of an image, small enough to inline in the page. + * It is shown while the real image loads. + */ +export interface SolidImagePlaceholder { + /** Data URL of the downscaled image. */ + url: string; + /** Dominant color of the image, as a hex string. */ + color: string; +} + /** * An image source */ @@ -43,6 +54,8 @@ export interface SolidImageSource { width: number; height: number; options: T; + /** Inline preview shown until the image has loaded. */ + placeholder?: SolidImagePlaceholder; } /** diff --git a/src/core/utils.ts b/src/core/utils.ts index 97b6739..16ab909 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,5 +1,6 @@ import type { JSX } from "solid-js"; import type { AspectRatio } from "./aspect-ratio"; +import type { SolidImagePlaceholder } from "./types"; function kebabify(str: string): string { return str @@ -38,6 +39,19 @@ export function getAspectRatioBoxStyle(ratio: AspectRatio): JSX.CSSProperties { }; } +/** + * Style that paints the inline preview behind the image. + * The preview is a few pixels wide, so the browser scales it up and blurs it. + */ +export function getPlaceholderStyle(placeholder: SolidImagePlaceholder): JSX.CSSProperties { + return { + "background-color": placeholder.color, + "background-image": `url("${placeholder.url}")`, + "background-size": "cover", + "background-position": "center", + }; +} + /** Returns an empty SVG of the given size. */ export function getEmptySVGPlaceholder({ width, height }: AspectRatio): string { return ``; diff --git a/src/vite/fs.ts b/src/vite/fs.ts index 46eefae..7c992f0 100644 --- a/src/vite/fs.ts +++ b/src/vite/fs.ts @@ -18,6 +18,16 @@ export async function fileExists(p: string): Promise { } } +/** + * 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. + */ +export async function getFileSignature(filePath: string): Promise { + const stat = await fs.stat(filePath); + return `${stat.size}-${stat.mtimeMs}`; +} + const PATH_FILTER = /[<>:"|?*]/; export function checkPath(pth: string) { diff --git a/src/vite/index.ts b/src/vite/index.ts index 6cafb79..7f53a8a 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -1,15 +1,23 @@ import path from "node:path"; import type { Plugin } from "vite"; import { getFilesFromFormat, getMIMEFromFormat, getOutputFileFromFormat } from "../core/transformer.ts"; -import type { SolidImageFile, SolidImageFormat, SolidImageVariant } from "../core/types.ts"; -import { outputFile } from "./fs.ts"; -import { getImageData, transformImage } from "./transformers.ts"; +import type { + SolidImageFile, + SolidImageFormat, + SolidImagePlaceholder, + SolidImageVariant, +} from "../core/types.ts"; +import { fileExists, getFileSignature, outputFile } from "./fs.ts"; +import { getImageData, getPlaceholderData, transformImage } from "./transformers.ts"; import xxHash32 from "./xxhash32.ts"; const DEFAULT_INPUT: SolidImageFormat[] = ["png", "jpeg", "webp"]; const DEFAULT_OUTPUT: SolidImageFormat[] = ["png", "jpeg", "webp"]; // sharp takes a quality from 1 to 100. const DEFAULT_QUALITY = 80; +// Width of the inline preview, in pixels. Small enough to stay under a +// kilobyte once encoded, large enough to show the shape of the image. +const DEFAULT_PLACEHOLDER_SIZE = 20; type MaybePromise = T | Promise; @@ -26,6 +34,11 @@ export interface SolidImageOptions { quality?: number; /** Directory the processed files are written to. Defaults to `dist`. */ publicPath?: string; + /** + * Inline preview shown until the image has loaded. + * Set to `false` to skip it, or give a width in pixels. Defaults to 20. + */ + placeholder?: boolean | { size?: number }; }; /** Handles imports that start with `image:`. */ remote?: { @@ -35,6 +48,7 @@ export interface SolidImageOptions { source: string; width: number; height: number; + placeholder?: SolidImagePlaceholder; }; variants: SolidImageVariant | SolidImageVariant[]; }>; @@ -55,14 +69,23 @@ function isValidFileExtension(extensions: Set, target: string): target i return extensions.has(target); } -async function getImageSource(imagePath: string, relativePath: string): Promise { +async function getImageSource( + imagePath: string, + relativePath: string, + placeholderSize: number | false, +): Promise { // TODO add format variation - const imageData = await getImageData(imagePath); + const [imageData, placeholder] = await Promise.all([ + getImageData(imagePath), + placeholderSize === false ? undefined : getPlaceholderData(imagePath, placeholderSize), + ]); + return ` import source from ${JSON.stringify(relativePath)}; export default { width: ${JSON.stringify(imageData.width)}, height: ${JSON.stringify(imageData.height)}, + placeholder: ${JSON.stringify(placeholder)}, source, }; `; @@ -154,6 +177,13 @@ export default { const quality = options.local.quality ?? DEFAULT_QUALITY; const sizes = options.local.sizes; const publicPath = options.local.publicPath ?? "dist"; + const placeholder = options.local.placeholder ?? true; + const placeholderSize = + placeholder === false + ? false + : placeholder === true + ? DEFAULT_PLACEHOLDER_SIZE + : (placeholder.size ?? DEFAULT_PLACEHOLDER_SIZE); const validInputFileExtensions = getValidFileExtensions(inputFormat); @@ -183,7 +213,7 @@ export default { const relativePath = `./${name}.${actualExtension}`; // Get the true source if (condition.startsWith("image-source")) { - return await getImageSource(originalPath, relativePath); + return await getImageSource(originalPath, relativePath, placeholderSize); } // Get the transformer file if (condition.startsWith("image-transformer")) { @@ -192,13 +222,21 @@ export default { // Image transformer variant if (condition.startsWith("image-raw")) { const [, , format, size] = condition.split("-"); - const hash = xxHash32(originalPath).toString(16); + // The name covers everything that changes the output, so an edited + // image or a changed option never reuses a stale file. + const signature = await getFileSignature(originalPath); + const hash = xxHash32( + `${originalPath}|${signature}|${format}|${size}|${quality}`, + ).toString(16); const filename = `i-${hash}-${size}.${getOutputFileFromFormat(format as SolidImageFormat)}`; - const image = transformImage(originalPath, format as SolidImageFormat, +size!, quality); - const buffer = await image.toBuffer(); const basePath = path.join(".image", filename); const targetPath = path.join(publicPath, basePath); - await outputFile(targetPath, buffer); + // Encoding is the slow part, so skip it when the file is already there. + if (!(await fileExists(targetPath))) { + const image = transformImage(originalPath, format as SolidImageFormat, +size!, quality); + const buffer = await image.toBuffer(); + await outputFile(targetPath, buffer); + } return `export default "/${basePath}"`; } // Image transformer variant diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts index 1a1d5c3..ce43d56 100644 --- a/src/vite/transformers.ts +++ b/src/vite/transformers.ts @@ -36,6 +36,38 @@ export function transformImage( } } +export interface PlaceholderData { + url: string; + color: string; +} + +function toHex(value: number): string { + return value.toString(16).padStart(2, "0"); +} + +/** + * Builds a preview small enough to inline in the page. + * The image is downscaled to a few pixels and encoded as a data URL, + * together with the dominant color of the original. + */ +export async function getPlaceholderData( + originalPath: string, + size: number, +): Promise { + const input = sharp(originalPath); + const [buffer, stats] = await Promise.all([ + input.clone().resize(size).webp({ quality: 40 }).toBuffer(), + input.clone().stats(), + ]); + + const { r, g, b } = stats.dominant; + + return { + url: `data:image/webp;base64,${buffer.toString("base64")}`, + color: `#${toHex(r)}${toHex(g)}${toHex(b)}`, + }; +} + interface ImageData { width: number; height: number;