diff --git a/.changeset/calm-owls-blur.md b/.changeset/calm-owls-blur.md new file mode 100644 index 0000000..79c6e2d --- /dev/null +++ b/.changeset/calm-owls-blur.md @@ -0,0 +1,7 @@ +--- +"@solidjs/image": minor +--- + +Add an opt-in BlurHash preview. Set `placeholder: { type: "blurhash" }` in the plugin and install `blurhash`, which is an optional peer dependency. The number of components is picked per image from its aspect ratio, so there is nothing else to configure. + +The server paints the average color of the image, and the browser decodes the hash into a blur. Remote images can return `{ hash, color }` from `transformURL` and get the same preview. diff --git a/README.md b/README.md index 90e6615..1c122e6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Requirements: - `solid-js` 1.9.9 or newer, and Vite 8 or newer. Both are peer dependencies. - Node 24 or newer for the Vite plugin. It uses [`sharp`](https://sharp.pixelplumbing.com) to process images. +- [`blurhash`](https://github.com/woltapp/blurhash) 2 or newer, only for the BlurHash preview. It is an optional peer dependency. ## Setup @@ -207,6 +208,12 @@ interface SolidImagePlaceholder { color: string; } +interface SolidImageBlurhashPlaceholder { + hash: string; + color: string; + decode: (hash: string, width: number, height: number) => Uint8ClampedArray; +} + interface SolidImageVariant { path: string; width: number; @@ -249,7 +256,7 @@ Handles imports ending in `?image`. | `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Source formats to process. Other files are left alone. | | `output` | `SolidImageFormat[]` | `["png", "jpeg", "webp"]` | Formats to emit. | | `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. | +| `placeholder` | `boolean \| { size?: number } \| { type: "blurhash" }` | `true` | Preview shown while the image loads. See [BlurHash preview](#blurhash-preview). | - 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. @@ -264,6 +271,28 @@ Handles imports ending in `?image`. - 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. +#### BlurHash preview + +The default preview is a 20px image inlined as a data URL. A [BlurHash](https://blurha.sh) is a string of about 30 characters that the browser decodes into a blur. Turn it on in the plugin: + +```bash +npm i blurhash +``` + +```ts +imagePlugin({ + local: { + sizes: [480, 800, 1200], + placeholder: { type: "blurhash" }, + }, +}); +``` + +- `blurhash` is an optional peer dependency. Install it yourself. The plugin fails at startup with install steps when it is missing. +- The number of components is picked per image from its aspect ratio, about 12 in total. The long side gets more, so portraits and landscapes keep even detail. +- The server paints the average color of the image. The browser decodes the hash into a 32px wide canvas and paints it over that color. +- Only apps that turn it on import `blurhash`. The component itself never does. + #### `options.remote` Handles imports starting with `image:`. @@ -272,12 +301,12 @@ 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 }`, and may carry a `placeholder` of `{ url, color }`. `variants` is one `SolidImageVariant` or an array of them. +`src` is `{ source, width, height }`, and may carry a `placeholder`. Return `{ url, color }` for an image preview, or `{ hash, color }` for a BlurHash. The plugin adds the decoder for a hash. `variants` is one `SolidImageVariant` or an array of them. ## How it works 1. `SolidImage` renders a padding based aspect ratio box, so the layout is stable before the image arrives. -2. The box is painted with the inline preview and the dominant color, when the source carries a placeholder. The preview is a few pixels wide, so the browser scales it up into a blur. +2. The box is painted with the preview and its color, when the source carries a placeholder. An image preview is a few pixels wide, so the browser scales it up into a blur. A BlurHash is decoded in the browser, and the server paints its average color until then. 3. An `IntersectionObserver` watches the container. Nothing loads until it enters the viewport. 4. Once visible, the `` and your placeholder render. The image starts transparent. 5. Your placeholder calls `onLoad` to say it is on screen. diff --git a/package.json b/package.json index fc53930..d05ce1d 100644 --- a/package.json +++ b/package.json @@ -34,15 +34,22 @@ "sharp": "^0.35.3" }, "peerDependencies": { + "blurhash": "^2.0.5", "solid-js": "^1.9.9", "vite": "^8 || ^9" }, + "peerDependenciesMeta": { + "blurhash": { + "optional": true + } + }, "devDependencies": { "@changesets/cli": "^2.30.0", "@tsdown/css": "^0.22.12", "@types/node": "^25.5.0", "@vitest/browser": "4.1.10", "@vitest/browser-playwright": "4.1.10", + "blurhash": "2.0.5", "playwright": "^1.63.0", "solid-js": "^1.9.9", "tsdown": "^0.22.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcfa8b7..0618b9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@vitest/browser-playwright': specifier: 4.1.10 version: 4.1.10(playwright@1.63.0)(vite@8.1.5(@types/node@25.9.5))(vitest@4.1.10) + blurhash: + specifier: 2.0.5 + version: 2.0.5 playwright: specifier: ^1.63.0 version: 1.63.0 @@ -1014,6 +1017,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + blurhash@2.0.5: + resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2708,6 +2714,8 @@ snapshots: dependencies: is-windows: 1.0.2 + blurhash@2.0.5: {} + braces@3.0.3: dependencies: fill-range: 7.1.1 diff --git a/src/__tests__/browser/solid-image.test.tsx b/src/__tests__/browser/solid-image.test.tsx index d994614..c4a1d03 100644 --- a/src/__tests__/browser/solid-image.test.tsx +++ b/src/__tests__/browser/solid-image.test.tsx @@ -1,5 +1,6 @@ import { onMount, Show } from "solid-js"; import { render } from "solid-js/web"; +import { decode } from "blurhash"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SolidImage } from "../../core/index"; import "../../core/styles.css"; @@ -224,6 +225,51 @@ describe("SolidImage in the browser", () => { expect(box.style.backgroundImage).toContain(PIXEL); }); + it("decodes a BlurHash into the preview and drops it once the image loads", async () => { + const hash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; + const calls: [string, number, number][] = []; + + const { host, scrollIntoView } = mount(() => ( + { + calls.push([value, width, height]); + return decode(value, width, height); + }, + }, + }} + alt="pixel" + fallback={(visible, show) => ( + + + + )} + /> + )); + + const box = host.querySelector('[data-solid-image="aspect-ratio"]')!; + + // The hash is decoded before the image is anywhere near the viewport. + await expect.poll(() => box.style.backgroundImage).toContain("data:image/png"); + expect(box.style.backgroundColor).toBe("rgb(51, 102, 153)"); + // Decoded small, at the aspect ratio of the image. + expect(calls).toEqual([[hash, 32, 18]]); + + scrollIntoView(); + + await expect.poll(() => findImage(host)?.style.opacity).toBe("1"); + + expect(box.style.backgroundImage).toBe(""); + expect(box.style.backgroundColor).toBe(""); + }); + it("reveals the image with no fallback at all", async () => { const { host, scrollIntoView } = mount(() => ( diff --git a/src/__tests__/components.test.tsx b/src/__tests__/components.test.tsx index baf90f5..bd50b49 100644 --- a/src/__tests__/components.test.tsx +++ b/src/__tests__/components.test.tsx @@ -1,6 +1,6 @@ import { createRoot } from "solid-js"; import { renderToString } from "solid-js/web"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ClientOnly, createClientSignal } from "../core/client-only"; import { createLazyRender } from "../core/create-lazy-render"; import { SolidImage } from "../core/index"; @@ -199,6 +199,29 @@ describe("SolidImage SSR", () => { expect(html).toContain("background-size:cover"); }); + it("paints only the average color of a BlurHash on the server", () => { + const decode = vi.fn(() => new Uint8ClampedArray(4)); + + const html = renderToString(() => ( +
loading
} + /> + )); + + expect(html).toContain("background-color:#336699"); + expect(html).not.toContain("background-image"); + // Decoding needs a canvas, so the server never calls it. + expect(decode).not.toHaveBeenCalled(); + }); + it("renders no placeholder background when the source has none", () => { const html = renderToString(() => ( { @@ -85,3 +87,34 @@ describe("getEmptyImageURL", () => { expect(decodeURIComponent(url)).toContain('height="600"'); }); }); + +describe("getPlaceholderStyle", () => { + it("paints the preview image over its color", () => { + expect(getPlaceholderStyle({ color: "#336699", url: "data:image/webp;base64,AAA" })).toEqual({ + "background-color": "#336699", + "background-image": 'url("data:image/webp;base64,AAA")', + "background-size": "cover", + "background-position": "center", + }); + }); + + it("paints only the color while there is no image yet", () => { + expect(getPlaceholderStyle({ color: "#336699" })).toEqual({ + "background-color": "#336699", + }); + }); +}); + +describe("isBlurhashPlaceholder", () => { + it("recognizes a BlurHash preview", () => { + const decode = () => new Uint8ClampedArray(4); + + expect(isBlurhashPlaceholder({ hash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", color: "#fff", decode })).toBe( + true, + ); + }); + + it("recognizes an inline image preview", () => { + expect(isBlurhashPlaceholder({ url: "data:image/webp;base64,AAA", color: "#fff" })).toBe(false); + }); +}); diff --git a/src/__tests__/vite-plugin.test.ts b/src/__tests__/vite-plugin.test.ts index 10555bc..a7d1217 100644 --- a/src/__tests__/vite-plugin.test.ts +++ b/src/__tests__/vite-plugin.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isBlurhashValid } from "blurhash"; import sharp from "sharp"; import type { Plugin } from "vite"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -32,6 +33,12 @@ function callConfigResolved( fn.call({} as any, { command, cacheDir, publicDir } as any); } +function callBuildStart(plugin: Plugin) { + const hook = plugin.buildStart as any; + const fn = typeof hook === "function" ? hook : hook.handler; + return fn.call({} as any, {} as any); +} + function getPlugin(plugins: Plugin[], name: string): Plugin { const found = plugins.find(plugin => plugin.name === name); if (!found) { @@ -540,6 +547,147 @@ describe("local images", () => { }); }); +describe("blurhash placeholder", () => { + // The example hash from the BlurHash project, at 4 by 3 components. + const EXAMPLE_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; + + let dir: string; + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-blurhash-")); + await sharp({ create: { width: 320, height: 180, channels: 3, background: "#336699" } }) + .png() + .toFile(path.join(dir, "photo.png")); + }); + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + function createPlugin(placeholder: NonNullable["placeholder"]) { + return getPlugin( + imagePlugin({ + local: { sizes: [400], output: ["webp"], publicPath: path.join(dir, "public"), placeholder }, + }), + "solid-start:image/local", + ); + } + + // The first character of a hash holds its component counts, in base 83. + function readComponents(hash: string): [number, number] { + const flag = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".indexOf( + hash[0]!, + ); + return [(flag % 9) + 1, Math.floor(flag / 9) + 1]; + } + + function readPlaceholder(code: string) { + return JSON.parse(/placeholder: \{ \.\.\.(\{.+?\}), decode \}/.exec(code)![1]!); + } + + it("ships a BlurHash and imports the decoder", async () => { + const code: string = await callLoad( + createPlugin({ type: "blurhash" }), + path.join(dir, "photo.png?image-source"), + ); + + expect(code).toContain('import { decode } from "blurhash";'); + + const placeholder = readPlaceholder(code); + expect(isBlurhashValid(placeholder.hash).result).toBe(true); + // The 320 by 180 fixture is 16:9, which gets 5 by 3 components. + expect(readComponents(placeholder.hash)).toEqual([5, 3]); + // A flat image averages to its own color. + expect(placeholder.color).toBe("#336699"); + expect(placeholder.url).toBeUndefined(); + }); + + it("picks the components per image from its aspect ratio", async () => { + await sharp({ create: { width: 180, height: 320, channels: 3, background: "#336699" } }) + .png() + .toFile(path.join(dir, "portrait.png")); + await sharp({ create: { width: 400, height: 40, channels: 3, background: "#336699" } }) + .png() + .toFile(path.join(dir, "banner.png")); + + const plugin = createPlugin({ type: "blurhash" }); + const portrait: string = await callLoad(plugin, path.join(dir, "portrait.png?image-source")); + const banner: string = await callLoad(plugin, path.join(dir, "banner.png?image-source")); + + expect(readComponents(readPlaceholder(portrait).hash)).toEqual([3, 5]); + expect(readComponents(readPlaceholder(banner).hash)).toEqual([9, 1]); + }); + + it("does not import blurhash for the default preview", async () => { + const code: string = await callLoad( + createPlugin(undefined), + path.join(dir, "photo.png?image-source"), + ); + + expect(code).not.toContain("blurhash"); + }); + + it("loads blurhash when the build starts", async () => { + await expect(callBuildStart(createPlugin({ type: "blurhash" }))).resolves.toBeUndefined(); + }); + + it("explains how to install blurhash when it is missing", async () => { + vi.doMock("blurhash", () => { + throw new Error("Cannot find package 'blurhash'"); + }); + + try { + await expect(callBuildStart(createPlugin({ type: "blurhash" }))).rejects.toThrow( + 'The BlurHash placeholder needs the "blurhash" package. Install it with `npm i blurhash`.', + ); + } finally { + vi.doUnmock("blurhash"); + } + }); + + it("imports the decoder for a remote BlurHash", async () => { + const plugin = getPlugin( + imagePlugin({ + remote: { + transformURL: () => ({ + src: { + source: "/a.jpg", + width: 4, + height: 3, + placeholder: { hash: EXAMPLE_HASH, color: "#336699" }, + }, + variants: [], + }), + }, + }), + "solid-start:image/remote", + ); + + const code: string = await callLoad(plugin, "image:a"); + + expect(code).toContain('import { decode } from "blurhash";'); + expect(code).toContain("placeholder: { ...SRC.placeholder, decode }"); + expect(code).toContain(EXAMPLE_HASH); + }); + + it("does not import blurhash for a remote image without a hash", async () => { + const plugin = getPlugin( + imagePlugin({ + remote: { + transformURL: () => ({ + src: { source: "/a.jpg", width: 4, height: 3 }, + variants: [], + }), + }, + }), + "solid-start:image/remote", + ); + + expect(await callLoad(plugin, "image:a")).not.toContain("blurhash"); + }); +}); + describe("getEffectiveSizes", () => { it("keeps every size that fits inside the source", () => { expect(getEffectiveSizes([400, 800], 1200)).toEqual([400, 800]); diff --git a/src/__tests__/vite-transformers.test.ts b/src/__tests__/vite-transformers.test.ts index cddfe74..1e5ec69 100644 --- a/src/__tests__/vite-transformers.test.ts +++ b/src/__tests__/vite-transformers.test.ts @@ -3,7 +3,13 @@ import os from "node:os"; import path from "node:path"; import sharp from "sharp"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getImageData, transformImage } from "../vite/transformers"; +import { encode, isBlurhashValid } from "blurhash"; +import { + getBlurhashComponents, + getBlurhashData, + getImageData, + transformImage, +} from "../vite/transformers"; let dir: string; let imagePath: string; @@ -100,6 +106,88 @@ describe("transformImage", () => { }); }); +// BlurHash writes every value in base 83 with this alphabet. +const BASE83 = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"; + +function readBase83(value: string) { + return [...value].reduce((total, character) => total * 83 + BASE83.indexOf(character), 0); +} + +// The first character of a hash holds its component counts. +function readComponents(hash: string): [number, number] { + const flag = readBase83(hash[0]!); + return [(flag % 9) + 1, Math.floor(flag / 9) + 1]; +} + +describe("getBlurhashComponents", () => { + it.each([ + ["4:3 landscape", 4, 3, [4, 3]], + ["16:9", 16, 9, [5, 3]], + ["square", 1, 1, [3, 3]], + ["9:16 portrait", 9, 16, [3, 5]], + ["3:1 panorama", 3, 1, [6, 2]], + ["10:1 banner", 10, 1, [9, 1]], + ["1:10 strip", 1, 10, [1, 9]], + ])("splits the components for a %s image", (_name, width, height, expected) => { + expect(getBlurhashComponents(width as number, height as number)).toEqual(expected); + }); + + it("keeps every count within the 1 to 9 BlurHash allows", () => { + expect(getBlurhashComponents(1000, 1)).toEqual([9, 1]); + expect(getBlurhashComponents(1, 1000)).toEqual([1, 9]); + }); + + it("falls back to 4 by 3 when the size is unknown", () => { + expect(getBlurhashComponents(0, 0)).toEqual([4, 3]); + }); +}); + +describe("getBlurhashData", () => { + it("encodes a valid BlurHash with components picked from the aspect ratio", async () => { + const { hash } = await getBlurhashData(imagePath, encode); + + expect(isBlurhashValid(hash).result).toBe(true); + // The 800 by 400 source has a ratio of 2. + expect(readComponents(hash)).toEqual([5, 2]); + }); + + it("reports the average color, which is the base color of the hash", async () => { + const { hash, color } = await getBlurhashData(imagePath, encode); + + expect(color).toBe("#112233"); + // Characters 2 to 5 hold the base color as a 24 bit number. + expect(readBase83(hash.slice(2, 6))).toBe(0x112233); + }); + + it("encodes a rotated photo upright", async () => { + const seen: [number, number][] = []; + + const { hash } = await getBlurhashData(rotatedPath, (pixels, width, height, x, y) => { + seen.push([width, height]); + return encode(pixels, width, height, x, y); + }); + + // Stored as 64 by 32 with orientation 6, so it displays as 32 by 64. + expect(seen).toEqual([[16, 32]]); + // A portrait gets more vertical components. + expect(readComponents(hash)).toEqual([2, 5]); + }); + + it("encodes a small copy instead of every pixel", async () => { + const seen: [number, number, number, number][] = []; + + await getBlurhashData(imagePath, (pixels, width, height, x, y) => { + seen.push([width, height, x, y]); + expect(pixels.length).toBe(width * height * 4); + return encode(pixels, width, height, x, y); + }); + + // The 800 by 400 source is reduced to fit inside 32 pixels. + expect(seen).toEqual([[32, 16, 5, 2]]); + }); +}); + describe("transformImage output", () => { it("never enlarges an image past its own width", async () => { const buffer = await transformImage(imagePath, "webp", 1600, 80).toBuffer(); diff --git a/src/core/index.tsx b/src/core/index.tsx index 806c8be..fa7c921 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -1,5 +1,5 @@ import type { JSX } from "solid-js"; -import { createMemo, createSignal, For, Show } from "solid-js"; +import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { ClientOnly } from "./client-only.tsx"; import { createLazyRender } from "./create-lazy-render.ts"; import { @@ -8,10 +8,20 @@ import { mergeImageVariantsToSrcSet, } from "./transformer.ts"; import type { SolidImageSource, SolidImageTransformer } from "./types.ts"; -import { getAspectRatioBoxStyle, getEmptyImageURL, getPlaceholderStyle } from "./utils.ts"; +import { + getAspectRatioBoxStyle, + getBlurhashURL, + getEmptyImageURL, + getPlaceholderStyle, + isBlurhashPlaceholder, +} from "./utils.ts"; import "./styles.css"; +// Width a BlurHash is decoded at. The browser scales it up, and a blur needs +// few pixels, so a small canvas decodes fast and looks the same. +const BLURHASH_WIDTH = 32; + export interface SolidImageProps { /** The image, its intrinsic size and any options the transformer needs. */ src: SolidImageSource; @@ -126,6 +136,21 @@ export function SolidImage(props: SolidImageProps): JSX.Element { }), ); + // Decoding a BlurHash needs a canvas. Effects only run in the browser, so the + // server paints the average color and the blur follows once decoded. + const [blurhashURL, setBlurhashURL] = createSignal(); + createEffect(() => { + const placeholder = props.src.placeholder; + if (!placeholder || !isBlurhashPlaceholder(placeholder)) { + setBlurhashURL(undefined); + return; + } + + const ratio = width() > 0 ? height() / width() : 1; + const decodedHeight = Math.max(1, Math.round(BLURHASH_WIDTH * ratio)); + setBlurhashURL(getBlurhashURL(placeholder, BLURHASH_WIDTH, decodedHeight)); + }); + const boxStyle = createMemo(() => { const style = getAspectRatioBoxStyle({ width: width(), @@ -139,7 +164,8 @@ export function SolidImage(props: SolidImageProps): JSX.Element { return style; } - return { ...style, ...getPlaceholderStyle(placeholder) }; + const url = isBlurhashPlaceholder(placeholder) ? blurhashURL() : placeholder.url; + return { ...style, ...getPlaceholderStyle({ color: placeholder.color, url }) }; }); return ( diff --git a/src/core/types.ts b/src/core/types.ts index 30e8afc..21a6c6c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -46,6 +46,19 @@ export interface SolidImagePlaceholder { color: string; } +/** + * A BlurHash preview of an image. + * The hash is a short string that the browser decodes into a blurred image. + */ +export interface SolidImageBlurhashPlaceholder { + /** The encoded BlurHash. */ + hash: string; + /** Average color of the image, as a hex string. It is painted until the hash is decoded. */ + color: string; + /** Decodes the hash into RGBA pixels. This is `decode` from the `blurhash` package. */ + decode: (hash: string, width: number, height: number) => Uint8ClampedArray; +} + /** * An image source */ @@ -55,7 +68,7 @@ export interface SolidImageSource { height: number; options: T; /** Inline preview shown until the image has loaded. */ - placeholder?: SolidImagePlaceholder; + placeholder?: SolidImagePlaceholder | SolidImageBlurhashPlaceholder; } /** diff --git a/src/core/utils.ts b/src/core/utils.ts index 16ab909..b5804b3 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,6 +1,6 @@ import type { JSX } from "solid-js"; import type { AspectRatio } from "./aspect-ratio"; -import type { SolidImagePlaceholder } from "./types"; +import type { SolidImageBlurhashPlaceholder, SolidImagePlaceholder } from "./types"; function kebabify(str: string): string { return str @@ -40,16 +40,57 @@ export function getAspectRatioBoxStyle(ratio: AspectRatio): JSX.CSSProperties { } /** - * Style that paints the inline preview behind the image. + * Style that paints the preview behind the image. * The preview is a few pixels wide, so the browser scales it up and blurs it. + * Without a URL only the color is painted. */ -export function getPlaceholderStyle(placeholder: SolidImagePlaceholder): JSX.CSSProperties { - return { +export function getPlaceholderStyle(placeholder: { + color: string; + url?: string | undefined; +}): JSX.CSSProperties { + const style: JSX.CSSProperties = { "background-color": placeholder.color, - "background-image": `url("${placeholder.url}")`, - "background-size": "cover", - "background-position": "center", }; + + if (placeholder.url) { + style["background-image"] = `url("${placeholder.url}")`; + style["background-size"] = "cover"; + style["background-position"] = "center"; + } + + return style; +} + +/** Tells a BlurHash preview apart from an inline image preview. */ +export function isBlurhashPlaceholder( + placeholder: SolidImagePlaceholder | SolidImageBlurhashPlaceholder, +): placeholder is SolidImageBlurhashPlaceholder { + return "hash" in placeholder; +} + +/** + * Decodes a BlurHash into a PNG data URL of the given size. + * It draws on a canvas, so call it in the browser only. + */ +export function getBlurhashURL( + placeholder: SolidImageBlurhashPlaceholder, + width: number, + height: number, +): string | undefined { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext("2d"); + if (!context) { + return undefined; + } + + const image = context.createImageData(width, height); + image.data.set(placeholder.decode(placeholder.hash, width, height)); + context.putImageData(image, 0, 0); + + return canvas.toDataURL(); } /** Returns an empty SVG of the given size. */ diff --git a/src/vite/index.ts b/src/vite/index.ts index a535af3..acc4979 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -3,13 +3,19 @@ import path from "node:path"; import type { Plugin } from "vite"; import { getFilesFromFormat, getMIMEFromFormat, getOutputFileFromFormat } from "../core/transformer.ts"; import type { + SolidImageBlurhashPlaceholder, SolidImageFile, SolidImageFormat, SolidImagePlaceholder, SolidImageVariant, } from "../core/types.ts"; import { fileExists, getFileSignature, outputFile } from "./fs.ts"; -import { getImageData, getPlaceholderData, transformImage } from "./transformers.ts"; +import { + getBlurhashData, + getImageData, + getPlaceholderData, + transformImage, +} from "./transformers.ts"; import xxHash32 from "./xxhash32.ts"; const DEFAULT_INPUT: SolidImageFormat[] = ["png", "jpeg", "webp"]; @@ -22,6 +28,14 @@ const DEFAULT_PLACEHOLDER_SIZE = 20; type MaybePromise = T | Promise; +/** + * Turns on a BlurHash preview instead of the inline image preview. + * The number of components is picked per image from its aspect ratio. + */ +export interface BlurhashPlaceholderOptions { + type: "blurhash"; +} + export interface SolidImageOptions { /** Handles imports that end with `?image`. */ local?: { @@ -36,10 +50,14 @@ export interface SolidImageOptions { /** Directory the dev server writes processed files to. Defaults to Vite's `publicDir`. */ 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. + * Preview shown until the image has loaded. Defaults to a 20px inline image. + * + * - Set to `false` to skip it. + * - Give `{ size }` to change the width of the inline image. + * - Give `{ type: "blurhash" }` to use a BlurHash instead. It needs the + * `blurhash` package installed. */ - placeholder?: boolean | { size?: number }; + placeholder?: boolean | { type?: "image"; size?: number } | BlurhashPlaceholderOptions; }; /** Handles imports that start with `image:`. */ remote?: { @@ -49,7 +67,7 @@ export interface SolidImageOptions { source: string; width: number; height: number; - placeholder?: SolidImagePlaceholder; + placeholder?: SolidImagePlaceholder | Omit; }; variants: SolidImageVariant | SolidImageVariant[]; }>; @@ -70,6 +88,58 @@ function isValidFileExtension(extensions: Set, target: string): target i return extensions.has(target); } +type ResolvedPlaceholder = + | { type: "none" } + | { type: "image"; size: number } + | { type: "blurhash" }; + +function resolvePlaceholder( + option: NonNullable["placeholder"], +): ResolvedPlaceholder { + if (option === false) { + return { type: "none" }; + } + if (option === undefined || option === true) { + return { type: "image", size: DEFAULT_PLACEHOLDER_SIZE }; + } + if (option.type === "blurhash") { + return { type: "blurhash" }; + } + return { type: "image", size: option.size ?? DEFAULT_PLACEHOLDER_SIZE }; +} + +/** + * Loads the `blurhash` package. + * It is only needed for the BlurHash preview, so it is an optional peer + * dependency that the app installs itself. + */ +async function loadBlurhash(): Promise { + try { + return await import("blurhash"); + } catch (error) { + throw new Error( + 'The BlurHash placeholder needs the "blurhash" package. Install it with `npm i blurhash`.', + { cause: error }, + ); + } +} + +async function getPlaceholder( + imagePath: string, + placeholder: ResolvedPlaceholder, +): Promise | undefined> { + switch (placeholder.type) { + case "none": + return undefined; + case "image": + return await getPlaceholderData(imagePath, placeholder.size); + case "blurhash": { + const { encode } = await loadBlurhash(); + return await getBlurhashData(imagePath, encode); + } + } +} + /** * Returns the widths to emit for a source of the given width. * @@ -101,21 +171,25 @@ async function getImageSource( relativePath: string, fallback: SolidImageFormat, sizes: number[], - placeholderSize: number | false, + placeholder: ResolvedPlaceholder, ): Promise { - const [imageData, placeholder] = await Promise.all([ + const [imageData, preview] = await Promise.all([ getImageData(imagePath), - placeholderSize === false ? undefined : getPlaceholderData(imagePath, placeholderSize), + getPlaceholder(imagePath, placeholder), ]); const largestSize = Math.max(...getEffectiveSizes(sizes, imageData.width)); + // A BlurHash is decoded in the browser. The module brings the decoder along, + // so only apps that turned the BlurHash preview on import the package. + const isBlurhash = placeholder.type === "blurhash"; const variantPath = `${relativePath}?image-raw-${fallback}-${largestSize}`; return ` import source from ${JSON.stringify(variantPath)}; +${isBlurhash ? 'import { decode } from "blurhash";' : ""} export default { width: ${JSON.stringify(imageData.width)}, height: ${JSON.stringify(imageData.height)}, - placeholder: ${JSON.stringify(placeholder)}, + placeholder: ${isBlurhash ? `{ ...${JSON.stringify(preview)}, decode }` : JSON.stringify(preview)}, source, }; `; @@ -187,9 +261,13 @@ export const imagePlugin = (options: SolidImageOptions) => { const result = await transformUrl(param); - return `const VARIANTS = ${JSON.stringify(result.variants)}; + const remotePlaceholder = result.src.placeholder; + const isBlurhash = remotePlaceholder != null && "hash" in remotePlaceholder; + + return `${isBlurhash ? 'import { decode } from "blurhash";\n' : ""}const SRC = ${JSON.stringify(result.src)}; +const VARIANTS = ${JSON.stringify(result.variants)}; export default { - src: ${JSON.stringify(result.src)}, + src: ${isBlurhash ? "{ ...SRC, placeholder: { ...SRC.placeholder, decode } }" : "SRC"}, transformer: { transform() { return VARIANTS; @@ -209,13 +287,7 @@ export default { 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 - ? false - : placeholder === true - ? DEFAULT_PLACEHOLDER_SIZE - : (placeholder.size ?? DEFAULT_PLACEHOLDER_SIZE); + const placeholder = resolvePlaceholder(options.local.placeholder); // 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]!; @@ -229,6 +301,11 @@ export default { plugins.push({ name: "solid-start:image/local", enforce: "pre", + async buildStart() { + if (placeholder.type === "blurhash") { + await loadBlurhash(); + } + }, configResolved(config) { isBuild = config.command === "build"; if (config.cacheDir) { @@ -268,7 +345,7 @@ export default { relativePath, fallbackFormat, sizes, - placeholderSize, + placeholder, ); } // Get the transformer file diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts index 0412321..c2133f0 100644 --- a/src/vite/transformers.ts +++ b/src/vite/transformers.ts @@ -70,6 +70,84 @@ export async function getPlaceholderData( }; } +export interface BlurhashData { + hash: string; + color: string; +} + +/** Signature of `encode` from the `blurhash` package. */ +export type BlurhashEncode = ( + pixels: Uint8ClampedArray, + width: number, + height: number, + componentX: number, + componentY: number, +) => string; + +// Longest side the image is reduced to before it is encoded. A BlurHash keeps +// only a few components, so more pixels cost time and add no detail. +const BLURHASH_SAMPLE_SIZE = 32; + +// Total components a hash aims for. 4 by 3 is the usual BlurHash default, and +// staying near that total keeps hashes near 28 characters. +const BLURHASH_COMPONENT_BUDGET = 12; + +/** + * Picks the horizontal and vertical component counts for an image. + * The budget is split by aspect ratio, so the long side gets more components + * and detail stays even. Each count stays within the 1 to 9 BlurHash allows. + */ +export function getBlurhashComponents(width: number, height: number): [x: number, y: number] { + if (!(width > 0 && height > 0)) { + return [4, 3]; + } + + const ratio = width / height; + const clamp = (value: number) => Math.min(9, Math.max(1, Math.round(value))); + + return [ + clamp(Math.sqrt(BLURHASH_COMPONENT_BUDGET * ratio)), + clamp(Math.sqrt(BLURHASH_COMPONENT_BUDGET / ratio)), + ]; +} + +/** + * Encodes an image as a BlurHash, together with its average color. + * The component counts come from the aspect ratio of the image. + * The encoder is passed in because `blurhash` is an optional dependency. + */ +export async function getBlurhashData( + originalPath: string, + encode: BlurhashEncode, +): Promise { + const { data, info } = await sharp(originalPath) + // Hash the photo as it displays, like the variants and the inline preview. + .autoOrient() + .resize(BLURHASH_SAMPLE_SIZE, BLURHASH_SAMPLE_SIZE, { fit: "inside", withoutEnlargement: true }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + let red = 0; + let green = 0; + let blue = 0; + for (let i = 0; i < data.length; i += 4) { + red += data[i]!; + green += data[i + 1]!; + blue += data[i + 2]!; + } + const count = info.width * info.height; + + const pixels = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength); + // The sample keeps the aspect ratio of the image, so its size is enough. + const [componentX, componentY] = getBlurhashComponents(info.width, info.height); + + return { + hash: encode(pixels, info.width, info.height, componentX, componentY), + color: `#${toHex(Math.round(red / count))}${toHex(Math.round(green / count))}${toHex(Math.round(blue / count))}`, + }; +} + interface ImageData { width: number; height: number;