From ed0af60998fcfc11b9be1ecd29aee9ceca1e60ed Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 13 Sep 2026 22:40:25 +0800 Subject: [PATCH 1/2] add: opt-in BlurHash placeholder - Set placeholder: { type: "blurhash" } in the plugin to use a BlurHash instead of the inline image preview. - blurhash is an optional peer dependency, loaded with a dynamic import and checked when the build starts. - The generated module imports decode only when the option is on. The component never imports blurhash. - The server paints the average color. The browser decodes the hash into a small canvas. - Remote images can return { hash, color } from transformURL. Co-Authored-By: Claude Opus 5 --- .changeset/calm-owls-blur.md | 7 + README.md | 35 ++++- package.json | 7 + pnpm-lock.yaml | 8 ++ src/__tests__/browser/solid-image.test.tsx | 46 +++++++ src/__tests__/components.test.tsx | 25 +++- src/__tests__/utils.test.ts | 33 +++++ src/__tests__/vite-plugin.test.ts | 141 +++++++++++++++++++++ src/__tests__/vite-transformers.test.ts | 44 ++++++- src/core/index.tsx | 32 ++++- src/core/types.ts | 15 ++- src/core/utils.ts | 55 +++++++- src/vite/index.ts | 135 +++++++++++++++++--- src/vite/transformers.ts | 52 ++++++++ 14 files changed, 600 insertions(+), 35 deletions(-) create mode 100644 .changeset/calm-owls-blur.md diff --git a/.changeset/calm-owls-blur.md b/.changeset/calm-owls-blur.md new file mode 100644 index 0000000..f9e97be --- /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 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 c60245d..23f19f3 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` | `"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. | +| `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. - 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`. @@ -260,6 +267,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. +- `componentX` and `componentY` set how much detail the hash keeps. Each goes from 1 to 9, and the defaults are 4 and 3. +- 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:`. @@ -268,12 +297,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 b19573b..9d1a5b7 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 b796eb3..b00806e 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"; @@ -27,6 +28,12 @@ function callConfigResolved(plugin: Plugin, command: "build" | "serve", cacheDir fn.call({} as any, { command, cacheDir } 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) { @@ -471,3 +478,137 @@ describe("local images", () => { expect(code).toContain("variant_webp_400"); }); }); + +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", + ); + } + + 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); + // 4 characters of header, then 2 for each component. The default is 4 by 3. + expect(placeholder.hash).toHaveLength(4 + 2 * 4 * 3); + // A flat image averages to its own color. + expect(placeholder.color).toBe("#336699"); + expect(placeholder.url).toBeUndefined(); + }); + + it("uses the configured number of components", async () => { + const code: string = await callLoad( + createPlugin({ type: "blurhash", componentX: 2, componentY: 2 }), + path.join(dir, "photo.png?image-source"), + ); + + expect(readPlaceholder(code).hash).toHaveLength(4 + 2 * 2 * 2); + }); + + it("rejects components outside 1 to 9 when the plugin is created", () => { + expect(() => createPlugin({ type: "blurhash", componentX: 10 })).toThrow( + "BlurHash componentX must be a whole number from 1 to 9, got 10.", + ); + expect(() => createPlugin({ type: "blurhash", componentY: 0 })).toThrow( + "BlurHash componentY must be a whole number from 1 to 9, got 0.", + ); + }); + + 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"); + }); +}); diff --git a/src/__tests__/vite-transformers.test.ts b/src/__tests__/vite-transformers.test.ts index adb7ef4..8a5fbbd 100644 --- a/src/__tests__/vite-transformers.test.ts +++ b/src/__tests__/vite-transformers.test.ts @@ -3,7 +3,8 @@ 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 { decode, encode, isBlurhashValid } from "blurhash"; +import { getBlurhashData, getImageData, transformImage } from "../vite/transformers"; let dir: string; let imagePath: string; @@ -61,3 +62,44 @@ describe("transformImage", () => { } }); }); + +describe("getBlurhashData", () => { + it("encodes a valid BlurHash with the requested components", async () => { + const { hash } = await getBlurhashData(imagePath, encode, 3, 2); + + expect(isBlurhashValid(hash).result).toBe(true); + expect(hash).toHaveLength(4 + 2 * 3 * 2); + }); + + it("reports the average color, which is the color the hash encodes", async () => { + const { color } = await getBlurhashData(imagePath, encode, 4, 3); + expect(color).toBe("#112233"); + + // With one component the hash keeps only its base color, so it decodes + // exactly. More components add detail terms that BlurHash rounds, which + // shifts a flat image by a few levels. + const { hash } = await getBlurhashData(imagePath, encode, 1, 1); + const pixels = decode(hash, 4, 4); + for (let i = 0; i < pixels.length; i += 4) { + expect([pixels[i], pixels[i + 1], pixels[i + 2]]).toEqual([0x11, 0x22, 0x33]); + } + }); + + it("encodes a small copy instead of every pixel", async () => { + const seen: [number, number][] = []; + + await getBlurhashData( + imagePath, + (pixels, width, height, componentX, componentY) => { + seen.push([width, height]); + expect(pixels.length).toBe(width * height * 4); + return encode(pixels, width, height, componentX, componentY); + }, + 4, + 3, + ); + + // The 800 by 400 source is reduced to fit inside 32 pixels. + expect(seen).toEqual([[32, 16]]); + }); +}); diff --git a/src/core/index.tsx b/src/core/index.tsx index a803afc..6c65d8c 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 7bf31c0..37d8e29 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"]; @@ -19,9 +25,22 @@ 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; +// A common BlurHash default. It keeps the broad shape of the image in a hash of +// about 30 characters. +const DEFAULT_BLURHASH_COMPONENT_X = 4; +const DEFAULT_BLURHASH_COMPONENT_Y = 3; type MaybePromise = T | Promise; +/** Turns on a BlurHash preview instead of the inline image preview. */ +export interface BlurhashPlaceholderOptions { + type: "blurhash"; + /** Horizontal components, from 1 to 9. More keep more detail. Defaults to 4. */ + componentX?: number; + /** Vertical components, from 1 to 9. More keep more detail. Defaults to 3. */ + componentY?: number; +} + export interface SolidImageOptions { /** Handles imports that end with `?image`. */ local?: { @@ -36,10 +55,14 @@ export interface SolidImageOptions { /** 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. + * 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 +72,7 @@ export interface SolidImageOptions { source: string; width: number; height: number; - placeholder?: SolidImagePlaceholder; + placeholder?: SolidImagePlaceholder | Omit; }; variants: SolidImageVariant | SolidImageVariant[]; }>; @@ -70,6 +93,73 @@ function isValidFileExtension(extensions: Set, target: string): target i return extensions.has(target); } +type ResolvedPlaceholder = + | { type: "none" } + | { type: "image"; size: number } + | { type: "blurhash"; componentX: number; componentY: number }; + +function assertComponents(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1 || value > 9) { + throw new Error(`BlurHash ${name} must be a whole number from 1 to 9, got ${value}.`); + } +} + +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") { + const componentX = option.componentX ?? DEFAULT_BLURHASH_COMPONENT_X; + const componentY = option.componentY ?? DEFAULT_BLURHASH_COMPONENT_Y; + assertComponents("componentX", componentX); + assertComponents("componentY", componentY); + return { type: "blurhash", componentX, componentY }; + } + 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, + placeholder.componentX, + placeholder.componentY, + ); + } + } +} + /** * Builds the module that carries the image, its intrinsic size and its preview. * @@ -81,20 +171,24 @@ async function getImageSource( relativePath: string, fallback: SolidImageFormat, largestSize: 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), ]); + // 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, }; `; @@ -166,9 +260,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; @@ -186,13 +284,7 @@ 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 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]!; @@ -207,6 +299,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) { @@ -241,7 +338,7 @@ export default { relativePath, fallbackFormat, largestSize, - placeholderSize, + placeholder, ); } // Get the transformer file diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts index ce43d56..97142c2 100644 --- a/src/vite/transformers.ts +++ b/src/vite/transformers.ts @@ -68,6 +68,58 @@ 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; + +/** + * Encodes an image as a BlurHash, together with its average color. + * The encoder is passed in because `blurhash` is an optional dependency. + */ +export async function getBlurhashData( + originalPath: string, + encode: BlurhashEncode, + componentX: number, + componentY: number, +): Promise { + const { data, info } = await sharp(originalPath) + .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); + + 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; From 9ee0223a5b0cdd416e5ff4ee207b68c9bd119f5b Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sun, 13 Sep 2026 22:56:39 +0800 Subject: [PATCH 2/2] fix: pick BlurHash components from the aspect ratio - Drop the componentX and componentY options. The whole config is placeholder: { type: "blurhash" }. - Split a budget of about 12 components by aspect ratio, so the long side gets more and detail stays even. - A 4:3 image still gets 4 by 3, the usual default. Co-Authored-By: Claude Opus 5 --- .changeset/calm-owls-blur.md | 2 +- README.md | 2 +- src/__tests__/vite-plugin.test.ts | 39 +++++---- src/__tests__/vite-transformers.test.ts | 103 +++++++++++++++--------- src/vite/index.ts | 34 ++------ src/vite/transformers.ts | 28 ++++++- 6 files changed, 124 insertions(+), 84 deletions(-) diff --git a/.changeset/calm-owls-blur.md b/.changeset/calm-owls-blur.md index f9e97be..79c6e2d 100644 --- a/.changeset/calm-owls-blur.md +++ b/.changeset/calm-owls-blur.md @@ -2,6 +2,6 @@ "@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. +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 f88aa8a..1c122e6 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ imagePlugin({ ``` - `blurhash` is an optional peer dependency. Install it yourself. The plugin fails at startup with install steps when it is missing. -- `componentX` and `componentY` set how much detail the hash keeps. Each goes from 1 to 9, and the defaults are 4 and 3. +- 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. diff --git a/src/__tests__/vite-plugin.test.ts b/src/__tests__/vite-plugin.test.ts index 5f11831..a7d1217 100644 --- a/src/__tests__/vite-plugin.test.ts +++ b/src/__tests__/vite-plugin.test.ts @@ -573,6 +573,15 @@ describe("blurhash placeholder", () => { ); } + // 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]!); } @@ -587,29 +596,27 @@ describe("blurhash placeholder", () => { const placeholder = readPlaceholder(code); expect(isBlurhashValid(placeholder.hash).result).toBe(true); - // 4 characters of header, then 2 for each component. The default is 4 by 3. - expect(placeholder.hash).toHaveLength(4 + 2 * 4 * 3); + // 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("uses the configured number of components", async () => { - const code: string = await callLoad( - createPlugin({ type: "blurhash", componentX: 2, componentY: 2 }), - path.join(dir, "photo.png?image-source"), - ); + 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")); - expect(readPlaceholder(code).hash).toHaveLength(4 + 2 * 2 * 2); - }); + 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")); - it("rejects components outside 1 to 9 when the plugin is created", () => { - expect(() => createPlugin({ type: "blurhash", componentX: 10 })).toThrow( - "BlurHash componentX must be a whole number from 1 to 9, got 10.", - ); - expect(() => createPlugin({ type: "blurhash", componentY: 0 })).toThrow( - "BlurHash componentY must be a whole number from 1 to 9, got 0.", - ); + 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 () => { diff --git a/src/__tests__/vite-transformers.test.ts b/src/__tests__/vite-transformers.test.ts index da7b44e..1e5ec69 100644 --- a/src/__tests__/vite-transformers.test.ts +++ b/src/__tests__/vite-transformers.test.ts @@ -3,8 +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 { decode, encode, isBlurhashValid } from "blurhash"; -import { getBlurhashData, getImageData, transformImage } from "../vite/transformers"; +import { encode, isBlurhashValid } from "blurhash"; +import { + getBlurhashComponents, + getBlurhashData, + getImageData, + transformImage, +} from "../vite/transformers"; let dir: string; let imagePath: string; @@ -101,61 +106,85 @@ 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 the requested components", async () => { - const { hash } = await getBlurhashData(imagePath, encode, 3, 2); + 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); - expect(hash).toHaveLength(4 + 2 * 3 * 2); + // The 800 by 400 source has a ratio of 2. + expect(readComponents(hash)).toEqual([5, 2]); }); - it("reports the average color, which is the color the hash encodes", async () => { - const { color } = await getBlurhashData(imagePath, encode, 4, 3); - expect(color).toBe("#112233"); + it("reports the average color, which is the base color of the hash", async () => { + const { hash, color } = await getBlurhashData(imagePath, encode); - // With one component the hash keeps only its base color, so it decodes - // exactly. More components add detail terms that BlurHash rounds, which - // shifts a flat image by a few levels. - const { hash } = await getBlurhashData(imagePath, encode, 1, 1); - const pixels = decode(hash, 4, 4); - for (let i = 0; i < pixels.length; i += 4) { - expect([pixels[i], pixels[i + 1], pixels[i + 2]]).toEqual([0x11, 0x22, 0x33]); - } + 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][] = []; - await getBlurhashData( - rotatedPath, - (pixels, width, height, componentX, componentY) => { - seen.push([width, height]); - return encode(pixels, width, height, componentX, componentY); - }, - 4, - 3, - ); + 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][] = []; + const seen: [number, number, number, number][] = []; - await getBlurhashData( - imagePath, - (pixels, width, height, componentX, componentY) => { - seen.push([width, height]); - expect(pixels.length).toBe(width * height * 4); - return encode(pixels, width, height, componentX, componentY); - }, - 4, - 3, - ); + 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]]); + expect(seen).toEqual([[32, 16, 5, 2]]); }); }); diff --git a/src/vite/index.ts b/src/vite/index.ts index 93e1be1..acc4979 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -25,20 +25,15 @@ 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; -// A common BlurHash default. It keeps the broad shape of the image in a hash of -// about 30 characters. -const DEFAULT_BLURHASH_COMPONENT_X = 4; -const DEFAULT_BLURHASH_COMPONENT_Y = 3; type MaybePromise = T | Promise; -/** Turns on a BlurHash preview instead of the inline image preview. */ +/** + * 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"; - /** Horizontal components, from 1 to 9. More keep more detail. Defaults to 4. */ - componentX?: number; - /** Vertical components, from 1 to 9. More keep more detail. Defaults to 3. */ - componentY?: number; } export interface SolidImageOptions { @@ -96,13 +91,7 @@ function isValidFileExtension(extensions: Set, target: string): target i type ResolvedPlaceholder = | { type: "none" } | { type: "image"; size: number } - | { type: "blurhash"; componentX: number; componentY: number }; - -function assertComponents(name: string, value: number): void { - if (!Number.isInteger(value) || value < 1 || value > 9) { - throw new Error(`BlurHash ${name} must be a whole number from 1 to 9, got ${value}.`); - } -} + | { type: "blurhash" }; function resolvePlaceholder( option: NonNullable["placeholder"], @@ -114,11 +103,7 @@ function resolvePlaceholder( return { type: "image", size: DEFAULT_PLACEHOLDER_SIZE }; } if (option.type === "blurhash") { - const componentX = option.componentX ?? DEFAULT_BLURHASH_COMPONENT_X; - const componentY = option.componentY ?? DEFAULT_BLURHASH_COMPONENT_Y; - assertComponents("componentX", componentX); - assertComponents("componentY", componentY); - return { type: "blurhash", componentX, componentY }; + return { type: "blurhash" }; } return { type: "image", size: option.size ?? DEFAULT_PLACEHOLDER_SIZE }; } @@ -150,12 +135,7 @@ async function getPlaceholder( return await getPlaceholderData(imagePath, placeholder.size); case "blurhash": { const { encode } = await loadBlurhash(); - return await getBlurhashData( - imagePath, - encode, - placeholder.componentX, - placeholder.componentY, - ); + return await getBlurhashData(imagePath, encode); } } } diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts index 2381939..c2133f0 100644 --- a/src/vite/transformers.ts +++ b/src/vite/transformers.ts @@ -88,15 +88,37 @@ export type BlurhashEncode = ( // 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, - componentX: number, - componentY: number, ): Promise { const { data, info } = await sharp(originalPath) // Hash the photo as it displays, like the variants and the inline preview. @@ -117,6 +139,8 @@ export async function getBlurhashData( 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),