From 908de44cb0788a6298404e40d1925c57c495ce94 Mon Sep 17 00:00:00 2001 From: Song <1667077010@qq.com> Date: Wed, 16 Sep 2026 10:36:57 +0800 Subject: [PATCH] add: opt-in ThumbHash placeholder --- .changeset/tidy-hashes-glow.md | 7 + README.md | 43 +++++- examples/README.md | 2 + examples/thumbhash/index.html | 12 ++ examples/thumbhash/package.json | 21 +++ examples/thumbhash/src/App.tsx | 83 +++++++++++ examples/thumbhash/src/env.d.ts | 1 + examples/thumbhash/src/index.tsx | 6 + examples/thumbhash/src/styles.css | 44 ++++++ examples/thumbhash/tsconfig.json | 15 ++ examples/thumbhash/vite.config.ts | 17 +++ package.json | 5 + pnpm-lock.yaml | 30 ++++ src/__tests__/browser/thumbhash.test.tsx | 87 ++++++++++++ src/__tests__/thumbhash.test.ts | 145 +++++++++++++++++++ src/core/index.tsx | 33 +++-- src/core/types.ts | 20 ++- src/core/utils.ts | 25 +++- src/vite/index.ts | 168 +++++++++++++---------- src/vite/transformers.ts | 53 +++++++ 20 files changed, 727 insertions(+), 90 deletions(-) create mode 100644 .changeset/tidy-hashes-glow.md create mode 100644 examples/thumbhash/index.html create mode 100644 examples/thumbhash/package.json create mode 100644 examples/thumbhash/src/App.tsx create mode 100644 examples/thumbhash/src/env.d.ts create mode 100644 examples/thumbhash/src/index.tsx create mode 100644 examples/thumbhash/src/styles.css create mode 100644 examples/thumbhash/tsconfig.json create mode 100644 examples/thumbhash/vite.config.ts create mode 100644 src/__tests__/browser/thumbhash.test.tsx create mode 100644 src/__tests__/thumbhash.test.ts diff --git a/.changeset/tidy-hashes-glow.md b/.changeset/tidy-hashes-glow.md new file mode 100644 index 0000000..7f92931 --- /dev/null +++ b/.changeset/tidy-hashes-glow.md @@ -0,0 +1,7 @@ +--- +"@solidjs/image": minor +--- + +Add an opt-in ThumbHash preview. Set `placeholder: { type: "thumbhash" }` in the Vite plugin and install `thumbhash`, which is an optional peer dependency. + +ThumbHash previews keep their binary hash as a `Uint8Array`, preserve alpha in the server-side average color, and work for both local and remote images. diff --git a/README.md b/README.md index 3495767..edb2729 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,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. +- [`thumbhash`](https://github.com/evanw/thumbhash) 0.1.1 or newer, only for the ThumbHash preview. It is an optional peer dependency. ## Setup @@ -228,6 +229,7 @@ interface SolidImageSource { width: number; height: number; options: T; + placeholder?: SolidImagePreview; } interface SolidImagePlaceholder { @@ -241,6 +243,12 @@ interface SolidImageBlurhashPlaceholder { decode: (hash: string, width: number, height: number) => Uint8ClampedArray; } +interface SolidImageThumbhashPlaceholder { + hash: Uint8Array; + color: string; + decode: (hash: Uint8Array) => string; +} + interface SolidImageVariant { path: string; width: number; @@ -252,8 +260,8 @@ interface SolidImageTransformer { } ``` -- `SolidImageMIME` is `"image/avif" | "image/jpeg" | "image/png" | "image/webp" | "image/tiff"`. -- `SolidImageFormat` is `"avif" | "jpeg" | "png" | "webp" | "tiff"`. +- `SolidImageMIME` is `"image/avif" | "image/jpeg" | "image/png" | "image/webp" | "image/tiff" | "image/gif"`. +- `SolidImageFormat` is `"avif" | "jpeg" | "png" | "webp" | "tiff" | "gif"`. - `SolidImageFile` is every file extension that maps to a format, such as `"jpg"`, `"jfif"` and `"tif"`. Notes on the shape: @@ -283,7 +291,7 @@ Handles imports ending in `?image`, and single file imports ending in `image-url | `input` | `SolidImageFormat[]` | `["png", "jpeg", "webp", "gif"]` | Source formats to process. Other files are left alone. | | `output` | `SolidImageFormat[]` | `["webp", "jpeg"]` | Formats to emit. They are offered smallest first, whatever the order here. | | `publicPath` | `string` | Vite's `publicDir` | Directory the dev server writes processed files to. | -| `placeholder` | `boolean \| { size?: number } \| { type: "blurhash" }` | `true` | Preview shown while the image loads. See [BlurHash preview](#blurhash-preview). | +| `placeholder` | `boolean \| { size?: number } \| { type: "blurhash" } \| { type: "thumbhash" }` | `true` | Preview shown while the image loads. See the hash preview sections below. | | `concurrency` | `number` | CPU cores | Most images processed at the same time. | - One file is emitted per output format and per size. `output: ["webp", "jpeg"]` with `sizes: [480, 800]` gives four files per image. @@ -328,6 +336,29 @@ imagePlugin({ - 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. +#### ThumbHash preview + +[ThumbHash](https://github.com/evanw/thumbhash) stores a compact binary preview and can preserve transparency. Turn it on in the plugin: + +```bash +npm i thumbhash +``` + +```ts +imagePlugin({ + local: { + sizes: [480, 800, 1200], + placeholder: { type: "thumbhash" }, + }, +}); +``` + +- `thumbhash` is an optional peer dependency. Install it yourself. The plugin fails at startup with install steps when it is missing. +- The plugin auto-orients the source and reduces it to fit inside 100 by 100 pixels before encoding, matching ThumbHash's input limit. +- The generated source keeps the hash as a `Uint8Array`; the disk cache only serializes its bytes as an array and restores the typed array in the generated module. +- The server paints ThumbHash's average RGBA color, including alpha. The browser decodes the hash with `thumbHashToDataURL` and paints the preview over that color. +- Only apps that turn it on import `thumbhash`. The component itself never does. + #### Single file URL Some places take one file instead of a responsive image, such as an `og:image` tag, a CSS background or a canvas. Import the image with `?image-url` to get the URL of one file. @@ -350,12 +381,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`. 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. +`src` is `{ source, width, height }`, and may carry a `placeholder`. Return `{ url, color }` for an image preview, `{ hash: string, color }` for a BlurHash, or `{ hash: Uint8Array, color }` for a ThumbHash. The plugin adds the matching decoder for either hash format. `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 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. +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. Hash previews are decoded in the browser; the server paints their average color until then. 3. An `IntersectionObserver` watches the container. Nothing loads until it comes within `rootMargin` of the viewport. 4. Once near, the `` and your placeholder render. The image starts transparent. 5. Your placeholder calls `onLoad` to say it is on screen. @@ -380,7 +411,7 @@ pnpm test:watch pnpm changeset # add a changeset before opening a pull request ``` -The [examples](./examples) folder has demo apps for the image and BlurHash previews. +The [examples](./examples) folder has demo apps for the image, BlurHash and ThumbHash previews. The suite is split into two Vitest projects. diff --git a/examples/README.md b/examples/README.md index 642fb87..f6cfe49 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,7 @@ Each folder is a small Vite app that uses `@solidjs/image` from this repository. - [`lqip`](./lqip) shows a 20px copy of each image while it loads. - [`blurhash`](./blurhash) shows a BlurHash of each image while it loads. +- [`thumbhash`](./thumbhash) shows a ThumbHash of each image while it loads. ## Run an example @@ -19,6 +20,7 @@ Each folder is a small Vite app that uses `@solidjs/image` from this repository. ```bash pnpm --filter @solidjs/image-example-lqip dev pnpm --filter @solidjs/image-example-blurhash dev + pnpm --filter @solidjs/image-example-thumbhash dev ``` The examples use the built package. Run `pnpm build` again after you change `src`. diff --git a/examples/thumbhash/index.html b/examples/thumbhash/index.html new file mode 100644 index 0000000..c9fd22e --- /dev/null +++ b/examples/thumbhash/index.html @@ -0,0 +1,12 @@ + + + + + + ThumbHash example + + +
+ + + diff --git a/examples/thumbhash/package.json b/examples/thumbhash/package.json new file mode 100644 index 0000000..c5aed95 --- /dev/null +++ b/examples/thumbhash/package.json @@ -0,0 +1,21 @@ +{ + "name": "@solidjs/image-example-thumbhash", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@solidjs/image": "workspace:*", + "solid-js": "^1.9.9", + "thumbhash": "^0.1.1" + }, + "devDependencies": { + "typescript": "^7.0.0", + "vite": "^8.1.5", + "vite-plugin-solid": "^2.11.11" + } +} diff --git a/examples/thumbhash/src/App.tsx b/examples/thumbhash/src/App.tsx new file mode 100644 index 0000000..f22b71a --- /dev/null +++ b/examples/thumbhash/src/App.tsx @@ -0,0 +1,83 @@ +import { SolidImage } from "@solidjs/image"; +import { createSignal, For, onMount, Show } from "solid-js"; + +import fjord from "../../assets/fjord.jpg?image"; +import highlands from "../../assets/highlands.jpg?image"; +import sea from "../../assets/sea.jpg?image"; +import strawberries from "../../assets/strawberries.jpg?image"; +import valley from "../../assets/valley.jpg?image"; + +const PHOTOS = [ + { image: fjord, alt: "People on a cliff above a long fjord" }, + { image: sea, alt: "Evergreen trees above the sea, with mountains on the far shore" }, + { image: strawberries, alt: "Strawberries in green baskets" }, + { image: highlands, alt: "A narrow road below green cliffs in low cloud" }, + { image: valley, alt: "Granite cliffs above a river lined with pine trees" }, +]; + +function Loading(props: { hold: boolean; show: () => void }) { + onMount(() => { + if (!props.hold) { + props.show(); + } + }); + + return {props.hold ? "Preview" : "Loading"}; +} + +function Gallery(props: { hold: boolean }) { + return ( + + {(photo, index) => { + const placeholder = photo.image.src.placeholder; + const hash = + placeholder && "hash" in placeholder && placeholder.hash instanceof Uint8Array + ? Array.from(placeholder.hash) + .map(value => value.toString(16).padStart(2, "0")) + .join("") + : ""; + + return ( +
+ ( + + + + )} + /> +
+ {photo.alt}. The ThumbHash is {hash}. +
+
+ ); + }} +
+ ); +} + +export default function App() { + const [hold, setHold] = createSignal(false); + + return ( +
+

ThumbHash preview

+

+ Each image carries a compact binary ThumbHash. The browser decodes it into a detailed, + color-accurate preview while the real image loads. ThumbHash also preserves alpha. +

+

Local images load fast. Keep the previews on screen to see them.

+ + }> + + +
+ ); +} diff --git a/examples/thumbhash/src/env.d.ts b/examples/thumbhash/src/env.d.ts new file mode 100644 index 0000000..4890756 --- /dev/null +++ b/examples/thumbhash/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/thumbhash/src/index.tsx b/examples/thumbhash/src/index.tsx new file mode 100644 index 0000000..5df0eff --- /dev/null +++ b/examples/thumbhash/src/index.tsx @@ -0,0 +1,6 @@ +import "@solidjs/image/style.css"; +import { render } from "solid-js/web"; +import App from "./App"; +import "./styles.css"; + +render(() => , document.getElementById("app")!); diff --git a/examples/thumbhash/src/styles.css b/examples/thumbhash/src/styles.css new file mode 100644 index 0000000..15401c6 --- /dev/null +++ b/examples/thumbhash/src/styles.css @@ -0,0 +1,44 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; +} + +main { + max-width: 800px; + margin: 0 auto; + padding: 32px 16px; +} + +label { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 32px; +} + +figure { + margin: 0 0 48px; +} + +figcaption { + margin-top: 8px; + font-size: 14px; + opacity: 0.7; + overflow-wrap: anywhere; +} + +.badge { + position: absolute; + top: 12px; + left: 12px; + padding: 2px 10px; + border-radius: 999px; + background: rgb(0 0 0 / 0.6); + color: white; + font-size: 13px; +} diff --git a/examples/thumbhash/tsconfig.json b/examples/thumbhash/tsconfig.json new file mode 100644 index 0000000..dc2a7b1 --- /dev/null +++ b/examples/thumbhash/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/thumbhash/vite.config.ts b/examples/thumbhash/vite.config.ts new file mode 100644 index 0000000..a221e1c --- /dev/null +++ b/examples/thumbhash/vite.config.ts @@ -0,0 +1,17 @@ +import { imagePlugin } from "@solidjs/image/vite"; +import { defineConfig } from "vite"; +import solid from "vite-plugin-solid"; + +export default defineConfig({ + plugins: [ + solid(), + imagePlugin({ + local: { + sizes: [480, 800, 1200, 1600], + // A compact binary hash per image that the browser decodes into a preview. + // It needs the `thumbhash` package installed. + placeholder: { type: "thumbhash" }, + }, + }), + ], +}); diff --git a/package.json b/package.json index 74aef85..df524d8 100644 --- a/package.json +++ b/package.json @@ -36,11 +36,15 @@ "peerDependencies": { "blurhash": "^2.0.5", "solid-js": "^1.9.9", + "thumbhash": "^0.1.1", "vite": "^8 || ^9" }, "peerDependenciesMeta": { "blurhash": { "optional": true + }, + "thumbhash": { + "optional": true } }, "devDependencies": { @@ -52,6 +56,7 @@ "blurhash": "2.0.5", "playwright": "^1.63.0", "solid-js": "^1.9.9", + "thumbhash": "0.1.1", "tsdown": "^0.22.12", "typescript": "^7.0.0", "vite": "^8.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74e35ee..7f95e68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: solid-js: specifier: ^1.9.9 version: 1.9.14 + thumbhash: + specifier: 0.1.1 + version: 0.1.1 tsdown: specifier: ^0.22.12 version: 0.22.13(@tsdown/css@0.22.13)(typescript@7.0.2) @@ -93,6 +96,28 @@ importers: specifier: ^2.11.11 version: 2.11.13(solid-js@1.9.14)(vite@8.1.5(@types/node@25.9.5)) + examples/thumbhash: + dependencies: + '@solidjs/image': + specifier: workspace:* + version: link:../.. + solid-js: + specifier: ^1.9.9 + version: 1.9.14 + thumbhash: + specifier: ^0.1.1 + version: 0.1.1 + devDependencies: + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vite: + specifier: ^8.1.5 + version: 8.1.5(@types/node@25.9.5) + vite-plugin-solid: + specifier: ^2.11.11 + version: 2.11.13(solid-js@1.9.14)(vite@8.1.5(@types/node@25.9.5)) + packages: '@babel/code-frame@7.29.7': @@ -1666,6 +1691,9 @@ packages: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} + thumbhash@0.1.1: + resolution: {integrity: sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3270,6 +3298,8 @@ snapshots: term-size@2.2.1: {} + thumbhash@0.1.1: {} + tinybench@2.9.0: {} tinyexec@1.2.4: {} diff --git a/src/__tests__/browser/thumbhash.test.tsx b/src/__tests__/browser/thumbhash.test.tsx new file mode 100644 index 0000000..1923379 --- /dev/null +++ b/src/__tests__/browser/thumbhash.test.tsx @@ -0,0 +1,87 @@ +import { onMount, Show } from "solid-js"; +import { render } from "solid-js/web"; +import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash"; +import { afterEach, describe, expect, it } from "vitest"; +import { SolidImage } from "../../core/index"; +import "../../core/styles.css"; + +const PIXEL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +const disposers: (() => void)[] = []; + +afterEach(() => { + for (const dispose of disposers.splice(0)) dispose(); + document.body.innerHTML = ""; + window.scrollTo(0, 0); +}); + +function mount(ui: () => ReturnType) { + const page = document.createElement("div"); + const spacer = document.createElement("div"); + spacer.style.height = "200vh"; + const host = document.createElement("div"); + host.style.width = "320px"; + page.append(spacer, host); + document.body.append(page); + disposers.push(render(ui, host)); + return { host, scrollIntoView: () => host.scrollIntoView() }; +} + +function findImage(host: HTMLElement) { + return host.querySelector('img[data-solid-image="image"]'); +} + +function Placeholder(props: { show: () => void }) { + onMount(() => props.show()); + return
Loading...
; +} + +describe("ThumbHash preview in the browser", () => { + it("decodes the binary hash and removes the preview after the image loads", async () => { + const hash = rgbaToThumbHash( + 2, + 1, + new Uint8Array([51, 102, 153, 128, 102, 153, 204, 128]), + ); + const calls: Uint8Array[] = []; + + const { host, scrollIntoView } = mount(() => ( + { + calls.push(value); + return thumbHashToDataURL(value); + }, + }, + }} + alt="pixel" + fallback={(visible, show) => ( + + + + )} + /> + )); + + const box = host.querySelector('[data-solid-image="aspect-ratio"]')!; + + await expect.poll(() => box.style.backgroundImage).toContain("data:image/png"); + expect(box.style.backgroundColor).toBe("rgba(51, 102, 153, 0.5)"); + expect(calls).toHaveLength(1); + expect(calls[0]).toBe(hash); + + scrollIntoView(); + await expect.poll(() => findImage(host)?.style.opacity).toBe("1"); + + expect(box.style.backgroundImage).toBe(""); + expect(box.style.backgroundColor).toBe(""); + }); +}); diff --git a/src/__tests__/thumbhash.test.ts b/src/__tests__/thumbhash.test.ts new file mode 100644 index 0000000..0acdb7f --- /dev/null +++ b/src/__tests__/thumbhash.test.ts @@ -0,0 +1,145 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import sharp from "sharp"; +import { rgbaToThumbHash, thumbHashToAverageRGBA } from "thumbhash"; +import type { Plugin } from "vite"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getThumbhashData } from "../vite/transformers"; +import { imagePlugin } from "../vite/index"; + +let dir: string; +let imagePath: string; +let transparentPath: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "solid-image-thumbhash-")); + imagePath = path.join(dir, "photo.png"); + transparentPath = path.join(dir, "transparent.png"); + + await sharp({ create: { width: 800, height: 400, channels: 3, background: "#112233" } }) + .png() + .toFile(imagePath); + await sharp({ + create: { + width: 80, + height: 40, + channels: 4, + background: { r: 51, g: 102, b: 153, alpha: 0.5 }, + }, + }) + .png() + .toFile(transparentPath); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +function callLoad(plugin: Plugin, id: string) { + const hook = plugin.load as any; + const fn = typeof hook === "function" ? hook : hook.handler; + return fn.call({} as any, id, {}); +} + +function callConfigResolved(plugin: Plugin, cacheDir: string, publicDir: string) { + const hook = plugin.configResolved as any; + const fn = typeof hook === "function" ? hook : hook.handler; + fn.call({} as any, { command: "serve", cacheDir, publicDir } as any); +} + +function getPlugin(plugins: Plugin[], name: string): Plugin { + const found = plugins.find(plugin => plugin.name === name); + if (!found) throw new Error(`Missing plugin: ${name}`); + return found; +} + +function parseRGBA(color: string): [number, number, number, number] { + const match = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(color); + if (!match) throw new Error(`Unexpected color: ${color}`); + return [+match[1]!, +match[2]!, +match[3]!, +match[4]!]; +} + +describe("ThumbHash placeholder", () => { + it("encodes an auto-sized sample no larger than 100px", async () => { + const seen: [number, number][] = []; + const result = await getThumbhashData( + imagePath, + (width, height, pixels) => { + seen.push([width, height]); + return rgbaToThumbHash(width, height, pixels); + }, + thumbHashToAverageRGBA, + ); + + expect(seen).toEqual([[100, 50]]); + expect(result.hash.length).toBeGreaterThan(5); + expect(result.hash.every(value => Number.isInteger(value) && value >= 0 && value <= 255)).toBe(true); + }); + + it("keeps alpha in the average server-side color", async () => { + const result = await getThumbhashData( + transparentPath, + rgbaToThumbHash, + thumbHashToAverageRGBA, + ); + const [red, green, blue, alpha] = parseRGBA(result.color); + + expect(Math.abs(red - 51)).toBeLessThan(16); + expect(Math.abs(green - 102)).toBeLessThan(16); + expect(Math.abs(blue - 153)).toBeLessThan(16); + expect(alpha).toBeGreaterThan(0.4); + expect(alpha).toBeLessThan(0.6); + }); + + it("generates a local module with binary hash restoration and the ThumbHash decoder", async () => { + const publicDir = path.join(dir, "public"); + const plugin = getPlugin( + imagePlugin({ + local: { + sizes: [400], + input: ["png"], + output: ["webp"], + publicPath: publicDir, + placeholder: { type: "thumbhash" }, + }, + }), + "solid-start:image/local", + ); + callConfigResolved(plugin, path.join(dir, "cache"), publicDir); + + const code: string = await callLoad(plugin, `${imagePath}?image-source`); + + expect(code).toContain('import { thumbHashToDataURL } from "thumbhash";'); + expect(code).toContain("hash: new Uint8Array("); + expect(code).toContain("decode: thumbHashToDataURL"); + expect(code).not.toContain('from "blurhash"'); + }); + + it("restores a remote Uint8Array and only imports ThumbHash for that preview", async () => { + const hash = rgbaToThumbHash(1, 1, new Uint8Array([51, 102, 153, 128])); + const plugin = getPlugin( + imagePlugin({ + remote: { + transformURL: () => ({ + src: { + source: "/photo.png", + width: 1, + height: 1, + placeholder: { hash, color: "rgba(51, 102, 153, 0.5)" }, + }, + variants: [], + }), + }, + }), + "solid-start:image/remote", + ); + + const code: string = await callLoad(plugin, "image:photo"); + + expect(code).toContain('import { thumbHashToDataURL } from "thumbhash";'); + expect(code).toContain("hash: new Uint8Array(SRC.placeholder.hash)"); + expect(code).toContain("decode: thumbHashToDataURL"); + expect(code).not.toContain('from "blurhash"'); + }); +}); diff --git a/src/core/index.tsx b/src/core/index.tsx index 27e0392..18f4031 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -14,7 +14,9 @@ import { getBlurhashURL, getEmptyImageURL, getPlaceholderStyle, + getThumbhashURL, isBlurhashPlaceholder, + isThumbhashPlaceholder, } from "./utils.ts"; import "./styles.css"; @@ -216,19 +218,29 @@ 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(); + // Hash previews need browser-only decoding. Effects never run on the server, + // so SSR paints the average color and the decoded preview follows on hydrate. + const [hashURL, setHashURL] = createSignal(); createEffect(() => { const placeholder = props.src.placeholder; - if (!placeholder || !isBlurhashPlaceholder(placeholder)) { - setBlurhashURL(undefined); + if (!placeholder) { + setHashURL(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)); + if (isBlurhashPlaceholder(placeholder)) { + const ratio = width() > 0 ? height() / width() : 1; + const decodedHeight = Math.max(1, Math.round(BLURHASH_WIDTH * ratio)); + setHashURL(getBlurhashURL(placeholder, BLURHASH_WIDTH, decodedHeight)); + return; + } + + if (isThumbhashPlaceholder(placeholder)) { + setHashURL(getThumbhashURL(placeholder)); + return; + } + + setHashURL(undefined); }); const boxStyle = createMemo(() => { @@ -244,7 +256,10 @@ export function SolidImage(props: SolidImageProps): JSX.Element { return style; } - const url = isBlurhashPlaceholder(placeholder) ? blurhashURL() : placeholder.url; + const url = + isBlurhashPlaceholder(placeholder) || isThumbhashPlaceholder(placeholder) + ? hashURL() + : placeholder.url; return { ...style, ...getPlaceholderStyle({ color: placeholder.color, url }) }; }); diff --git a/src/core/types.ts b/src/core/types.ts index 6222ee3..562cde4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -62,6 +62,24 @@ export interface SolidImageBlurhashPlaceholder { decode: (hash: string, width: number, height: number) => Uint8ClampedArray; } +/** + * A ThumbHash preview of an image. + * The binary hash is decoded into a data URL in the browser. + */ +export interface SolidImageThumbhashPlaceholder { + /** The encoded ThumbHash bytes. */ + hash: Uint8Array; + /** Average color of the image as a CSS color. Alpha is preserved. */ + color: string; + /** Decodes the hash into a data URL. This is `thumbHashToDataURL` from the `thumbhash` package. */ + decode: (hash: Uint8Array) => string; +} + +export type SolidImagePreview = + | SolidImagePlaceholder + | SolidImageBlurhashPlaceholder + | SolidImageThumbhashPlaceholder; + /** * An image source */ @@ -71,7 +89,7 @@ export interface SolidImageSource { height: number; options: T; /** Inline preview shown until the image has loaded. */ - placeholder?: SolidImagePlaceholder | SolidImageBlurhashPlaceholder; + placeholder?: SolidImagePreview; } /** diff --git a/src/core/utils.ts b/src/core/utils.ts index b5804b3..d5061f5 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -1,6 +1,11 @@ import type { JSX } from "solid-js"; import type { AspectRatio } from "./aspect-ratio"; -import type { SolidImageBlurhashPlaceholder, SolidImagePlaceholder } from "./types"; +import type { + SolidImageBlurhashPlaceholder, + SolidImagePlaceholder, + SolidImagePreview, + SolidImageThumbhashPlaceholder, +} from "./types"; function kebabify(str: string): string { return str @@ -61,11 +66,18 @@ export function getPlaceholderStyle(placeholder: { return style; } -/** Tells a BlurHash preview apart from an inline image preview. */ +/** Tells a BlurHash preview apart from the other preview formats. */ export function isBlurhashPlaceholder( - placeholder: SolidImagePlaceholder | SolidImageBlurhashPlaceholder, + placeholder: SolidImagePreview, ): placeholder is SolidImageBlurhashPlaceholder { - return "hash" in placeholder; + return "hash" in placeholder && typeof placeholder.hash === "string"; +} + +/** Tells a ThumbHash preview apart from the other preview formats. */ +export function isThumbhashPlaceholder( + placeholder: SolidImagePreview, +): placeholder is SolidImageThumbhashPlaceholder { + return "hash" in placeholder && placeholder.hash instanceof Uint8Array; } /** @@ -93,6 +105,11 @@ export function getBlurhashURL( return canvas.toDataURL(); } +/** Decodes a ThumbHash into the data URL painted behind the image. */ +export function getThumbhashURL(placeholder: SolidImageThumbhashPlaceholder): string { + return placeholder.decode(placeholder.hash); +} + /** Returns an empty SVG of the given size. */ export function getEmptySVGPlaceholder({ width, height }: AspectRatio): string { return ``; diff --git a/src/vite/index.ts b/src/vite/index.ts index a43abad..6cff38d 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -8,6 +8,7 @@ import type { SolidImageFile, SolidImageFormat, SolidImagePlaceholder, + SolidImageThumbhashPlaceholder, SolidImageVariant, } from "../core/types.ts"; import { fileExists, getFileSignature, outputFile, pruneStaleFiles, touchFile } from "./fs.ts"; @@ -16,6 +17,7 @@ import { getBlurhashData, getImageData, getPlaceholderData, + getThumbhashData, type ImageInfo, transformImage, } from "./transformers.ts"; @@ -54,7 +56,20 @@ const STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; type MaybePromise = T | Promise; -type Preview = SolidImagePlaceholder | Omit; +type StoredThumbhashPlaceholder = { + hash: number[]; + color: string; +}; + +type Preview = + | SolidImagePlaceholder + | Omit + | StoredThumbhashPlaceholder; + +type RemotePreview = + | SolidImagePlaceholder + | Omit + | Omit; /** * Turns on a BlurHash preview instead of the inline image preview. @@ -64,6 +79,11 @@ export interface BlurhashPlaceholderOptions { type: "blurhash"; } +/** Turns on a ThumbHash preview instead of the inline image preview. */ +export interface ThumbhashPlaceholderOptions { + type: "thumbhash"; +} + export interface SolidImageOptions { /** Handles imports that end with `?image`. */ local?: { @@ -92,10 +112,14 @@ export interface SolidImageOptions { * * - 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. + * - Give `{ type: "blurhash" }` to use BlurHash. It needs `blurhash` installed. + * - Give `{ type: "thumbhash" }` to use ThumbHash. It needs `thumbhash` installed. */ - placeholder?: boolean | { type?: "image"; size?: number } | BlurhashPlaceholderOptions; + placeholder?: + | boolean + | { type?: "image"; size?: number } + | BlurhashPlaceholderOptions + | ThumbhashPlaceholderOptions; /** Most images processed at the same time. Defaults to the number of CPU cores. */ concurrency?: number; }; @@ -107,7 +131,7 @@ export interface SolidImageOptions { source: string; width: number; height: number; - placeholder?: Preview; + placeholder?: RemotePreview; }; variants: SolidImageVariant | SolidImageVariant[]; }>; @@ -133,7 +157,8 @@ function isValidFileExtension(extensions: Set, target: string): target i type ResolvedPlaceholder = | { type: "none" } | { type: "image"; size: number } - | { type: "blurhash" }; + | { type: "blurhash" } + | { type: "thumbhash" }; function resolvePlaceholder(option: LocalOptions["placeholder"]): ResolvedPlaceholder { if (option === false) { @@ -142,8 +167,8 @@ function resolvePlaceholder(option: LocalOptions["placeholder"]): ResolvedPlaceh if (option === undefined || option === true) { return { type: "image", size: DEFAULT_PLACEHOLDER_SIZE }; } - if (option.type === "blurhash") { - return { type: "blurhash" }; + if (option.type === "blurhash" || option.type === "thumbhash") { + return { type: option.type }; } return { type: "image", size: option.size ?? DEFAULT_PLACEHOLDER_SIZE }; } @@ -177,11 +202,7 @@ export function getVariantFilename( return `i-${hash}-${size}.${getOutputFileFromFormat(format)}`; } -/** - * Loads the `blurhash` package. - * It is only needed for the BlurHash preview, so it is an optional peer - * dependency that the app installs itself. - */ +/** Loads the optional BlurHash package only when its placeholder is enabled. */ async function loadBlurhash(): Promise { try { return await import("blurhash"); @@ -193,6 +214,18 @@ async function loadBlurhash(): Promise { } } +/** Loads the optional ThumbHash package only when its placeholder is enabled. */ +async function loadThumbhash(): Promise { + try { + return await import("thumbhash"); + } catch (error) { + throw new Error( + 'The ThumbHash placeholder needs the "thumbhash" package. Install it with `npm i thumbhash`.', + { cause: error }, + ); + } +} + async function computePlaceholder( imagePath: string, placeholder: Exclude, @@ -200,8 +233,12 @@ async function computePlaceholder( if (placeholder.type === "image") { return await getPlaceholderData(imagePath, placeholder.size); } - const { encode } = await loadBlurhash(); - return await getBlurhashData(imagePath, encode); + if (placeholder.type === "blurhash") { + const { encode } = await loadBlurhash(); + return await getBlurhashData(imagePath, encode); + } + const { rgbaToThumbHash, thumbHashToAverageRGBA } = await loadThumbhash(); + return await getThumbhashData(imagePath, rgbaToThumbHash, thumbHashToAverageRGBA); } /** @@ -267,35 +304,37 @@ function remember(cache: Map>, file: string, read: () => P return value; } -/** - * Builds the module that carries the image, its intrinsic size and its preview. - * - * `source` points at the largest variant of the fallback format rather than the - * original file, so the untouched original never reaches the bundle. - */ +/** Builds the module that carries the image, its intrinsic size and its preview. */ function getImageSource( relativePath: string, info: ImageInfo, preview: Preview | undefined, outputFormat: SolidImageFormat[], sizes: number[], - isBlurhash: boolean, + placeholderType: ResolvedPlaceholder["type"], ): string { const largestSize = Math.max(...getEffectiveSizes(sizes, info.width)); - // The last format is the one every browser reads, so the `img` falls back to it. const formats = getEffectiveFormats(outputFormat, info.transparent); const fallback = formats[formats.length - 1]!; const variantPath = `${relativePath}?image-raw-${fallback}-${largestSize}`; - // 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. + let decoderImport = ""; + let placeholderCode = JSON.stringify(preview); + if (placeholderType === "blurhash") { + decoderImport = 'import { decode } from "blurhash";'; + placeholderCode = `{ ...${placeholderCode}, decode }`; + } else if (placeholderType === "thumbhash") { + decoderImport = 'import { thumbHashToDataURL } from "thumbhash";'; + placeholderCode = `{ ...${placeholderCode}, hash: new Uint8Array(${JSON.stringify((preview as StoredThumbhashPlaceholder | undefined)?.hash ?? [])}), decode: thumbHashToDataURL }`; + } + return ` import source from ${JSON.stringify(variantPath)}; -${isBlurhash ? 'import { decode } from "blurhash";' : ""} +${decoderImport} export default { width: ${JSON.stringify(info.width)}, height: ${JSON.stringify(info.height)}, - placeholder: ${isBlurhash ? `{ ...${JSON.stringify(preview)}, decode }` : JSON.stringify(preview)}, + placeholder: ${placeholderCode}, source, }; `; @@ -379,7 +418,6 @@ export default { src, transformer }; `; } -// Query flag of an import that asks for the URL of one file. const URL_QUERY = "image-url"; const LOCAL_PATH = /\?image(-[a-z]+(-[0-9]+)?)?|&image-url(&|$)/; const REMOTE_PATH = "image:"; @@ -405,16 +443,36 @@ export const imagePlugin = (options: SolidImageOptions) => { async load(id) { if (id.startsWith(REMOTE_PATH)) { const param = id.substring(REMOTE_PATH.length); - const result = await transformUrl(param); - 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 hasHash = remotePlaceholder != null && "hash" in remotePlaceholder; + const isBlurhash = hasHash && typeof remotePlaceholder.hash === "string"; + const isThumbhash = hasHash && remotePlaceholder.hash instanceof Uint8Array; + const serializableSource = isThumbhash + ? { + ...result.src, + placeholder: { + ...remotePlaceholder, + hash: Array.from(remotePlaceholder.hash), + }, + } + : result.src; + + const decoderImport = isBlurhash + ? 'import { decode } from "blurhash";\n' + : isThumbhash + ? 'import { thumbHashToDataURL } from "thumbhash";\n' + : ""; + const sourceCode = isBlurhash + ? "{ ...SRC, placeholder: { ...SRC.placeholder, decode } }" + : isThumbhash + ? "{ ...SRC, placeholder: { ...SRC.placeholder, hash: new Uint8Array(SRC.placeholder.hash), decode: thumbHashToDataURL } }" + : "SRC"; + + return `${decoderImport}const SRC = ${JSON.stringify(serializableSource)}; const VARIANTS = ${JSON.stringify(result.variants)}; export default { - src: ${isBlurhash ? "{ ...SRC, placeholder: { ...SRC.placeholder, decode } }" : "SRC"}, + src: ${sourceCode}, transformer: { transform() { return VARIANTS; @@ -432,7 +490,6 @@ export default { const getQuality = resolveQuality(options.local.quality); const sizes = options.local.sizes; const publicPathOption = options.local.publicPath; - // Replaced by Vite's public directory once the config is resolved. let publicPath = publicPathOption ?? "public"; const placeholder = resolvePlaceholder(options.local.placeholder); const limit = createLimit( @@ -442,27 +499,20 @@ export default { const validInputFileExtensions = getValidFileExtensions(inputFormat); let isBuild = false; - // Replaced by the Vite cache directory once the config is resolved. let cacheDir = path.join("node_modules", ".vite", "solid-image"); - // Every variant of an image needs its content hash, and several modules need - // its metadata. Each is read once per file and shared. A file that changes - // is forgotten, so the dev server reads it again. const signatures = new Map>(); const infos = new Map>(); - const readSignature = (file: string) => - remember(signatures, file, () => getFileSignature(file)); + const readSignature = (file: string) => remember(signatures, file, () => getFileSignature(file)); const readInfo = (file: string) => remember(infos, file, () => limit(() => getImageData(file))); - // Previews are cached on disk like the variants, keyed by content, so a - // build or a dev server restart does not compute them again. async function readPreview(file: string): Promise { if (placeholder.type === "none") { return undefined; } const signature = await readSignature(file); - const kind = placeholder.type === "image" ? `image-${placeholder.size}` : "blurhash"; + const kind = placeholder.type === "image" ? `image-${placeholder.size}` : placeholder.type; const hash = xxHash32(`v${PIPELINE_VERSION}|${signature}|${kind}`).toString(16); const cachePath = path.join(cacheDir, "previews", `p-${hash}.json`); @@ -487,8 +537,9 @@ export default { async buildStart() { if (placeholder.type === "blurhash") { await loadBlurhash(); + } else if (placeholder.type === "thumbhash") { + await loadThumbhash(); } - // Old files are removed at startup, before anything reads them. await Promise.all([ pruneStaleFiles(cacheDir, STALE_AFTER_MS), pruneStaleFiles(path.join(cacheDir, "previews"), STALE_AFTER_MS), @@ -500,8 +551,6 @@ export default { if (config.cacheDir) { cacheDir = path.join(config.cacheDir, "solid-image"); } - // The dev server serves the public directory at the root of the site, - // so processed files have to land there to be reachable. if (publicPathOption == null && config.publicDir) { publicPath = config.publicDir; } @@ -523,7 +572,6 @@ export default { } const { dir, name, ext } = path.parse(id); const [actualExtension, condition] = ext.substring(1).split("?"); - // Check if extension is valid if (!isValidFileExtension(validInputFileExtensions, actualExtension!)) { return null; } @@ -532,27 +580,17 @@ export default { } const originalPath = `${dir}/${name}.${actualExtension}`; const relativePath = `./${name}.${actualExtension}`; - // The URL of one file, for places that take a single file. const query = new URLSearchParams(condition); if (query.has(URL_QUERY)) { return getImageURL(relativePath, await readInfo(originalPath), query, outputFormat, sizes); } - // Get the true source if (condition.startsWith("image-source")) { const [info, preview] = await Promise.all([ readInfo(originalPath), readPreview(originalPath), ]); - return getImageSource( - relativePath, - info, - preview, - outputFormat, - sizes, - placeholder.type === "blurhash", - ); + return getImageSource(relativePath, info, preview, outputFormat, sizes, placeholder.type); } - // Get the transformer file if (condition.startsWith("image-transformer")) { const { width, transparent } = await readInfo(originalPath); return getImageTransformer( @@ -561,7 +599,6 @@ export default { getEffectiveSizes(sizes, width), ); } - // Image transformer variant if (condition.startsWith("image-raw")) { const [, , rawFormat, rawSize] = condition.split("-"); const format = rawFormat as SolidImageFormat; @@ -569,15 +606,9 @@ export default { const quality = getQuality(format); const signature = await readSignature(originalPath); const filename = getVariantFilename(signature, format, size, quality); - const encode = () => - limit(() => transformImage(originalPath, format, size, quality).toBuffer()); + const encode = () => limit(() => transformImage(originalPath, format, size, quality).toBuffer()); - // On build the file goes through the bundler, so it picks up `base`, - // `assetsDir` and the manifest like any other asset. if (isBuild) { - // Nothing is written to the public directory on build, so keep the - // encoded file in the Vite cache directory. The next build reads it - // back instead of encoding again. const cachePath = path.join(cacheDir, filename); let buffer: Buffer; if (await fileExists(cachePath)) { @@ -597,7 +628,6 @@ export default { const basePath = path.join(".image", filename); const targetPath = path.join(publicPath, basePath); - // Encoding is the slow part, so skip it when the file is already there. if (await fileExists(targetPath)) { await touchFile(targetPath); } else { @@ -605,10 +635,8 @@ export default { } return `export default "/${basePath}"`; } - // Image transformer variant if (condition.startsWith("image-")) { const [, format, size] = condition.split("-"); - return getImageVariant(relativePath, format as SolidImageFormat, +size!); } if (condition.startsWith("image")) { diff --git a/src/vite/transformers.ts b/src/vite/transformers.ts index 966ddda..8290bc3 100644 --- a/src/vite/transformers.ts +++ b/src/vite/transformers.ts @@ -153,6 +153,59 @@ export async function getBlurhashData( }; } +export interface ThumbhashData { + /** JSON-friendly representation of the binary hash. */ + hash: number[]; + /** Average color from the hash. Alpha is preserved. */ + color: string; +} + +export type ThumbhashEncode = (width: number, height: number, pixels: Uint8Array) => Uint8Array; +export type ThumbhashAverageRGBA = (hash: Uint8Array) => { + r: number; + g: number; + b: number; + a: number; +}; + +// ThumbHash accepts images up to 100x100. The reference browser example scales +// the longest side to 100px before encoding, which preserves all useful detail. +const THUMBHASH_SAMPLE_SIZE = 100; + +function toRGBAColor(r: number, g: number, b: number, a: number): string { + const alpha = Math.round(a * 1000) / 1000; + return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})`; +} + +/** + * Encodes an image as a ThumbHash and derives the server-side fallback color + * from that hash. The sample is auto-oriented and never exceeds 100x100. + */ +export async function getThumbhashData( + originalPath: string, + encode: ThumbhashEncode, + average: ThumbhashAverageRGBA, +): Promise { + const { data, info } = await sharp(originalPath) + .autoOrient() + .resize(THUMBHASH_SAMPLE_SIZE, THUMBHASH_SAMPLE_SIZE, { + fit: "inside", + withoutEnlargement: true, + }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const pixels = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + const hash = encode(info.width, info.height, pixels); + const { r, g, b, a } = average(hash); + + return { + hash: Array.from(hash), + color: toRGBAColor(r, g, b, a), + }; +} + export interface ImageInfo { width: number; height: number;