diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..74942db
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,17 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "browser-tests",
+ "runtimeExecutable": "bunx",
+ "runtimeArgs": ["vite", "test/browser", "--port", "5183", "--strictPort"],
+ "port": 5183
+ },
+ {
+ "name": "example",
+ "runtimeExecutable": "bun",
+ "runtimeArgs": ["run", "dev:example"],
+ "port": 5173
+ }
+ ]
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5f94148
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,21 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - run: bun install --frozen-lockfile
+ - run: bunx prettier --check "src/**/*.ts" "test/**/*.ts"
+ - run: bun run typecheck
+ - run: bun test
+ - run: bun run build
diff --git a/.gitignore b/.gitignore
index 73b5c50..4521e89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,4 +13,4 @@ bun.lockb
*~
# OS
-Thumbs.db
\ No newline at end of file
+Thumbs.db
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..94db0c1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Rodrigo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 9bc7809..c0ec0e6 100644
--- a/README.md
+++ b/README.md
@@ -1,151 +1,155 @@
# sharp-gpu
-A GPU-accelerated image processing library for the web, powered by WebGL and REGL. Perform real-time image transformations with blur, color adjustments, LUTs, and more.
+[sharp](https://www.npmjs.com/package/sharp)-style image processing for the browser, running on the GPU through WebGL.
-> β οΈ **Beta software**: SharpGPU is actively under development and not yet ready for production workloads.
+Same chainable pipeline you would write in Node, executed as fragment shaders: every operation is one full-screen pass, and nothing touches the CPU until you ask for the result.
-## Features
+> β οΈ **Beta**: the API is still moving. Pin an exact version.
-- π GPU-accelerated image processing using WebGL
-- π¨ Color adjustments (brightness, saturation, hue, lightness, tint)
-- π«οΈ Gaussian blur with variable radius
-- π LUT (Look-Up Table) support for custom color grading
-- βοΈ Chainable API for complex image pipelines
-- π¦ Built with TypeScript for full type safety
+```bash
+bun add sharp-gpu
+```
-## Roadmap
+## Usage
-### Core Pipeline & I/O
+```typescript
+import { SharpGPU } from "sharp-gpu";
-- [ ] Multi-source inputs (`Buffer`, `ReadableStream`, filesystem paths)
-- [x] Load from image URL/path via `SharpGPU.from`
-- [ ] Metadata inspection (`metadata()`)
-- [ ] File/`Buffer` outputs (`toFile`, `toBuffer`)
-- [x] Canvas output (`toCanvas`)
-- [x] Browser blob export (`toBlob`)
+const image = await SharpGPU.from("/photo.jpg");
-### Geometry & Resizing
+await image
+ .resize({ width: 800, height: 600, fit: "cover" })
+ .modulate({ brightness: 1.1, saturation: 1.3 })
+ .sharpen()
+ .toCanvas(document.querySelector("canvas"));
+```
-- [x] Basic resize by width/height with aspect preservation
-- [ ] Resize fit/cover/fill strategies (e.g. `fit: cover`, `background`)
-- [ ] Crop/extract, extend/pad, trim
-- [ ] Rotate, flip, flop
-- [ ] Affine/projective transforms
+Operations are queued, not executed. The pipeline runs once, on the GPU, when
+you call an output method β so `clone()` is cheap and re-rendering with new
+parameters costs a single pass per operation.
-### Color & Tone
+```typescript
+const base = await SharpGPU.from(file); // a Blob from an
-- [x] Modulate brightness, saturation, hue, lightness
-- [x] Tint
-- [x] Grayscale
-- [x] LUT-based grading (`lut()`)
-- [x] Linear, gamma, negate
-- [ ] Histogram-based normalize
-- [ ] Thresholding
-- [ ] Channel operations (remove/ensure alpha, join/extract channel)
+const thumbnail = await base.clone().resize({ width: 200 }).toBlob("image/webp");
+const preview = await base.clone().blur(8).toImageData();
+```
-### Effects & Convolution
+## Differences from sharp
-- [x] Gaussian blur (single-pass separable)
-- [ ] Median blur
-- [ ] Sharpen
-- [ ] Custom convolution kernels
-- [ ] Composite/overlay operations
+| | sharp | sharp-gpu |
+| ---------- | ---------------------------- | ------------------------------ |
+| Runs on | Node, libvips | Browser, WebGL2 |
+| Colors | 0β255 integers | 0β1 floats, `[r, g, b, a]` |
+| Output | files and buffers | canvas, `Blob`, `ImageData` |
+| Encoding | jpeg/png/webp/avif options | whatever `canvas.toBlob` takes |
-### Pipeline Composition
+Format encoding, EXIF, ICC profiles and multi-page images are out of scope:
+they are decoder concerns, and the browser already owns them.
-- [x] Chainable operation builder
-- [ ] Stream-based piping (`pipeline()`, `clone()` semantics for concurrency)
-- [ ] Queued/concurrent job control (`queue()`, `limitInputPixels`)
+## API
-## Installation
+### Input
-```bash
-bun add sharp-gpu
+```typescript
+SharpGPU.from(input); // URL string, Blob, File, ImageData, ImageBitmap,
+ // HTMLImageElement, HTMLCanvasElement, OffscreenCanvas
+image.metadata(); // { width, height } of the loaded image
+image.clone(); // fork the pipeline, sharing the GPU context
+image.destroy(); // release GPU resources
```
-## Usage
+### Geometry
```typescript
-import { SharpGPU } from "sharp-gpu";
+.resize({ width, height, fit, position, background })
+.extract({ left, top, width, height })
+.extend({ top, bottom, left, right, background })
+.rotate(degrees, background?) // clockwise, canvas grows to the bounding box
+.flip() // mirror vertically
+.flop() // mirror horizontally
+```
-// Load an image and apply transformations
-const canvas = document.getElementById("output") as HTMLCanvasElement;
+`fit` is `cover` (default), `contain`, `fill`, `inside` or `outside`.
+`position` is `center` (default), `top`, `right`, `bottom`, `left` or a corner
+such as `top-left`. Passing only `width` or only `height` preserves the aspect
+ratio and ignores `fit`.
-const image = await SharpGPU.from("/path/to/image.png");
+### Color and tone
-await image
- .blur(10)
- .modulate({
- brightness: 1.2,
- saturation: 1.5,
- hue: 30,
- })
- .toCanvas(canvas);
-
-// Or export as a blob
-const blob = await image.blur(5).grayscale().toBlob("image/png");
+```typescript
+.modulate({ brightness, saturation, hue, lightness })
+.linear(multiply, add?) // number | [r, g, b] | [r, g, b, a]
+.gamma(gamma?, gammaOut?) // defaults to 2.2 and 1.0
+.negate()
+.grayscale() // BT.709 luma, alias: greyscale()
+.tint([r, g, b])
+.threshold(value?, { grayscale })
+.normalize() // stretch luminance, alias: normalise()
+.recomb([[r], [g], [b]]) // 3x3 channel matrix
+.lut(values | ((x) => number)) // tone curve over luminance
```
-### Available Operations
+### Effects
```typescript
-// Blur with radius
-.blur(radius: number)
+.blur(radius) // separable gaussian
+.sharpen(amount?)
+.median() // 3x3, removes salt-and-pepper noise
+.convolve({ width, height, kernel, scale?, offset? }) // up to 7x7
+```
-// Color modulation
-.modulate({
- brightness?: number, // 0-2 (default: 1)
- saturation?: number, // 0-2 (default: 1)
- hue?: number, // 0-360 degrees (default: 0)
- lightness?: number, // -1 to 1 (default: 0)
- tint?: [r, g, b] // 0-1 normalized RGB (default: [1, 1, 1])
-})
+### Channels and composition
-// Linear adjustment (scale + offset per channel, optional alpha)
-.linear(multiply: number | [r, g, b] | [r, g, b, a], add?: number | [r, g, b] | [r, g, b, a])
+```typescript
+.extractChannel("red" | "green" | "blue" | "alpha")
+.removeAlpha()
+.flatten(background?) // composite over a color, drop transparency
+.composite([{ input, blend?, gravity?, top?, left? }])
+```
-// Gamma correction (default gamma=2.2, gammaOut=1.0)
-.gamma(gamma?: number | [r, g, b], gammaOut?: number | [r, g, b])
+`blend` is `over` (default), `multiply`, `screen`, `overlay`, `darken`,
+`lighten`, `difference` or `exclusion`. `input` accepts anything
+`SharpGPU.from` accepts.
-// Channel inversion
-.negate()
+### Output
-// Grayscale (shorthand for saturation: 0)
-.grayscale()
+```typescript
+await image.toCanvas(canvas);
+await image.toBlob(type?, quality?);
+await image.toImageData();
+```
-// Tint (helper around linear scaling)
-.tint([r, g, b])
+## Notes
-// Look-Up Table (custom color grading)
-.lut((x: number) => number) // Function mapping
-.lut([...values]) // Array of values
+- Colors are `[r, g, b, a]` with components in `0..1`, not `0..255`.
+- `normalize()` and `toImageData()` read pixels back from the GPU, which stalls
+ the pipeline. Everything else stays on the GPU.
+- `blur` caps its radius at 32 px per pass; chain calls for stronger blurs.
+- `metadata()` describes the loaded image, like sharp β queued operations do
+ not change it.
-// Resize
-.resize({ width: number, height: number })
-```
+## Roadmap
+
+- [ ] Trim uniform borders (`trim`)
+- [ ] Histogram and image statistics (`stats`)
+- [ ] Affine and perspective transforms
+- [ ] Lanczos/mitchell resampling kernels
+- [ ] WebGPU backend
## Development
```bash
-# Install dependencies
bun install
-
-# Build the library
-bun run build
-
-# Watch mode for development
-bun run dev
-
-# Type checking
+bun test # pure logic
+bun run test:browser # GPU tests, open the printed URL
bun run typecheck
-
-# Run example
-cd example && bun install && bun run dev
+bun run build
+bun run dev:example # interactive playground
```
-## Example
-
-Check out the [`example/`](./example) folder for a working interactive demo with Tweakpane controls.
+`bun test` covers the plan math (sizes, transforms, kernels, curves).
+`test/browser` renders each operation on a real GPU and asserts on pixels.
## License
diff --git a/bun.lock b/bun.lock
index 7b29699..de6b5e6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "sharp-gpu",
@@ -8,10 +9,17 @@
"prettier": "^3.6.2",
"tsup": "^8.3.0",
"typescript": "^5.6.2",
+ "vite": "^8.1.5",
},
},
},
"packages": {
+ "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
+
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
@@ -74,8 +82,44 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
+
+ "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
+
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
+
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
+
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="],
@@ -120,6 +164,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="],
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
+
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -164,6 +210,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -190,6 +238,30 @@
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
+ "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -226,7 +298,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
@@ -244,6 +316,8 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
+ "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
+
"rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -280,6 +354,8 @@
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"tsup": ["tsup@8.5.0", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.25.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "0.8.0-beta.0", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -288,6 +364,8 @@
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
+ "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
+
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
@@ -304,6 +382,12 @@
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "vite/postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
+
+ "vite/tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
+
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -312,6 +396,8 @@
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "vite/postcss/nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
+
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
diff --git a/example/bun.lock b/example/bun.lock
index e35ef7f..4ed7f20 100644
--- a/example/bun.lock
+++ b/example/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "examples",
diff --git a/package.json b/package.json
index 377cd91..53a023c 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
"@types/bun": "^1.3.0",
"prettier": "^3.6.2",
"tsup": "^8.3.0",
- "typescript": "^5.6.2"
+ "typescript": "^5.6.2",
+ "vite": "^8.1.5"
},
"exports": {
".": {
@@ -46,7 +47,8 @@
"dev:example": "cd example && bun install && bun run dev",
"build:example": "cd example && bun install && bun run build",
"release:patch": "npm version patch && git push --follow-tags",
- "test": "bun test"
+ "test": "bun test",
+ "test:browser": "vite test/browser"
},
"type": "module",
"types": "./dist/index.d.ts"
diff --git a/src/core.ts b/src/core.ts
index 2dfc8a4..7aced59 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -1,156 +1,293 @@
-import { GLRenderer, GLTexture } from "./gl";
-import { clampVec3Min, toVec3, toVec4, Vec3, Vec4 } from "./utils/vector";
-
-import { COPY } from "./programs";
-import { BaseOperation, CopyOperation } from "./operations/base";
-
-import { ColorOperation } from "./operations/color";
-import { Size, ResizeParams, ResizeOperation } from "./operations/resize";
-import { BlurOperation } from "./operations/blur";
-import { ModulateOperation, ModulateParams } from "./operations/modulate";
-import { GammaOperation } from "./operations/gamma";
-import { LinearOperation } from "./operations/linear";
-import { LUTOperation, LUTParams } from "./operations/lut";
-
-type ImageSource = string;
-type LinearInput = number | Vec3 | Vec4;
+import { GLRenderer } from "./gl";
+import { clampVec3Min, toVec3, toVec4, Vec2, Vec3, Vec4 } from "./utils/vector";
+import {
+ extendPlan,
+ ExtendParams,
+ extractPlan,
+ flipPlan,
+ flopPlan,
+ Mat3,
+ Rect,
+ resizePlan,
+ ResizeParams,
+ rotatePlan,
+ SamplePlan,
+ Size,
+} from "./geometry";
+import { decodeImage, ImageInput } from "./input";
+import { toImageData } from "./output";
+
+import {
+ COPY,
+ InputOperation,
+ Operation,
+ ShaderOperation,
+} from "./operations/base";
+import { BLUR } from "./operations/blur";
+import { CompositeOperation, CompositeParams } from "./operations/composite";
+import {
+ Channel,
+ CHANNEL_MASKS,
+ EXTRACT_CHANNEL,
+ FLATTEN,
+ LUMA,
+ RECOMB,
+ THRESHOLD,
+} from "./operations/color";
+import {
+ CONVOLVE,
+ ConvolveParams,
+ kernelScale,
+ padKernel,
+} from "./operations/convolve";
+import { GAMMA } from "./operations/gamma";
+import { LINEAR } from "./operations/linear";
+import { LUTInput, LUTOperation } from "./operations/lut";
+import { MEDIAN } from "./operations/median";
+import { MODULATE, ModulateParams } from "./operations/modulate";
+import { NormalizeOperation } from "./operations/normalize";
+import { SampleOperation } from "./operations/sample";
+
+export type LinearInput = number | Vec3 | Vec4;
type SharpGPUParams = {
renderer?: GLRenderer;
- operations?: BaseOperation[];
+ operations?: Operation[];
};
export class SharpGPU {
renderer: GLRenderer;
- operations: BaseOperation[] = [];
+ operations: Operation[];
+ private source?: Size;
constructor(params: SharpGPUParams = {}) {
this.renderer = params.renderer ?? new GLRenderer();
this.operations = params.operations ?? [];
}
- static async from(src: ImageSource) {
- return new SharpGPU().loadImage(src);
+ static async from(input: ImageInput) {
+ return new SharpGPU().loadImage(input);
}
get canvas() {
return this.renderer.canvas;
}
- get size(): Size {
- return {
- width: this.canvas.width,
- height: this.canvas.height,
- };
+ // Describes the loaded image, like sharp's metadata(); pending operations do
+ // not change it.
+ metadata(): Size {
+ if (!this.source) {
+ throw new Error("No image loaded");
+ }
+ return this.source;
}
clone() {
- return new SharpGPU({
+ const clone = new SharpGPU({
renderer: this.renderer,
operations: [...this.operations],
});
+ clone.source = this.source;
+ return clone;
}
- async loadImage(src: ImageSource) {
- const image = new Image();
- image.src = src;
- await image.decode();
+ async loadImage(input: ImageInput) {
+ const image = await decodeImage(input);
const texture = this.renderer.texture({
width: image.width,
height: image.height,
data: image,
- flipY: true,
});
- return this.resize(image).copy(texture);
+ this.source = { width: image.width, height: image.height };
+ return this.addOperation(new InputOperation(texture));
}
- // Operations
- addOperation(operation: BaseOperation) {
+ addOperation(operation: Operation) {
this.operations.push(operation);
return this;
}
+ // Geometry
resize(params: ResizeParams) {
- this.addOperation(new ResizeOperation(params));
- return this;
+ return this.sample((src) => resizePlan(src, params));
}
- copy(src: GLTexture) {
- this.addOperation(new CopyOperation(src));
- return this;
+ extract(rect: Rect) {
+ return this.sample((src) => extractPlan(src, rect));
}
- blur(radius: number) {
- if (radius > 0) {
- this.addOperation(new BlurOperation({ radius, direction: [1, 0] }));
- this.addOperation(new BlurOperation({ radius, direction: [0, 1] }));
- }
+ extend(params: ExtendParams) {
+ return this.sample((src) => extendPlan(src, params));
+ }
- return this;
+ flip() {
+ return this.sample(flipPlan);
}
- modulate(props: ModulateParams) {
- this.addOperation(new ModulateOperation(props));
- return this;
+ flop() {
+ return this.sample(flopPlan);
}
- multiply(multiply: LinearInput) {
- return this.linear(multiply, 0);
+ rotate(degrees: number, background?: Vec4) {
+ return this.sample((src) => rotatePlan(src, degrees, background));
}
- add(add: LinearInput) {
- return this.linear(1, add);
+ private sample(plan: (source: Size) => SamplePlan) {
+ return this.addOperation(new SampleOperation(plan));
}
- linear(multiply: LinearInput, add: LinearInput = 0) {
+ // Effects
+ convolve(params: ConvolveParams) {
+ return this.addOperation(
+ new ShaderOperation(CONVOLVE, {
+ kernel: padKernel(params),
+ shape: [params.width, params.height] as Vec2,
+ scale: kernelScale(params),
+ offset: params.offset ?? 0,
+ }),
+ );
+ }
+
+ sharpen(amount = 1) {
+ return this.convolve({
+ width: 3,
+ height: 3,
+ kernel: [0, -amount, 0, -amount, 1 + 4 * amount, -amount, 0, -amount, 0],
+ scale: 1,
+ });
+ }
+
+ median() {
+ return this.addOperation(new ShaderOperation(MEDIAN, {}));
+ }
+
+ blur(radius: number) {
+ if (radius <= 0) {
+ return this;
+ }
+ const horizontal: Vec2 = [1, 0];
+ const vertical: Vec2 = [0, 1];
this.addOperation(
- new LinearOperation({
+ new ShaderOperation(BLUR, { radius, direction: horizontal }),
+ );
+ return this.addOperation(
+ new ShaderOperation(BLUR, { radius, direction: vertical }),
+ );
+ }
+
+ // Color
+ modulate(params: ModulateParams) {
+ return this.addOperation(
+ new ShaderOperation(MODULATE, {
+ brightness: params.brightness ?? 1,
+ saturation: params.saturation ?? 1,
+ hue: params.hue ?? 0,
+ lightness: params.lightness ?? 0,
+ }),
+ );
+ }
+
+ linear(multiply: LinearInput, add: LinearInput = 0) {
+ return this.addOperation(
+ new ShaderOperation(LINEAR, {
multiply: toVec4(multiply, 1, 1),
add: toVec4(add, 0, 0),
}),
);
- return this;
}
gamma(gamma: LinearInput = 2.2, gammaOut: LinearInput = 1) {
const a = clampVec3Min(toVec3(gamma, 2.2), 0.0001);
const b = clampVec3Min(toVec3(gammaOut, 1), 0.0001);
const exponent: Vec3 = [b[0] / a[0], b[1] / a[1], b[2] / a[2]];
- this.addOperation(new GammaOperation({ exponent }));
- return this;
+ return this.addOperation(new ShaderOperation(GAMMA, { exponent }));
}
negate() {
return this.linear([-1, -1, -1, 1], [1, 1, 1, 0]);
}
+ // Row-major rows, as sharp takes them.
+ recomb(matrix: [Vec3, Vec3, Vec3]) {
+ const [r, g, b] = matrix;
+ const columns: Mat3 = [
+ r[0],
+ g[0],
+ b[0],
+ r[1],
+ g[1],
+ b[1],
+ r[2],
+ g[2],
+ b[2],
+ ];
+ return this.addOperation(new ShaderOperation(RECOMB, { matrix: columns }));
+ }
+
grayscale() {
- return this.modulate({ saturation: 0 });
+ return this.addOperation(new ShaderOperation(RECOMB, { matrix: LUMA }));
}
- tint(tint: ModulateParams["tint"]) {
- const multiply = toVec4(tint, 1, 1);
- return this.linear(multiply, toVec4(0, 0, 0));
+ greyscale() {
+ return this.grayscale();
}
- lut(lut: LUTParams["lut"]) {
- this.addOperation(new LUTOperation({ lut }));
- return this;
+ threshold(threshold = 0.5, options: { grayscale?: boolean } = {}) {
+ return this.addOperation(
+ new ShaderOperation(THRESHOLD, {
+ threshold,
+ perChannel: options.grayscale === false,
+ }),
+ );
+ }
+
+ tint(tint: number | Vec3) {
+ return this.linear(toVec4(tint, 1, 1));
+ }
+
+ normalize() {
+ return this.addOperation(new NormalizeOperation());
+ }
+
+ normalise() {
+ return this.normalize();
+ }
+
+ lut(lut: LUTInput) {
+ return this.addOperation(new LUTOperation(lut));
+ }
+
+ // Channels
+ extractChannel(channel: Channel) {
+ return this.addOperation(
+ new ShaderOperation(EXTRACT_CHANNEL, { mask: CHANNEL_MASKS[channel] }),
+ );
+ }
+
+ flatten(background: Vec4 = [0, 0, 0, 1]) {
+ return this.addOperation(new ShaderOperation(FLATTEN, { background }));
+ }
+
+ removeAlpha() {
+ return this.linear([1, 1, 1, 0], [0, 0, 0, 1]);
}
- color(color: Vec4) {
- this.addOperation(new ColorOperation(color));
+ // Composition
+ composite(overlays: CompositeParams[]) {
+ for (const overlay of overlays) {
+ this.addOperation(new CompositeOperation(overlay));
+ }
return this;
}
- // Render
- private render() {
- let src = this.renderer.framebuffer();
- let dst = this.renderer.framebuffer();
+ // Output
+ private async render() {
+ await Promise.all(this.operations.map((op) => op.prepare?.(this.renderer)));
+
+ let [src, dst] = this.renderer.pingpong();
- // Run operations
for (const operation of this.operations) {
operation.run({
renderer: this.renderer,
@@ -158,42 +295,41 @@ export class SharpGPU {
target: dst,
});
- // Swap buffers
[src, dst] = [dst, src];
-
- // Resize target to source size
dst.texture.resize(src.texture.width, src.texture.height);
}
- // Resize canvas
this.renderer.resize(src.texture.width, src.texture.height);
+ this.renderer.program(COPY).draw({ source: src.texture });
- // Copy source to canvas
- this.renderer.program(COPY).draw({
- source: src.texture,
- });
+ return src.texture;
}
async toCanvas(target: HTMLCanvasElement) {
- this.render();
+ const texture = await this.render();
const ctx = target.getContext("2d");
-
if (!ctx) {
throw new Error("Failed to get 2D context");
}
- target.width = this.size.width;
- target.height = this.size.height;
+ target.width = texture.width;
+ target.height = texture.height;
ctx.drawImage(this.canvas, 0, 0, target.width, target.height);
return this;
}
+ async toImageData(): Promise {
+ const texture = await this.render();
+ return toImageData(this.renderer.readPixels(texture), texture);
+ }
+
async toBlob(type?: string, quality?: number): Promise {
- this.render();
+ await this.render();
return new Promise((resolve, reject) => {
this.canvas.toBlob(
- (blob) => (blob ? resolve(blob) : reject()),
+ (blob) =>
+ blob ? resolve(blob) : reject(new Error("Canvas toBlob failed")),
type,
quality,
);
diff --git a/src/geometry.ts b/src/geometry.ts
new file mode 100644
index 0000000..2586178
--- /dev/null
+++ b/src/geometry.ts
@@ -0,0 +1,270 @@
+import { Vec4 } from "./utils/vector";
+
+export type Size = {
+ width: number;
+ height: number;
+};
+
+export type Rect = Size & {
+ left: number;
+ top: number;
+};
+
+export type Fit = "cover" | "contain" | "fill" | "inside" | "outside";
+
+export type Gravity =
+ | "center"
+ | "top"
+ | "right"
+ | "bottom"
+ | "left"
+ | "top-left"
+ | "top-right"
+ | "bottom-left"
+ | "bottom-right";
+
+export type ResizeParams = {
+ width?: number;
+ height?: number;
+ fit?: Fit;
+ position?: Gravity;
+ background?: Vec4;
+};
+
+export type ExtendParams = {
+ top?: number;
+ bottom?: number;
+ left?: number;
+ right?: number;
+ background?: Vec4;
+};
+
+// Column-major 3x3 mapping a target uv to a source uv. Coordinates outside
+// [0, 1] render as background.
+export type Mat3 = [
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+];
+
+export type SamplePlan = {
+ size: Size;
+ transform: Mat3;
+ background: Vec4;
+};
+
+export const TRANSPARENT: Vec4 = [0, 0, 0, 0];
+
+export const IDENTITY: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
+
+function affine(
+ a: number,
+ b: number,
+ c: number,
+ d: number,
+ tx: number,
+ ty: number,
+): Mat3 {
+ return [a, b, 0, c, d, 0, tx, ty, 1];
+}
+
+function align(gravity: Gravity, start: string, end: string): number {
+ if (gravity.includes(start)) {
+ return 0;
+ }
+ if (gravity.includes(end)) {
+ return 1;
+ }
+ return 0.5;
+}
+
+// Anchor as a 0..1 fraction, y measured from the top.
+export function anchor(gravity: Gravity = "center"): [number, number] {
+ return [align(gravity, "left", "right"), align(gravity, "top", "bottom")];
+}
+
+function round(size: Size): Size {
+ return {
+ width: Math.max(1, Math.round(size.width)),
+ height: Math.max(1, Math.round(size.height)),
+ };
+}
+
+// Maps the source rect onto the destination rect of a target of `size`.
+// Rects use a top-left origin; uv space is bottom-up, so y is mirrored here.
+export function place(source: Size, size: Size, src: Rect, dst: Rect): Mat3 {
+ const scaleX = (size.width * src.width) / (dst.width * source.width);
+ const scaleY = (size.height * src.height) / (dst.height * source.height);
+
+ const srcBottom = source.height - (src.top + src.height);
+ const dstBottom = size.height - (dst.top + dst.height);
+
+ return affine(
+ scaleX,
+ 0,
+ 0,
+ scaleY,
+ (src.left - (dst.left * src.width) / dst.width) / source.width,
+ (srcBottom - (dstBottom * src.height) / dst.height) / source.height,
+ );
+}
+
+function fullRect(size: Size): Rect {
+ return { left: 0, top: 0, ...size };
+}
+
+// Size requested by width/height, preserving aspect when only one is given.
+export function computeSize(src: Size, params: ResizeParams): Size {
+ const aspect = src.width / src.height;
+
+ if (params.width && params.height) {
+ return round({ width: params.width, height: params.height });
+ }
+ if (params.width) {
+ return round({ width: params.width, height: params.width / aspect });
+ }
+ if (params.height) {
+ return round({ width: params.height * aspect, height: params.height });
+ }
+ return src;
+}
+
+// Centers `inner` inside `outer` according to the gravity anchor.
+function anchoredRect(outer: Size, inner: Size, gravity?: Gravity): Rect {
+ const [alignX, alignY] = anchor(gravity);
+ return {
+ ...inner,
+ left: (outer.width - inner.width) * alignX,
+ top: (outer.height - inner.height) * alignY,
+ };
+}
+
+// `inside` and `outside` change the output size instead of cropping or padding.
+function scaledToBox(source: Size, box: Size, fit: Fit): Size {
+ const ratios = [box.width / source.width, box.height / source.height];
+ const scale = fit === "inside" ? Math.min(...ratios) : Math.max(...ratios);
+ return round({ width: source.width * scale, height: source.height * scale });
+}
+
+export function resizePlan(source: Size, params: ResizeParams): SamplePlan {
+ const background = params.background ?? TRANSPARENT;
+ const size = computeSize(source, params);
+ const fit = params.fit ?? "cover";
+
+ // A single dimension always scales uniformly, as does an unconstrained resize.
+ const uniform = !params.width || !params.height || fit === "fill";
+ if (uniform) {
+ return { size, transform: IDENTITY, background };
+ }
+
+ if (fit === "inside" || fit === "outside") {
+ return {
+ size: scaledToBox(source, size, fit),
+ transform: IDENTITY,
+ background,
+ };
+ }
+
+ if (fit === "cover") {
+ // Sample the largest centered region of the source with the target aspect.
+ const scale = Math.max(
+ size.width / source.width,
+ size.height / source.height,
+ );
+ const crop = { width: size.width / scale, height: size.height / scale };
+ const src = anchoredRect(source, crop, params.position);
+ return {
+ size,
+ transform: place(source, size, src, fullRect(size)),
+ background,
+ };
+ }
+
+ // contain: draw the whole source inside the target and let the rest fall
+ // outside [0, 1], where the shader paints the background.
+ const scale = Math.min(
+ size.width / source.width,
+ size.height / source.height,
+ );
+ const inner = { width: source.width * scale, height: source.height * scale };
+ const dst = anchoredRect(size, inner, params.position);
+ return {
+ size,
+ transform: place(source, size, fullRect(source), dst),
+ background,
+ };
+}
+
+export function extractPlan(source: Size, rect: Rect): SamplePlan {
+ const size = round(rect);
+ return {
+ size,
+ transform: place(source, size, rect, fullRect(size)),
+ background: TRANSPARENT,
+ };
+}
+
+export function extendPlan(source: Size, params: ExtendParams): SamplePlan {
+ const top = params.top ?? 0;
+ const left = params.left ?? 0;
+ const size = round({
+ width: source.width + left + (params.right ?? 0),
+ height: source.height + top + (params.bottom ?? 0),
+ });
+
+ return {
+ size,
+ transform: place(source, size, fullRect(source), { ...source, left, top }),
+ background: params.background ?? TRANSPARENT,
+ };
+}
+
+export function flipPlan(source: Size): SamplePlan {
+ return {
+ size: source,
+ transform: affine(1, 0, 0, -1, 0, 1),
+ background: TRANSPARENT,
+ };
+}
+
+export function flopPlan(source: Size): SamplePlan {
+ return {
+ size: source,
+ transform: affine(-1, 0, 0, 1, 1, 0),
+ background: TRANSPARENT,
+ };
+}
+
+// Rotates clockwise about the center, growing the canvas to the bounding box.
+export function rotatePlan(
+ source: Size,
+ degrees: number,
+ background: Vec4 = TRANSPARENT,
+): SamplePlan {
+ const radians = (degrees * Math.PI) / 180;
+ const cos = Math.cos(radians);
+ const sin = Math.sin(radians);
+
+ const size = round({
+ width: Math.abs(source.width * cos) + Math.abs(source.height * sin),
+ height: Math.abs(source.width * sin) + Math.abs(source.height * cos),
+ });
+
+ // Target pixel -> centered -> inverse rotation -> source uv.
+ const a = (cos * size.width) / source.width;
+ const c = (-sin * size.height) / source.width;
+ const b = (sin * size.width) / source.height;
+ const d = (cos * size.height) / source.height;
+
+ return {
+ size,
+ transform: affine(a, b, c, d, 0.5 - (a + c) / 2, 0.5 - (b + d) / 2),
+ background,
+ };
+}
diff --git a/src/gl.ts b/src/gl.ts
deleted file mode 100644
index 25d2156..0000000
--- a/src/gl.ts
+++ /dev/null
@@ -1,813 +0,0 @@
-// Generic type for WebGL context
-export type GLContext = WebGLRenderingContext | WebGL2RenderingContext;
-
-// Map WebGL constants to human-readable values
-export const glMap = (gl: GLContext) => ({
- format: {
- rgba: gl.RGBA,
- rgb: gl.RGB,
- alpha: gl.ALPHA,
- luminance: gl.LUMINANCE,
- luminanceAlpha: gl.LUMINANCE_ALPHA,
- },
- type: {
- uint8: gl.UNSIGNED_BYTE,
- float: gl.FLOAT,
- },
- wrap: {
- clamp: gl.CLAMP_TO_EDGE,
- repeat: gl.REPEAT,
- mirror: gl.MIRRORED_REPEAT,
- },
- filter: {
- nearest: gl.NEAREST,
- linear: gl.LINEAR,
- },
- attributeType: {
- float: gl.FLOAT,
- byte: gl.BYTE,
- short: gl.SHORT,
- unsignedByte: gl.UNSIGNED_BYTE,
- unsignedShort: gl.UNSIGNED_SHORT,
- },
- indexType: {
- uint8: gl.UNSIGNED_BYTE,
- uint16: gl.UNSIGNED_SHORT,
- uint32: gl.UNSIGNED_INT,
- },
- blendFactor: {
- zero: gl.ZERO,
- one: gl.ONE,
- srcColor: gl.SRC_COLOR,
- oneMinusSrcColor: gl.ONE_MINUS_SRC_COLOR,
- dstColor: gl.DST_COLOR,
- oneMinusDstColor: gl.ONE_MINUS_DST_COLOR,
- },
- blendEquation: {
- add: gl.FUNC_ADD,
- subtract: gl.FUNC_SUBTRACT,
- reverseSubtract: gl.FUNC_REVERSE_SUBTRACT,
- },
- primitive: {
- points: gl.POINTS,
- lines: gl.LINES,
- lineStrip: gl.LINE_STRIP,
- lineLoop: gl.LINE_LOOP,
- triangleStrip: gl.TRIANGLE_STRIP,
- triangleFan: gl.TRIANGLE_FAN,
- },
- drawMode: {
- points: gl.POINTS,
- lines: gl.LINES,
- lineStrip: gl.LINE_STRIP,
- lineLoop: gl.LINE_LOOP,
- triangleStrip: gl.TRIANGLE_STRIP,
- triangleFan: gl.TRIANGLE_FAN,
- },
- bufferTarget: {
- array: gl.ARRAY_BUFFER,
- element: gl.ELEMENT_ARRAY_BUFFER,
- },
- bufferUsage: {
- static: gl.STATIC_DRAW,
- dynamic: gl.DYNAMIC_DRAW,
- stream: gl.STREAM_DRAW,
- },
-});
-
-export type GLMap = ReturnType;
-
-// Texture
-export type GLTextureSource = TexImageSource | ArrayBufferView | null;
-
-export type GLTextureParams = {
- width: number;
- height: number;
- data: GLTextureSource;
- format: keyof GLMap["format"];
- type: keyof GLMap["type"];
- wrapS: keyof GLMap["wrap"];
- wrapT: keyof GLMap["wrap"];
- minFilter: keyof GLMap["filter"];
- magFilter: keyof GLMap["filter"];
- flipY: boolean;
-};
-
-export class GLTexture {
- readonly gl: GLContext;
- readonly handle: WebGLTexture;
-
- params: GLTextureParams = {
- width: 1,
- height: 1,
- data: null,
- format: "rgba",
- type: "uint8",
- wrapS: "clamp",
- wrapT: "clamp",
- minFilter: "linear",
- magFilter: "linear",
- flipY: false,
- };
-
- constructor(gl: GLContext, params: Partial = {}) {
- const handle = gl.createTexture();
- if (!handle) {
- throw new Error("Failed to create texture");
- }
-
- this.gl = gl;
- this.handle = handle;
- this.update(params);
- }
-
- get width() {
- return this.params.width;
- }
-
- get height() {
- return this.params.height;
- }
-
- bind(unit = 0) {
- this.gl.activeTexture(this.gl.TEXTURE0 + unit);
- this.gl.bindTexture(this.gl.TEXTURE_2D, this.handle);
- }
-
- update(params: Partial = {}) {
- const gl = this.gl;
- const map = glMap(gl);
-
- this.params = { ...this.params, ...params };
-
- const minFilter = map.filter[this.params.minFilter];
- const magFilter = map.filter[this.params.magFilter];
- const wrapS = map.wrap[this.params.wrapS];
- const wrapT = map.wrap[this.params.wrapT];
- const format = map.format[this.params.format];
- const type = map.type[this.params.type];
- const data = this.params.data;
-
- gl.bindTexture(gl.TEXTURE_2D, this.handle);
-
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, minFilter);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, magFilter);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS);
- gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapT);
- gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.params.flipY ? 1 : 0);
-
- if (data && !ArrayBuffer.isView(data)) {
- gl.texImage2D(gl.TEXTURE_2D, 0, format, format, type, data);
- } else {
- gl.texImage2D(
- gl.TEXTURE_2D,
- 0,
- format,
- this.width,
- this.height,
- 0,
- format,
- type,
- data,
- );
- }
- }
-
- resize(width: number, height: number) {
- if (width <= 0 || height <= 0) {
- throw new Error("Texture width and height must be positive");
- }
- if (width === this.width && height === this.height) {
- return;
- }
- this.update({ width, height });
- }
-
- dispose() {
- this.gl.deleteTexture(this.handle);
- }
-}
-
-// Buffers
-export type GLBufferData = ArrayBufferView | ArrayLike | null;
-
-export type GLBufferParams = {
- target: keyof GLMap["bufferTarget"];
- usage: keyof GLMap["bufferUsage"];
- data: GLBufferData;
-};
-
-export class GLBuffer {
- target: keyof GLMap["bufferTarget"];
- usage: keyof GLMap["bufferUsage"];
-
- readonly gl: GLContext;
- readonly handle: WebGLBuffer;
-
- constructor(gl: GLContext, params: Partial = {}) {
- this.gl = gl;
- this.handle = gl.createBuffer();
-
- this.target = params.target ?? "array";
- this.usage = params.usage ?? "static";
-
- if (params.data) {
- this.update(params.data);
- }
- }
-
- private normalizeData(data: GLBufferData): ArrayBufferView | null {
- if (ArrayBuffer.isView(data)) {
- return data;
- }
- if (Array.isArray(data)) {
- return new Float32Array(data);
- }
- return null;
- }
-
- use(fn: () => void) {
- if (!this.handle) return;
- const targetEnum = glMap(this.gl).bufferTarget[this.target];
- this.gl.bindBuffer(targetEnum, this.handle);
- fn();
- this.gl.bindBuffer(targetEnum, null);
- }
-
- update(data: GLBufferData) {
- if (!this.handle) return;
- const targetEnum = glMap(this.gl).bufferTarget[this.target];
- const usageEnum = glMap(this.gl).bufferUsage[this.usage];
- const payload = this.normalizeData(data);
- this.use(() => {
- if (payload) {
- this.gl.bufferData(targetEnum, payload, usageEnum);
- } else {
- this.gl.bufferData(targetEnum, 0, usageEnum);
- }
- });
- }
-
- dispose() {
- this.gl.deleteBuffer(this.handle);
- }
-}
-
-export class GLFramebuffer {
- readonly gl: GLContext;
- readonly texture: GLTexture;
- readonly handle: WebGLFramebuffer;
-
- constructor(gl: GLContext, texture: GLTexture) {
- this.gl = gl;
- this.texture = texture;
- this.handle = gl.createFramebuffer();
-
- // bind
- gl.bindFramebuffer(gl.FRAMEBUFFER, this.handle);
- gl.framebufferTexture2D(
- gl.FRAMEBUFFER,
- gl.COLOR_ATTACHMENT0,
- gl.TEXTURE_2D,
- this.texture.handle,
- 0,
- );
- gl.bindFramebuffer(gl.FRAMEBUFFER, null);
- }
-
- use(fn: () => void) {
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.handle);
- this.gl.viewport(0, 0, this.texture.width, this.texture.height);
- fn();
- this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
- }
-
- dispose() {
- this.gl.deleteFramebuffer(this.handle);
- this.texture.dispose();
- }
-}
-
-// Program
-export type GLBlendConfig = {
- enabled?: boolean;
- srcFactor?: keyof GLMap["blendFactor"];
- dstFactor?: keyof GLMap["blendFactor"];
- equation?: keyof GLMap["blendEquation"];
-};
-
-export type GLAttribute = {
- buffer: GLBuffer;
- size: number;
- type?: keyof GLMap["attributeType"];
- normalized?: boolean;
- stride?: number;
- offset?: number;
-};
-
-export type GLAttributes = Record<
- string,
- GLAttribute | ((props: Props) => GLAttribute)
->;
-
-export type GLUniformValue =
- | number
- | boolean
- | readonly number[]
- | Float32Array
- | Int32Array
- | GLTexture;
-
-export type GLUniforms = Record<
- string,
- GLUniformValue | ((props: Props) => GLUniformValue)
->;
-
-export type GLProgramDefinition = {
- vert?: string;
- frag?: string;
- primitive?: keyof GLMap["primitive"];
- count?: number;
- offset?: number;
- indexType?: keyof GLMap["indexType"];
- elements?: GLBuffer;
- attributes?: GLAttributes;
- uniforms?: GLUniforms;
- blend?: GLBlendConfig;
-};
-
-type GLProgramUniform = {
- name: string;
- location: WebGLUniformLocation;
- value: GLUniformValue | ((props: Props) => GLUniformValue);
-};
-
-type GLProgramAttribute = {
- name: string;
- location: number;
- value: GLAttribute | ((props: Props) => GLAttribute);
-};
-
-export class GLProgram {
- readonly gl: GLContext;
- private readonly handle: WebGLProgram;
-
- private blend: GLBlendConfig = {
- enabled: false,
- };
-
- private elements?: GLBuffer;
- private primitive: keyof GLMap["primitive"];
- private count: number;
- private offset: number;
- private indexType?: keyof GLMap["indexType"];
-
- private uniforms: Record> = {};
- private attributes: Record> = {};
- private attributeCache = new Map();
-
- static readonly DEFAULT_VERT = /* glsl */ `
- precision mediump float;
- attribute vec2 position;
- varying vec2 uv;
- void main() {
- uv = position * 0.5 + 0.5;
- gl_Position = vec4(position, 0.0, 1.0);
- }
- `;
-
- static readonly DEFAULT_FRAG = /* glsl */ `
- precision mediump float;
- varying vec2 uv;
- void main() {
- gl_FragColor = vec4(uv, 0.0, 1.0);
- }
- `;
-
- static createFSQuadBuffer(gl: GLContext) {
- return new GLBuffer(gl, {
- target: "array",
- usage: "static",
- data: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
- });
- }
-
- constructor(gl: GLContext, definition: GLProgramDefinition = {}) {
- this.gl = gl;
-
- const vert = definition.vert ?? GLProgram.DEFAULT_VERT;
- const frag = definition.frag ?? GLProgram.DEFAULT_FRAG;
- const attributes = definition.attributes ?? {
- position: {
- buffer: GLProgram.createFSQuadBuffer(gl),
- size: 2,
- },
- };
-
- if (!vert.length || !frag.length) {
- throw new Error("Program requires both vertex and fragment shaders");
- }
-
- if (definition.blend) {
- this.blend = definition.blend;
- }
-
- this.elements = definition.elements;
- this.primitive = definition.primitive ?? "triangleStrip";
- this.count = definition.count ?? 4;
- this.offset = definition.offset ?? 0;
- this.indexType = definition.indexType ?? "uint16";
-
- this.handle = this.buildProgram(gl, vert, frag);
-
- if (definition.uniforms) {
- this.uniforms = this.buildUniforms(gl, definition.uniforms);
- }
-
- if (attributes) {
- this.attributes = this.buildAttributes(gl, attributes);
- }
- }
-
- private buildUniforms(gl: GLContext, uniforms: GLUniforms) {
- const result: Record> = {};
- for (const [name, value] of Object.entries(uniforms)) {
- const location = gl.getUniformLocation(this.handle, name);
- if (!location) {
- throw new Error(`Uniform not found: ${name}`);
- }
- result[name] = { name, location, value };
- }
- return result;
- }
-
- private buildAttributes(gl: GLContext, attributes: GLAttributes) {
- const result: Record> = {};
- for (const [name, value] of Object.entries(attributes)) {
- const location = gl.getAttribLocation(this.handle, name);
- if (location === -1) {
- throw new Error(`Attribute not found: ${name}`);
- }
- result[name] = { name, location, value };
- }
- return result;
- }
-
- private compileShader(gl: GLContext, type: number, source: string) {
- const shader = gl.createShader(type);
- if (!shader) {
- throw new Error("Failed to create shader");
- }
-
- gl.shaderSource(shader, source);
- gl.compileShader(shader);
-
- const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
- if (!compiled) {
- const log = gl.getShaderInfoLog(shader) ?? "";
- gl.deleteShader(shader);
- throw new Error(`Shader compile failed: ${log}`);
- }
-
- return shader;
- }
-
- private buildProgram(gl: GLContext, vertSrc: string, fragSrc: string) {
- const vert = this.compileShader(gl, gl.VERTEX_SHADER, vertSrc);
- const frag = this.compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
-
- const program = gl.createProgram();
- if (!program) {
- gl.deleteShader(vert);
- gl.deleteShader(frag);
- throw new Error("Failed to create program");
- }
-
- gl.attachShader(program, vert);
- gl.attachShader(program, frag);
- gl.linkProgram(program);
-
- const linked = gl.getProgramParameter(program, gl.LINK_STATUS);
- if (!linked) {
- const log = gl.getProgramInfoLog(program) ?? "";
- gl.deleteProgram(program);
- gl.deleteShader(vert);
- gl.deleteShader(frag);
- throw new Error(`Program link failed: ${log}`);
- }
-
- gl.deleteShader(vert);
- gl.deleteShader(frag);
- return program;
- }
-
- private writeUniformArray(
- gl: GLContext,
- location: WebGLUniformLocation,
- value: Float32Array | Int32Array,
- ): void {
- const length = value.length;
-
- if (value instanceof Int32Array) {
- const intArray = value;
- switch (length) {
- case 1:
- gl.uniform1iv(location, intArray);
- return;
- case 2:
- gl.uniform2iv(location, intArray);
- return;
- case 3:
- gl.uniform3iv(location, intArray);
- return;
- case 4:
- gl.uniform4iv(location, intArray);
- return;
- default:
- throw new Error("Unsupported integer uniform array length");
- }
- }
-
- const floatArray =
- value instanceof Float32Array ? value : new Float32Array(value);
-
- switch (length) {
- case 1:
- gl.uniform1fv(location, floatArray);
- return;
- case 2:
- gl.uniform2fv(location, floatArray);
- return;
- case 3:
- gl.uniform3fv(location, floatArray);
- return;
- case 4:
- gl.uniform4fv(location, floatArray);
- return;
- case 9:
- gl.uniformMatrix3fv(location, false, floatArray);
- return;
- case 16:
- gl.uniformMatrix4fv(location, false, floatArray);
- return;
- default:
- throw new Error("Unsupported float uniform array length");
- }
- }
-
- private writeUniform(
- gl: GLContext,
- location: WebGLUniformLocation,
- value: GLUniformValue,
- textureUnit?: number,
- ) {
- if (textureUnit !== undefined) {
- gl.uniform1i(location, textureUnit);
- return;
- }
-
- if (typeof value === "number") {
- gl.uniform1f(location, value);
- return;
- }
-
- if (typeof value === "boolean") {
- gl.uniform1i(location, value ? 1 : 0);
- return;
- }
-
- if (Array.isArray(value)) {
- this.writeUniformArray(gl, location, new Float32Array(value));
- return;
- }
-
- if (value instanceof Float32Array || value instanceof Int32Array) {
- this.writeUniformArray(gl, location, value);
- return;
- }
-
- throw new Error("Unsupported uniform value");
- }
-
- private applyUniforms(props: Props) {
- const gl = this.gl;
-
- let textureUnit = 0;
- for (const uniform of Object.values(this.uniforms)) {
- const value =
- typeof uniform.value === "function"
- ? uniform.value(props)
- : uniform.value;
-
- if (value instanceof GLTexture) {
- value.bind(textureUnit);
- gl.uniform1i(uniform.location, textureUnit);
- textureUnit += 1;
- continue;
- }
-
- // Write uniform value to shader
- this.writeUniform(gl, uniform.location, value);
- }
- }
-
- private writeAttribute(
- gl: GLContext,
- location: number,
- attribute: GLAttribute,
- ) {
- if (attribute.buffer.target !== "array") {
- throw new Error("Attribute buffers must use the 'array' target");
- }
-
- const type = glMap(gl).attributeType[attribute.type ?? "float"] ?? gl.FLOAT;
- const normalized = attribute.normalized ?? false;
- const stride = attribute.stride ?? 0;
- const offset = attribute.offset ?? 0;
-
- attribute.buffer.use(() => {
- gl.enableVertexAttribArray(location);
- gl.vertexAttribPointer(
- location,
- attribute.size,
- type,
- normalized,
- stride,
- offset,
- );
- });
- }
-
- private applyAttributes(props: Props) {
- const gl = this.gl;
- for (const attribute of Object.values(this.attributes)) {
- const value =
- typeof attribute.value === "function"
- ? attribute.value(props)
- : attribute.value;
-
- const cached = this.attributeCache.get(attribute.location);
-
- if (cached === value) {
- continue;
- }
-
- this.writeAttribute(gl, attribute.location, value);
- this.attributeCache.set(attribute.location, value);
- }
- }
-
- private applyBlend() {
- if (this.blend?.enabled) {
- this.gl.enable(this.gl.BLEND);
- this.gl.blendFunc(
- glMap(this.gl).blendFactor[this.blend.srcFactor ?? "one"],
- glMap(this.gl).blendFactor[this.blend.dstFactor ?? "zero"],
- );
- this.gl.blendEquation(
- glMap(this.gl).blendEquation[this.blend.equation ?? "add"],
- );
- } else {
- this.gl.disable(this.gl.BLEND);
- }
- }
-
- private drawElements(elements: GLBuffer) {
- if (elements.target !== "element") {
- throw new Error("Indexed draws require an element buffer");
- }
-
- const mode = glMap(this.gl).primitive[this.primitive];
- const type = glMap(this.gl).indexType[this.indexType ?? "uint16"];
-
- elements.use(() => {
- this.gl.drawElements(mode, this.count ?? 4, type, this.offset ?? 0);
- });
- }
-
- private drawArrays() {
- const mode = glMap(this.gl).primitive[this.primitive];
- this.gl.drawArrays(mode, this.offset ?? 0, this.count ?? 4);
- }
-
- use(fn: () => void) {
- const gl = this.gl;
- const previous = gl.getParameter(gl.CURRENT_PROGRAM);
- if (previous === this.handle) {
- fn();
- return;
- }
-
- gl.useProgram(this.handle);
- try {
- fn();
- } finally {
- gl.useProgram(previous);
- }
- }
-
- draw(props: Props = {} as Props) {
- this.use(() => {
- this.applyBlend();
- this.applyUniforms(props);
- this.applyAttributes(props);
-
- if (this.elements) {
- this.drawElements(this.elements);
- } else {
- this.drawArrays();
- }
- });
- }
-
- dispose() {
- this.gl.deleteProgram(this.handle);
- }
-}
-
-// Renderer
-export type GLRendererParams = {
- context?: GLContext;
- canvas?: HTMLCanvasElement;
- attributes?: WebGLContextAttributes;
-};
-
-export class GLRenderer {
- gl: GLContext;
- programs: WeakMap;
-
- constructor(params: GLRendererParams = {}) {
- const canvas = params.canvas ?? document.createElement("canvas");
- const context =
- params.context ??
- canvas.getContext("webgl2", {
- antialias: true,
- alpha: true,
- preserveDrawingBuffer: true,
- depth: false,
- ...params.attributes,
- });
-
- if (!context) {
- throw new Error("Failed to create WebGL context");
- }
-
- this.gl = context;
- this.programs = new WeakMap();
- }
-
- get canvas() {
- return this.gl.canvas as HTMLCanvasElement;
- }
-
- resize(width: number, height: number) {
- if (this.canvas.width !== width) {
- this.canvas.width = width;
- }
- if (this.canvas.height !== height) {
- this.canvas.height = height;
- }
- this.gl.viewport(0, 0, width, height);
- }
-
- clear(color = [0, 0, 0, 1]) {
- const [r, g, b, a] = color;
- this.gl.clearColor(r, g, b, a);
- this.gl.clear(this.gl.COLOR_BUFFER_BIT);
- }
-
- program(definition: GLProgramDefinition) {
- const cached = this.programs.get(definition as GLProgramDefinition);
- if (cached) {
- return cached;
- }
- const program = new GLProgram(this.gl, definition);
- this.programs.set(definition as GLProgramDefinition, program as GLProgram);
- return program as GLProgram;
- }
-
- texture(params: Partial = {}) {
- return new GLTexture(this.gl, params);
- }
-
- framebuffer(texture?: GLTexture): GLFramebuffer {
- if (!texture) {
- texture = this.texture({
- width: this.canvas.width,
- height: this.canvas.height,
- });
- }
- return new GLFramebuffer(this.gl, texture);
- }
-
- buffer(params: Partial = {}) {
- return new GLBuffer(this.gl, params);
- }
-
- dispose() {
- for (const program of Object.values(this.programs)) {
- program.dispose();
- }
- this.programs = new WeakMap();
- }
-}
diff --git a/src/gl/framebuffer.ts b/src/gl/framebuffer.ts
new file mode 100644
index 0000000..33881c3
--- /dev/null
+++ b/src/gl/framebuffer.ts
@@ -0,0 +1,35 @@
+import { GLContext, GLTexture } from "./texture";
+
+// Renders into a texture. The renderer owns both; this is just the binding.
+export class GLFramebuffer {
+ readonly gl: GLContext;
+ readonly texture: GLTexture;
+ readonly handle: WebGLFramebuffer;
+
+ constructor(gl: GLContext, texture: GLTexture) {
+ this.gl = gl;
+ this.texture = texture;
+ this.handle = gl.createFramebuffer();
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.handle);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ texture.handle,
+ 0,
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ }
+
+ use(fn: () => void) {
+ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.handle);
+ this.gl.viewport(0, 0, this.texture.width, this.texture.height);
+ fn();
+ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
+ }
+
+ dispose() {
+ this.gl.deleteFramebuffer(this.handle);
+ }
+}
diff --git a/src/gl/index.ts b/src/gl/index.ts
new file mode 100644
index 0000000..7ef9d01
--- /dev/null
+++ b/src/gl/index.ts
@@ -0,0 +1,4 @@
+export * from "./framebuffer";
+export * from "./program";
+export * from "./renderer";
+export * from "./texture";
diff --git a/src/gl/program.ts b/src/gl/program.ts
new file mode 100644
index 0000000..634e485
--- /dev/null
+++ b/src/gl/program.ts
@@ -0,0 +1,204 @@
+import { GLContext, GLTexture } from "./texture";
+
+export type GLUniformValue =
+ | number
+ | boolean
+ | readonly number[]
+ | Float32Array
+ | GLTexture;
+
+export type GLUniforms = Record<
+ string,
+ GLUniformValue | ((props: Props) => GLUniformValue)
+>;
+
+// Every draw is a full-screen quad, so only the fragment shader and its
+// uniforms vary between programs.
+export type GLProgramDefinition = {
+ frag: string;
+ uniforms?: GLUniforms;
+};
+
+type GLProgramUniform = {
+ location: WebGLUniformLocation;
+ type: number;
+ value: GLUniformValue | ((props: Props) => GLUniformValue);
+};
+
+const VERT = /* glsl */ `
+ precision mediump float;
+ attribute vec2 position;
+ varying vec2 uv;
+ void main() {
+ uv = position * 0.5 + 0.5;
+ gl_Position = vec4(position, 0.0, 1.0);
+ }
+`;
+
+const quadBuffers = new WeakMap();
+
+function quadBuffer(gl: GLContext) {
+ let buffer = quadBuffers.get(gl);
+ if (!buffer) {
+ buffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
+ gl.STATIC_DRAW,
+ );
+ quadBuffers.set(gl, buffer);
+ }
+ return buffer;
+}
+
+function compileShader(gl: GLContext, type: number, source: string) {
+ const shader = gl.createShader(type);
+ if (!shader) {
+ throw new Error("Failed to create shader");
+ }
+
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const log = gl.getShaderInfoLog(shader) ?? "";
+ gl.deleteShader(shader);
+ throw new Error(`Shader compile failed: ${log}`);
+ }
+
+ return shader;
+}
+
+function linkProgram(gl: GLContext, fragSrc: string) {
+ const vert = compileShader(gl, gl.VERTEX_SHADER, VERT);
+ const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
+
+ const program = gl.createProgram();
+ gl.attachShader(program, vert);
+ gl.attachShader(program, frag);
+ gl.linkProgram(program);
+ gl.deleteShader(vert);
+ gl.deleteShader(frag);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ const log = gl.getProgramInfoLog(program) ?? "";
+ gl.deleteProgram(program);
+ throw new Error(`Program link failed: ${log}`);
+ }
+
+ return program;
+}
+
+// The shader is the source of truth for how a uniform must be written; a JS
+// array alone cannot tell a mat3 from a float[9].
+function declaredTypes(gl: GLContext, program: WebGLProgram) {
+ const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
+ const types = new Map();
+
+ for (let i = 0; i < count; i++) {
+ const info = gl.getActiveUniform(program, i);
+ if (info) {
+ types.set(info.name.replace("[0]", ""), info.type);
+ }
+ }
+
+ return types;
+}
+
+export class GLProgram {
+ private readonly position: number;
+ private readonly uniforms: GLProgramUniform[] = [];
+ private readonly handle: WebGLProgram;
+
+ constructor(
+ readonly gl: GLContext,
+ definition: GLProgramDefinition,
+ ) {
+ this.handle = linkProgram(gl, definition.frag);
+ this.position = gl.getAttribLocation(this.handle, "position");
+
+ const types = declaredTypes(gl, this.handle);
+
+ for (const [name, value] of Object.entries(definition.uniforms ?? {})) {
+ const location = gl.getUniformLocation(this.handle, name);
+ const type = types.get(name);
+ if (!location || type === undefined) {
+ throw new Error(`Uniform not found: ${name}`);
+ }
+ this.uniforms.push({ location, type, value });
+ }
+ }
+
+ private writeUniform(
+ location: WebGLUniformLocation,
+ type: number,
+ value: Exclude,
+ ) {
+ const gl = this.gl;
+
+ if (typeof value === "number") {
+ return gl.uniform1f(location, value);
+ }
+
+ if (typeof value === "boolean") {
+ return gl.uniform1i(location, value ? 1 : 0);
+ }
+
+ const array =
+ value instanceof Float32Array ? value : new Float32Array(value);
+
+ switch (type) {
+ case gl.FLOAT:
+ return gl.uniform1fv(location, array);
+ case gl.FLOAT_VEC2:
+ return gl.uniform2fv(location, array);
+ case gl.FLOAT_VEC3:
+ return gl.uniform3fv(location, array);
+ case gl.FLOAT_VEC4:
+ return gl.uniform4fv(location, array);
+ case gl.FLOAT_MAT3:
+ return gl.uniformMatrix3fv(location, false, array);
+ case gl.FLOAT_MAT4:
+ return gl.uniformMatrix4fv(location, false, array);
+ default:
+ throw new Error(`Unsupported uniform type: ${type}`);
+ }
+ }
+
+ private applyUniforms(props: Props) {
+ let textureUnit = 0;
+
+ for (const uniform of this.uniforms) {
+ const value =
+ typeof uniform.value === "function"
+ ? uniform.value(props)
+ : uniform.value;
+
+ if (value instanceof GLTexture) {
+ value.bind(textureUnit);
+ this.gl.uniform1i(uniform.location, textureUnit);
+ textureUnit += 1;
+ continue;
+ }
+
+ this.writeUniform(uniform.location, uniform.type, value);
+ }
+ }
+
+ draw(props: Props = {} as Props) {
+ const gl = this.gl;
+
+ gl.useProgram(this.handle);
+ this.applyUniforms(props);
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer(gl));
+ gl.enableVertexAttribArray(this.position);
+ gl.vertexAttribPointer(this.position, 2, gl.FLOAT, false, 0, 0);
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
+ }
+
+ dispose() {
+ this.gl.deleteProgram(this.handle);
+ }
+}
diff --git a/src/gl/renderer.ts b/src/gl/renderer.ts
new file mode 100644
index 0000000..7c38553
--- /dev/null
+++ b/src/gl/renderer.ts
@@ -0,0 +1,130 @@
+import { GLFramebuffer } from "./framebuffer";
+import { GLProgram, GLProgramDefinition } from "./program";
+import { GLContext, GLTexture, GLTextureParams } from "./texture";
+
+export type GLRendererParams = {
+ context?: GLContext;
+ canvas?: HTMLCanvasElement;
+ attributes?: WebGLContextAttributes;
+};
+
+// Owns the WebGL context and everything allocated from it.
+export class GLRenderer {
+ readonly gl: GLContext;
+ private programs = new Map();
+ private textures = new Set();
+ private scratch?: [GLFramebuffer, GLFramebuffer];
+ private readback?: WebGLFramebuffer;
+
+ constructor(params: GLRendererParams = {}) {
+ const canvas = params.canvas ?? document.createElement("canvas");
+ const context =
+ params.context ??
+ canvas.getContext("webgl2", {
+ alpha: true,
+ // Shaders work on straight alpha, so the canvas must not premultiply.
+ premultipliedAlpha: false,
+ preserveDrawingBuffer: true,
+ antialias: false,
+ depth: false,
+ ...params.attributes,
+ });
+
+ if (!context) {
+ throw new Error("Failed to create WebGL context");
+ }
+
+ this.gl = context;
+ }
+
+ get canvas() {
+ return this.gl.canvas as HTMLCanvasElement;
+ }
+
+ resize(width: number, height: number) {
+ if (this.canvas.width !== width) {
+ this.canvas.width = width;
+ }
+ if (this.canvas.height !== height) {
+ this.canvas.height = height;
+ }
+ this.gl.viewport(0, 0, width, height);
+ }
+
+ // Programs are cached per definition, so operations can share one instance.
+ program(definition: GLProgramDefinition) {
+ const cached = this.programs.get(definition as GLProgramDefinition);
+ if (cached) {
+ return cached as GLProgram;
+ }
+
+ const program = new GLProgram(this.gl, definition);
+ this.programs.set(definition as GLProgramDefinition, program as GLProgram);
+ return program;
+ }
+
+ texture(params: Partial = {}) {
+ const texture = new GLTexture(this.gl, params);
+ this.textures.add(texture);
+ return texture;
+ }
+
+ framebuffer(texture = this.texture()) {
+ return new GLFramebuffer(this.gl, texture);
+ }
+
+ // Reusable framebuffer pair for multi-pass ping-pong rendering.
+ pingpong(): [GLFramebuffer, GLFramebuffer] {
+ this.scratch ??= [this.framebuffer(), this.framebuffer()];
+ return this.scratch;
+ }
+
+ // Bottom-up RGBA rows, as WebGL stores them. Stalls the GPU pipeline.
+ readPixels(texture: GLTexture): Uint8ClampedArray {
+ const gl = this.gl;
+ const { width, height } = texture;
+ const pixels = new Uint8ClampedArray(width * height * 4);
+
+ this.readback ??= gl.createFramebuffer();
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.readback);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ texture.handle,
+ 0,
+ );
+ gl.readPixels(
+ 0,
+ 0,
+ width,
+ height,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ new Uint8Array(pixels.buffer),
+ );
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ return pixels;
+ }
+
+ dispose() {
+ for (const program of this.programs.values()) {
+ program.dispose();
+ }
+ this.programs.clear();
+
+ for (const texture of this.textures) {
+ texture.dispose();
+ }
+ this.textures.clear();
+
+ this.scratch?.forEach((framebuffer) => framebuffer.dispose());
+ this.scratch = undefined;
+
+ if (this.readback) {
+ this.gl.deleteFramebuffer(this.readback);
+ this.readback = undefined;
+ }
+ }
+}
diff --git a/src/gl/texture.ts b/src/gl/texture.ts
new file mode 100644
index 0000000..c1bea01
--- /dev/null
+++ b/src/gl/texture.ts
@@ -0,0 +1,145 @@
+export type GLContext = WebGLRenderingContext | WebGL2RenderingContext;
+
+// Human-readable names for the WebGL constants this library uses.
+export const glMap = (gl: GLContext) => ({
+ format: {
+ rgba: gl.RGBA,
+ rgb: gl.RGB,
+ alpha: gl.ALPHA,
+ luminance: gl.LUMINANCE,
+ },
+ type: {
+ uint8: gl.UNSIGNED_BYTE,
+ float: gl.FLOAT,
+ },
+ wrap: {
+ clamp: gl.CLAMP_TO_EDGE,
+ repeat: gl.REPEAT,
+ mirror: gl.MIRRORED_REPEAT,
+ },
+ filter: {
+ nearest: gl.NEAREST,
+ linear: gl.LINEAR,
+ },
+});
+
+export type GLMap = ReturnType;
+
+export type GLTextureSource = TexImageSource | ArrayBufferView | null;
+
+export type GLTextureParams = {
+ width: number;
+ height: number;
+ data: GLTextureSource;
+ format: keyof GLMap["format"];
+ type: keyof GLMap["type"];
+ wrapS: keyof GLMap["wrap"];
+ wrapT: keyof GLMap["wrap"];
+ minFilter: keyof GLMap["filter"];
+ magFilter: keyof GLMap["filter"];
+};
+
+export class GLTexture {
+ readonly gl: GLContext;
+ readonly handle: WebGLTexture;
+
+ params: GLTextureParams = {
+ width: 1,
+ height: 1,
+ data: null,
+ format: "rgba",
+ type: "uint8",
+ wrapS: "clamp",
+ wrapT: "clamp",
+ minFilter: "linear",
+ magFilter: "linear",
+ };
+
+ constructor(gl: GLContext, params: Partial = {}) {
+ const handle = gl.createTexture();
+ if (!handle) {
+ throw new Error("Failed to create texture");
+ }
+
+ this.gl = gl;
+ this.handle = handle;
+ this.update(params);
+ }
+
+ get width() {
+ return this.params.width;
+ }
+
+ get height() {
+ return this.params.height;
+ }
+
+ bind(unit = 0) {
+ this.gl.activeTexture(this.gl.TEXTURE0 + unit);
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.handle);
+ }
+
+ update(params: Partial = {}) {
+ const gl = this.gl;
+ const map = glMap(gl);
+
+ this.params = { ...this.params, ...params };
+
+ const format = map.format[this.params.format];
+ const type = map.type[this.params.type];
+ const data = this.params.data;
+
+ gl.bindTexture(gl.TEXTURE_2D, this.handle);
+ gl.texParameteri(
+ gl.TEXTURE_2D,
+ gl.TEXTURE_MIN_FILTER,
+ map.filter[this.params.minFilter],
+ );
+ gl.texParameteri(
+ gl.TEXTURE_2D,
+ gl.TEXTURE_MAG_FILTER,
+ map.filter[this.params.magFilter],
+ );
+ gl.texParameteri(
+ gl.TEXTURE_2D,
+ gl.TEXTURE_WRAP_S,
+ map.wrap[this.params.wrapS],
+ );
+ gl.texParameteri(
+ gl.TEXTURE_2D,
+ gl.TEXTURE_WRAP_T,
+ map.wrap[this.params.wrapT],
+ );
+
+ if (data && !ArrayBuffer.isView(data)) {
+ gl.texImage2D(gl.TEXTURE_2D, 0, format, format, type, data);
+ return;
+ }
+
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ format,
+ this.width,
+ this.height,
+ 0,
+ format,
+ type,
+ data,
+ );
+ }
+
+ resize(width: number, height: number) {
+ if (width <= 0 || height <= 0) {
+ throw new Error("Texture width and height must be positive");
+ }
+ if (width === this.width && height === this.height) {
+ return;
+ }
+ this.update({ width, height, data: null });
+ }
+
+ dispose() {
+ this.gl.deleteTexture(this.handle);
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 8d119de..837f006 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1,20 @@
-export * from "./core";
+export { SharpGPU, type LinearInput } from "./core";
+
+export type {
+ ExtendParams,
+ Fit,
+ Gravity,
+ Rect,
+ ResizeParams,
+ Size,
+} from "./geometry";
+export type { ImageInput } from "./input";
+export type { Channel } from "./operations/color";
+export type { BlendMode, CompositeParams } from "./operations/composite";
+export type { ConvolveParams } from "./operations/convolve";
+export type { LUTInput } from "./operations/lut";
+export type { ModulateParams } from "./operations/modulate";
+export type { Vec2, Vec3, Vec4 } from "./utils/vector";
+
+// Escape hatch for sharing a WebGL context across pipelines.
+export { GLRenderer } from "./gl";
diff --git a/src/input.ts b/src/input.ts
new file mode 100644
index 0000000..9bd543b
--- /dev/null
+++ b/src/input.ts
@@ -0,0 +1,32 @@
+export type ImageInput =
+ | string
+ | URL
+ | Blob
+ | ImageData
+ | ImageBitmap
+ | HTMLImageElement
+ | HTMLCanvasElement
+ | OffscreenCanvas;
+
+// WebGL ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap uploads, so orientation and
+// alpha are fixed here: textures are bottom-up with straight (non-premultiplied)
+// alpha, matching what the shaders expect.
+const TEXTURE_ORIENTATION: ImageBitmapOptions = {
+ imageOrientation: "flipY",
+ premultiplyAlpha: "none",
+};
+
+// Normalizes every accepted input to a single internal shape. `createImageBitmap`
+// decodes off the main thread and, unlike `img.decode()`, resolves for images
+// that are never attached to the document.
+export async function decodeImage(input: ImageInput): Promise {
+ if (typeof input === "string" || input instanceof URL) {
+ const response = await fetch(input);
+ if (!response.ok) {
+ throw new Error(`Failed to load image: ${response.status} ${input}`);
+ }
+ return createImageBitmap(await response.blob(), TEXTURE_ORIENTATION);
+ }
+
+ return createImageBitmap(input, TEXTURE_ORIENTATION);
+}
diff --git a/src/operations/base.ts b/src/operations/base.ts
index c4e8832..43471e7 100644
--- a/src/operations/base.ts
+++ b/src/operations/base.ts
@@ -5,47 +5,61 @@ import {
GLFramebuffer,
} from "../gl";
-import { COPY } from "../programs";
-
export type OperationContext = {
renderer: GLRenderer;
- source?: GLTexture;
+ source: GLTexture;
target: GLFramebuffer;
};
-export abstract class BaseOperation {
+export abstract class Operation {
+ // Async work (decoding, uploads) happens here so run() can stay synchronous.
+ prepare?(renderer: GLRenderer): Promise;
abstract run(ctx: OperationContext): void;
}
-export class ProgramOperation extends BaseOperation {
- definition: GLProgramDefinition;
-
- constructor(definition: GLProgramDefinition) {
+// Draws a full-screen program into the target. The previous pipeline texture
+// is bound as `source` unless the params provide their own.
+export class ShaderOperation extends Operation {
+ constructor(
+ readonly definition: GLProgramDefinition,
+ readonly params: Params,
+ ) {
super();
- this.definition = definition;
- }
-
- getProps(_ctx: OperationContext): Props {
- return {} as Props;
}
run(ctx: OperationContext) {
const program = ctx.renderer.program(this.definition);
ctx.target.use(() => {
- program.draw(this.getProps(ctx));
+ program.draw({ source: ctx.source, ...this.params });
});
}
}
-export class CopyOperation extends ProgramOperation<{ source: GLTexture }> {
- source: GLTexture;
-
- constructor(source: GLTexture) {
- super(COPY);
- this.source = source;
+// Seeds the pipeline: sizes the target to the input texture and copies it.
+export class InputOperation extends Operation {
+ constructor(private readonly texture: GLTexture) {
+ super();
}
- getProps() {
- return { source: this.source };
+ run(ctx: OperationContext) {
+ ctx.target.texture.resize(this.texture.width, this.texture.height);
+ ctx.target.use(() => {
+ ctx.renderer.program(COPY).draw({ source: this.texture });
+ });
}
}
+
+export const COPY: GLProgramDefinition<{ source: GLTexture }> = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ varying vec2 uv;
+
+ void main() {
+ gl_FragColor = texture2D(source, uv);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ },
+};
diff --git a/src/operations/blur.ts b/src/operations/blur.ts
index eabde16..5a2fa8d 100644
--- a/src/operations/blur.ts
+++ b/src/operations/blur.ts
@@ -1,26 +1,27 @@
import { GLProgramDefinition, GLTexture } from "../gl";
-import { OperationContext, ProgramOperation } from "./base";
-export type BlurProps = {
+import { Vec2 } from "../utils/vector";
+
+export type BlurUniforms = {
source: GLTexture;
- radius?: number;
- direction?: [number, number];
+ radius: number;
+ direction: Vec2;
};
-const blur: GLProgramDefinition = {
+// Separable gaussian blur: apply once per axis via `direction`.
+export const BLUR: GLProgramDefinition = {
frag: /* glsl */ `
precision mediump float;
varying vec2 uv;
- uniform sampler2D src;
- uniform float width;
- uniform float height;
+ uniform sampler2D source;
+ uniform vec2 size;
uniform vec2 direction;
uniform float radius;
#define MAX_RADIUS 32
void main() {
- vec2 texel = direction / vec2(width, height);
+ vec2 texel = direction / size;
vec4 color = vec4(0.0);
float total = 0.0;
@@ -31,7 +32,7 @@ const blur: GLProgramDefinition = {
if (i < -radiusInt || i > radiusInt) continue;
float fi = float(i);
float w = exp(-0.5 * (fi * fi) / (r * r + 0.0001));
- color += texture2D(src, uv + texel * fi) * w;
+ color += texture2D(source, uv + texel * fi) * w;
total += w;
}
@@ -39,48 +40,9 @@ const blur: GLProgramDefinition = {
}
`,
uniforms: {
- src: (props) => props.source,
- width: (props) => props.source.width,
- height: (props) => props.source.height,
- direction: (props) => props.direction ?? [1, 0],
- radius: (props) => props.radius ?? 0,
+ source: (props) => props.source,
+ size: (props) => [props.source.width, props.source.height],
+ direction: (props) => props.direction,
+ radius: (props) => props.radius,
},
};
-
-export type BlurOperationProps = {
- radius?: number;
- direction?: [number, number];
-};
-
-export class BlurOperation extends ProgramOperation {
- radius: number;
- direction: [number, number];
-
- constructor(props: BlurOperationProps = {}) {
- super(blur);
- this.radius = props.radius ?? 0;
- this.direction = props.direction ?? [0, 1];
- }
-
- run(ctx: OperationContext) {
- if (this.radius === 0) {
- return;
- }
-
- const source = ctx.source;
-
- if (!source) {
- throw new Error("Source texture is required");
- }
-
- const program = ctx.renderer.program(blur);
-
- ctx.target.use(() => {
- program.draw({
- source,
- radius: this.radius,
- direction: this.direction,
- });
- });
- }
-}
diff --git a/src/operations/color.ts b/src/operations/color.ts
index e82c99a..5800d53 100644
--- a/src/operations/color.ts
+++ b/src/operations/color.ts
@@ -1,15 +1,106 @@
-import { COLOR } from "../programs";
-import { ProgramOperation } from "./base";
+import { GLProgramDefinition, GLTexture } from "../gl";
+import { Mat3 } from "../geometry";
import { Vec4 } from "../utils/vector";
-export class ColorOperation extends ProgramOperation<{ color: Vec4 }> {
- color: Vec4;
- constructor(color: Vec4) {
- super(COLOR);
- this.color = color;
- }
-
- getProps() {
- return { color: this.color };
- }
-}
+export type Channel = "red" | "green" | "blue" | "alpha";
+
+export const CHANNEL_MASKS: Record = {
+ red: [1, 0, 0, 0],
+ green: [0, 1, 0, 0],
+ blue: [0, 0, 1, 0],
+ alpha: [0, 0, 0, 1],
+};
+
+// BT.709 luma, matching sharp's greyscale conversion.
+export const LUMA: Mat3 = [
+ 0.2126, 0.2126, 0.2126, 0.7152, 0.7152, 0.7152, 0.0722, 0.0722, 0.0722,
+];
+
+export const RECOMB: GLProgramDefinition<{ source: GLTexture; matrix: Mat3 }> =
+ {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform mat3 matrix;
+ varying vec2 uv;
+
+ void main() {
+ vec4 color = texture2D(source, uv);
+ gl_FragColor = vec4(clamp(matrix * color.rgb, 0.0, 1.0), color.a);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ matrix: (props) => props.matrix,
+ },
+ };
+
+export const THRESHOLD: GLProgramDefinition<{
+ source: GLTexture;
+ threshold: number;
+ perChannel: boolean;
+}> = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform float threshold;
+ uniform bool perChannel;
+ varying vec2 uv;
+
+ const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
+
+ void main() {
+ vec4 color = texture2D(source, uv);
+ vec3 value = perChannel ? color.rgb : vec3(dot(color.rgb, LUMA));
+ gl_FragColor = vec4(step(vec3(threshold), value), color.a);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ threshold: (props) => props.threshold,
+ perChannel: (props) => props.perChannel,
+ },
+};
+
+// Isolates one channel as a greyscale image, as sharp's extractChannel does.
+export const EXTRACT_CHANNEL: GLProgramDefinition<{
+ source: GLTexture;
+ mask: Vec4;
+}> = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform vec4 mask;
+ varying vec2 uv;
+
+ void main() {
+ float value = dot(texture2D(source, uv), mask);
+ gl_FragColor = vec4(vec3(value), 1.0);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ mask: (props) => props.mask,
+ },
+};
+
+export const FLATTEN: GLProgramDefinition<{
+ source: GLTexture;
+ background: Vec4;
+}> = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform vec4 background;
+ varying vec2 uv;
+
+ void main() {
+ vec4 color = texture2D(source, uv);
+ gl_FragColor = vec4(mix(background.rgb, color.rgb, color.a), 1.0);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ background: (props) => props.background,
+ },
+};
diff --git a/src/operations/composite.ts b/src/operations/composite.ts
new file mode 100644
index 0000000..aa7cd88
--- /dev/null
+++ b/src/operations/composite.ts
@@ -0,0 +1,150 @@
+import { GLProgramDefinition, GLRenderer, GLTexture } from "../gl";
+import { anchor, Gravity, Mat3, place, Size } from "../geometry";
+import { decodeImage, ImageInput } from "../input";
+import { Operation, OperationContext } from "./base";
+
+export type BlendMode =
+ | "over"
+ | "multiply"
+ | "screen"
+ | "overlay"
+ | "darken"
+ | "lighten"
+ | "difference"
+ | "exclusion";
+
+const BLEND_MODES: BlendMode[] = [
+ "over",
+ "multiply",
+ "screen",
+ "overlay",
+ "darken",
+ "lighten",
+ "difference",
+ "exclusion",
+];
+
+export type CompositeParams = {
+ input: ImageInput;
+ blend?: BlendMode;
+ gravity?: Gravity;
+ top?: number;
+ left?: number;
+};
+
+type CompositeUniforms = {
+ source: GLTexture;
+ overlay: GLTexture;
+ transform: Mat3;
+ mode: number;
+};
+
+// Separable blending per the PDF model: colors are straight alpha, so each
+// term is weighted by its own coverage and divided back out at the end.
+const COMPOSITE: GLProgramDefinition = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform sampler2D overlay;
+ uniform mat3 transform;
+ uniform float mode;
+ varying vec2 uv;
+
+ vec3 blend(vec3 cb, vec3 cs) {
+ if (mode == 1.0) return cb * cs;
+ if (mode == 2.0) return cb + cs - cb * cs;
+ if (mode == 3.0) {
+ vec3 low = 2.0 * cb * cs;
+ vec3 high = 1.0 - 2.0 * (1.0 - cb) * (1.0 - cs);
+ return mix(low, high, step(vec3(0.5), cb));
+ }
+ if (mode == 4.0) return min(cb, cs);
+ if (mode == 5.0) return max(cb, cs);
+ if (mode == 6.0) return abs(cb - cs);
+ if (mode == 7.0) return cb + cs - 2.0 * cb * cs;
+ return cs;
+ }
+
+ void main() {
+ vec4 base = texture2D(source, uv);
+
+ vec2 ouv = (transform * vec3(uv, 1.0)).xy;
+ vec2 inside = step(vec2(0.0), ouv) * step(ouv, vec2(1.0));
+ vec4 top = texture2D(overlay, ouv) * (inside.x * inside.y);
+
+ float alpha = top.a + base.a * (1.0 - top.a);
+ if (alpha <= 0.0) {
+ gl_FragColor = vec4(0.0);
+ return;
+ }
+
+ vec3 rgb =
+ (1.0 - top.a) * base.a * base.rgb +
+ (1.0 - base.a) * top.a * top.rgb +
+ top.a * base.a * blend(base.rgb, top.rgb);
+
+ gl_FragColor = vec4(clamp(rgb / alpha, 0.0, 1.0), alpha);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ overlay: (props) => props.overlay,
+ transform: (props) => props.transform,
+ mode: (props) => props.mode,
+ },
+};
+
+// Explicit top/left wins; otherwise the overlay is anchored by gravity.
+export function placement(base: Size, overlay: Size, params: CompositeParams) {
+ const [alignX, alignY] = anchor(params.gravity);
+ return {
+ left: params.left ?? (base.width - overlay.width) * alignX,
+ top: params.top ?? (base.height - overlay.height) * alignY,
+ ...overlay,
+ };
+}
+
+export class CompositeOperation extends Operation {
+ private texture?: GLTexture;
+
+ constructor(private readonly params: CompositeParams) {
+ super();
+ }
+
+ async prepare(renderer: GLRenderer) {
+ if (this.texture) {
+ return;
+ }
+ const image = await decodeImage(this.params.input);
+ this.texture = renderer.texture({
+ width: image.width,
+ height: image.height,
+ data: image,
+ });
+ }
+
+ run(ctx: OperationContext) {
+ const overlay = this.texture;
+ if (!overlay) {
+ throw new Error("Composite input was not prepared");
+ }
+
+ const base = { width: ctx.source.width, height: ctx.source.height };
+ const size = { width: overlay.width, height: overlay.height };
+ const transform = place(
+ size,
+ base,
+ { left: 0, top: 0, ...size },
+ placement(base, size, this.params),
+ );
+
+ ctx.target.use(() => {
+ ctx.renderer.program(COMPOSITE).draw({
+ source: ctx.source,
+ overlay,
+ transform,
+ mode: BLEND_MODES.indexOf(this.params.blend ?? "over"),
+ });
+ });
+ }
+}
diff --git a/src/operations/convolve.ts b/src/operations/convolve.ts
new file mode 100644
index 0000000..14d23ff
--- /dev/null
+++ b/src/operations/convolve.ts
@@ -0,0 +1,92 @@
+import { GLProgramDefinition, GLTexture } from "../gl";
+import { Vec2 } from "../utils/vector";
+
+export type ConvolveParams = {
+ width: number;
+ height: number;
+ kernel: number[];
+ scale?: number;
+ offset?: number;
+};
+
+export type ConvolveUniforms = {
+ source: GLTexture;
+ kernel: Float32Array;
+ shape: Vec2;
+ scale: number;
+ offset: number;
+};
+
+const MAX_SIDE = 7;
+
+// Alpha passes through: kernels that sum to zero (edge detection) would
+// otherwise erase the image.
+export const CONVOLVE: GLProgramDefinition = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform vec2 size;
+ uniform float kernel[${MAX_SIDE * MAX_SIDE}];
+ uniform vec2 shape;
+ uniform float scale;
+ uniform float offset;
+ varying vec2 uv;
+
+ #define MAX_SIDE ${MAX_SIDE}
+
+ void main() {
+ vec2 texel = 1.0 / size;
+ vec2 center = floor(shape * 0.5);
+ vec3 sum = vec3(0.0);
+
+ for (int y = 0; y < MAX_SIDE; y++) {
+ if (float(y) >= shape.y) break;
+ for (int x = 0; x < MAX_SIDE; x++) {
+ if (float(x) >= shape.x) break;
+ vec2 delta = (vec2(float(x), float(y)) - center) * texel;
+ sum += texture2D(source, uv + delta).rgb * kernel[y * MAX_SIDE + x];
+ }
+ }
+
+ vec3 rgb = sum / scale + offset;
+ gl_FragColor = vec4(clamp(rgb, 0.0, 1.0), texture2D(source, uv).a);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ size: (props) => [props.source.width, props.source.height],
+ kernel: (props) => props.kernel,
+ shape: (props) => props.shape,
+ scale: (props) => props.scale,
+ offset: (props) => props.offset,
+ },
+};
+
+// Rows are padded to a fixed stride so the shader can index the kernel with a
+// loop expression, which GLSL ES requires.
+export function padKernel(params: ConvolveParams): Float32Array {
+ const { width, height, kernel } = params;
+
+ if (width > MAX_SIDE || height > MAX_SIDE) {
+ throw new Error(`Kernel must be at most ${MAX_SIDE}x${MAX_SIDE}`);
+ }
+ if (kernel.length !== width * height) {
+ throw new Error(`Kernel must hold ${width * height} values`);
+ }
+
+ const padded = new Float32Array(MAX_SIDE * MAX_SIDE);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ padded[y * MAX_SIDE + x] = kernel[y * width + x];
+ }
+ }
+ return padded;
+}
+
+export function kernelScale(params: ConvolveParams): number {
+ if (params.scale) {
+ return params.scale;
+ }
+ const sum = params.kernel.reduce((total, value) => total + value, 0);
+ return sum === 0 ? 1 : sum;
+}
diff --git a/src/operations/gamma.ts b/src/operations/gamma.ts
index 643810b..03d949c 100644
--- a/src/operations/gamma.ts
+++ b/src/operations/gamma.ts
@@ -1,5 +1,4 @@
import { GLTexture, GLProgramDefinition } from "../gl";
-import { ProgramOperation, OperationContext } from "./base";
import { Vec3 } from "../utils/vector";
export type GammaUniforms = {
@@ -7,7 +6,7 @@ export type GammaUniforms = {
exponent: Vec3;
};
-const gammaProgram: GLProgramDefinition = {
+export const GAMMA: GLProgramDefinition = {
frag: /* glsl */ `
precision mediump float;
uniform sampler2D source;
@@ -16,8 +15,7 @@ const gammaProgram: GLProgramDefinition = {
void main() {
vec4 color = texture2D(source, uv);
- vec3 base = max(color.rgb, vec3(0.0));
- vec3 rgb = pow(base, exponent);
+ vec3 rgb = pow(max(color.rgb, vec3(0.0)), exponent);
gl_FragColor = vec4(clamp(rgb, 0.0, 1.0), color.a);
}
`,
@@ -26,27 +24,3 @@ const gammaProgram: GLProgramDefinition = {
exponent: (props) => props.exponent,
},
};
-
-export type GammaParams = {
- exponent?: Vec3;
-};
-
-export class GammaOperation extends ProgramOperation {
- private exponent: Vec3;
-
- constructor(options: GammaParams = {}) {
- super(gammaProgram);
- this.exponent = options.exponent ?? [1, 1, 1];
- }
-
- getProps(ctx: OperationContext): GammaUniforms {
- if (!ctx.source) {
- throw new Error("Source texture is required");
- }
-
- return {
- source: ctx.source,
- exponent: this.exponent,
- };
- }
-}
diff --git a/src/operations/linear.ts b/src/operations/linear.ts
index 56983f6..af33c15 100644
--- a/src/operations/linear.ts
+++ b/src/operations/linear.ts
@@ -1,5 +1,4 @@
import { GLTexture, GLProgramDefinition } from "../gl";
-import { ProgramOperation, OperationContext } from "./base";
import { Vec4 } from "../utils/vector";
export type LinearUniforms = {
@@ -8,7 +7,7 @@ export type LinearUniforms = {
add: Vec4;
};
-const linearProgram: GLProgramDefinition = {
+export const LINEAR: GLProgramDefinition = {
frag: /* glsl */ `
precision mediump float;
uniform sampler2D source;
@@ -18,10 +17,7 @@ const linearProgram: GLProgramDefinition = {
void main() {
vec4 color = texture2D(source, uv);
- vec4 result = color * multiply + add;
- result.rgb = clamp(result.rgb, 0.0, 1.0);
- result.a = clamp(result.a, 0.0, 1.0);
- gl_FragColor = result;
+ gl_FragColor = clamp(color * multiply + add, 0.0, 1.0);
}
`,
uniforms: {
@@ -30,29 +26,3 @@ const linearProgram: GLProgramDefinition = {
add: (props) => props.add,
},
};
-
-export type LinearParams = {
- multiply?: Vec4;
- add?: Vec4;
-};
-
-export class LinearOperation extends ProgramOperation {
- params: LinearParams;
-
- constructor(params: LinearParams = {}) {
- super(linearProgram);
- this.params = params;
- }
-
- getProps(ctx: OperationContext): LinearUniforms {
- if (!ctx.source) {
- throw new Error("Source texture is required");
- }
-
- return {
- source: ctx.source,
- multiply: this.params.multiply ?? [1, 1, 1, 1],
- add: this.params.add ?? [0, 0, 0, 0],
- };
- }
-}
diff --git a/src/operations/lut.ts b/src/operations/lut.ts
index 1cc6cee..ace46e0 100644
--- a/src/operations/lut.ts
+++ b/src/operations/lut.ts
@@ -1,16 +1,15 @@
import { GLTexture, GLProgramDefinition } from "../gl";
-import { ProgramOperation, OperationContext } from "./base";
+import { Operation, OperationContext } from "./base";
-export type LUTParams = {
- lut?: number[] | ((x: number) => number);
-};
+export type LUTInput = number[] | ((x: number) => number);
-export type LUTUniforms = {
+type LUTUniforms = {
source: GLTexture;
lut: GLTexture;
};
-const lutProgram: GLProgramDefinition = {
+// Remaps luminance through a 256-entry curve, preserving chroma ratios.
+const LUT: GLProgramDefinition = {
frag: /* glsl */ `
precision mediump float;
uniform sampler2D source;
@@ -41,72 +40,41 @@ function createLerp(src: number[]) {
if (t >= 1) return src[n - 1];
const pos = t * (n - 1);
const idx = Math.floor(pos);
- const frac = pos - idx;
const a = src[idx];
const b = src[Math.min(idx + 1, n - 1)];
- return a + (b - a) * frac;
+ return a + (b - a) * (pos - idx);
};
}
-export class LUTOperation extends ProgramOperation {
- private lut?: LUTParams["lut"];
- private texture?: GLTexture;
- private lastLut?: LUTParams["lut"];
- private readonly data = new Uint8ClampedArray(256);
-
- constructor(params: LUTParams = {}) {
- super(lutProgram);
- this.lut = params.lut;
- }
-
- private ensureTexture(ctx: OperationContext) {
- if (!this.texture) {
- this.texture = ctx.renderer.texture({
- width: 256,
- height: 1,
- format: "luminance",
- data: this.data,
- minFilter: "linear",
- magFilter: "linear",
- wrapS: "clamp",
- wrapT: "clamp",
- });
- }
- return this.texture;
+export function sampleCurve(lut: LUTInput): Uint8ClampedArray {
+ const curve = typeof lut === "function" ? lut : createLerp(lut);
+ const data = new Uint8ClampedArray(256);
+ for (let i = 0; i < 256; i++) {
+ data[i] = curve(i / 255) * 255;
}
+ return data;
+}
- private updateTexture(lut: LUTParams["lut"]) {
- const target = lut ?? [0, 1];
- if (target === this.lastLut) {
- return;
- }
- this.lastLut = target;
+export class LUTOperation extends Operation {
+ private readonly data: Uint8ClampedArray;
+ private texture?: GLTexture;
- const interpolate =
- typeof target === "function" ? target : createLerp(target);
- for (let i = 0; i < 256; i++) {
- this.data[i] = interpolate(i / 255) * 255;
- }
+ constructor(lut: LUTInput) {
+ super();
+ this.data = sampleCurve(lut);
+ }
- this.texture?.update({
+ run(ctx: OperationContext) {
+ this.texture ??= ctx.renderer.texture({
+ width: 256,
+ height: 1,
+ format: "luminance",
data: this.data,
});
- }
-
- getProps(ctx: OperationContext): LUTUniforms {
- if (!ctx.source) {
- throw new Error("Source texture is required");
- }
- const texture = this.ensureTexture(ctx);
- this.updateTexture(this.lut);
-
- return {
- source: ctx.source,
- lut: texture,
- };
- }
- dispose() {
- this.texture?.dispose();
+ const lut = this.texture;
+ ctx.target.use(() => {
+ ctx.renderer.program(LUT).draw({ source: ctx.source, lut });
+ });
}
}
diff --git a/src/operations/median.ts b/src/operations/median.ts
new file mode 100644
index 0000000..92ce59c
--- /dev/null
+++ b/src/operations/median.ts
@@ -0,0 +1,44 @@
+import { GLProgramDefinition, GLTexture } from "../gl";
+
+export type MedianUniforms = {
+ source: GLTexture;
+};
+
+// The median of a 3x3 window equals the median of (max of column minimums,
+// median of column medians, min of column maximums).
+export const MEDIAN: GLProgramDefinition = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform vec2 size;
+ varying vec2 uv;
+
+ vec3 lo(vec3 a, vec3 b, vec3 c) { return min(a, min(b, c)); }
+ vec3 hi(vec3 a, vec3 b, vec3 c) { return max(a, max(b, c)); }
+ vec3 mid(vec3 a, vec3 b, vec3 c) {
+ return max(min(a, b), min(max(a, b), c));
+ }
+
+ vec3 at(float x, float y) {
+ return texture2D(source, uv + vec2(x, y) / size).rgb;
+ }
+
+ void main() {
+ vec3 a = at(-1.0, -1.0), b = at(-1.0, 0.0), c = at(-1.0, 1.0);
+ vec3 d = at(0.0, -1.0), e = at(0.0, 0.0), f = at(0.0, 1.0);
+ vec3 g = at(1.0, -1.0), h = at(1.0, 0.0), i = at(1.0, 1.0);
+
+ vec3 result = mid(
+ hi(lo(a, b, c), lo(d, e, f), lo(g, h, i)),
+ mid(mid(a, b, c), mid(d, e, f), mid(g, h, i)),
+ lo(hi(a, b, c), hi(d, e, f), hi(g, h, i))
+ );
+
+ gl_FragColor = vec4(result, texture2D(source, uv).a);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ size: (props) => [props.source.width, props.source.height],
+ },
+};
diff --git a/src/operations/modulate.ts b/src/operations/modulate.ts
index b335a74..13e94af 100644
--- a/src/operations/modulate.ts
+++ b/src/operations/modulate.ts
@@ -1,6 +1,11 @@
import { GLTexture, GLProgramDefinition } from "../gl";
-import { Vec3 } from "../utils/vector";
-import { ProgramOperation, type OperationContext } from "./base";
+
+export type ModulateParams = {
+ brightness?: number;
+ saturation?: number;
+ lightness?: number;
+ hue?: number;
+};
export type ModulateUniforms = {
source: GLTexture;
@@ -8,10 +13,9 @@ export type ModulateUniforms = {
saturation: number;
hue: number;
lightness: number;
- tint: [number, number, number];
};
-const modulateProgram: GLProgramDefinition = {
+export const MODULATE: GLProgramDefinition = {
frag: /* glsl */ `
precision mediump float;
uniform sampler2D source;
@@ -19,7 +23,6 @@ const modulateProgram: GLProgramDefinition = {
uniform float saturation;
uniform float hue;
uniform float lightness;
- uniform vec3 tint;
varying vec2 uv;
vec3 rgb2hsl(vec3 c) {
@@ -78,9 +81,7 @@ const modulateProgram: GLProgramDefinition = {
hsl.y *= saturation;
hsl.z = clamp(hsl.z + lightness, 0.0, 1.0);
- vec3 rgb = hsl2rgb(hsl);
- rgb *= brightness;
- rgb *= tint;
+ vec3 rgb = hsl2rgb(hsl) * brightness;
gl_FragColor = vec4(clamp(rgb, 0.0, 1.0), color.a);
}
@@ -91,38 +92,5 @@ const modulateProgram: GLProgramDefinition = {
saturation: (props) => props.saturation,
hue: (props) => props.hue,
lightness: (props) => props.lightness,
- tint: (props) => props.tint,
},
};
-
-export type ModulateParams = {
- brightness?: number;
- saturation?: number;
- lightness?: number;
- hue?: number;
- tint?: Vec3;
-};
-
-export class ModulateOperation extends ProgramOperation {
- params: ModulateParams;
-
- constructor(params: ModulateParams = {}) {
- super(modulateProgram);
- this.params = params;
- }
-
- getProps(ctx: OperationContext): ModulateUniforms {
- if (!ctx.source) {
- throw new Error("Source texture is required");
- }
-
- return {
- source: ctx.source,
- brightness: this.params.brightness ?? 1,
- saturation: this.params.saturation ?? 1,
- hue: this.params.hue ?? 0,
- lightness: this.params.lightness ?? 0,
- tint: this.params.tint ?? [1, 1, 1],
- };
- }
-}
diff --git a/src/operations/normalize.ts b/src/operations/normalize.ts
new file mode 100644
index 0000000..819185e
--- /dev/null
+++ b/src/operations/normalize.ts
@@ -0,0 +1,54 @@
+import { Vec4 } from "../utils/vector";
+import { LINEAR } from "./linear";
+import { Operation, OperationContext } from "./base";
+
+export type Range = {
+ min: number;
+ max: number;
+};
+
+// Luminance range over opaque pixels; fully transparent padding is ignored so
+// that extend() or rotate() do not flatten the result.
+export function luminanceRange(pixels: Uint8ClampedArray): Range {
+ let min = 255;
+ let max = 0;
+
+ for (let i = 0; i < pixels.length; i += 4) {
+ if (pixels[i + 3] === 0) {
+ continue;
+ }
+ const luma =
+ 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
+ min = Math.min(min, luma);
+ max = Math.max(max, luma);
+ }
+
+ return min > max ? { min: 0, max: 255 } : { min, max };
+}
+
+export function stretch({ min, max }: Range): { multiply: Vec4; add: Vec4 } {
+ // A flat image has no range to stretch; leave it alone.
+ if (max <= min) {
+ return { multiply: [1, 1, 1, 1], add: [0, 0, 0, 0] };
+ }
+
+ const scale = 255 / (max - min);
+ const shift = (-min / 255) * scale;
+ return {
+ multiply: [scale, scale, scale, 1],
+ add: [shift, shift, shift, 0],
+ };
+}
+
+// Stretches luminance to the full dynamic range. Reads the pipeline back to the
+// CPU, so it forces a GPU sync.
+export class NormalizeOperation extends Operation {
+ run(ctx: OperationContext) {
+ const range = luminanceRange(ctx.renderer.readPixels(ctx.source));
+ const { multiply, add } = stretch(range);
+
+ ctx.target.use(() => {
+ ctx.renderer.program(LINEAR).draw({ source: ctx.source, multiply, add });
+ });
+ }
+}
diff --git a/src/operations/resize.ts b/src/operations/resize.ts
deleted file mode 100644
index 7cdf849..0000000
--- a/src/operations/resize.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { GLTexture } from "../gl";
-
-import { COPY } from "../programs";
-import { OperationContext, ProgramOperation } from "./base";
-
-export type Size = {
- width: number;
- height: number;
-};
-
-export type ResizeParams = {
- width?: number;
- height?: number;
-};
-
-export function computeSize(srcSize: Size, params: ResizeParams) {
- if (params.width && params.height) {
- return { width: params.width, height: params.height };
- }
-
- const aspect = srcSize.width / srcSize.height;
-
- if (params.width) {
- return { width: params.width, height: params.width / aspect };
- }
-
- if (params.height) {
- return { width: params.height * aspect, height: params.height };
- }
-
- return srcSize;
-}
-
-export class ResizeOperation extends ProgramOperation<{
- source: GLTexture;
-}> {
- params: ResizeParams;
-
- constructor(params: ResizeParams) {
- super(COPY);
- this.params = params;
- }
-
- getProps(ctx: OperationContext) {
- if (!ctx.source) {
- throw new Error("Source texture is required");
- }
-
- return { source: ctx.source };
- }
-
- run(ctx: OperationContext) {
- const previous = {
- width: ctx.target.texture.width,
- height: ctx.target.texture.height,
- };
- const size = computeSize(previous, this.params);
- ctx.target.texture.resize(size.width, size.height);
- super.run(ctx);
- }
-}
diff --git a/src/operations/sample.ts b/src/operations/sample.ts
new file mode 100644
index 0000000..8000505
--- /dev/null
+++ b/src/operations/sample.ts
@@ -0,0 +1,56 @@
+import { GLProgramDefinition, GLTexture } from "../gl";
+import { Mat3, SamplePlan, Size } from "../geometry";
+import { Vec4 } from "../utils/vector";
+import { Operation, OperationContext } from "./base";
+
+type SampleUniforms = {
+ source: GLTexture;
+ transform: Mat3;
+ background: Vec4;
+};
+
+const SAMPLE: GLProgramDefinition = {
+ frag: /* glsl */ `
+ precision mediump float;
+ uniform sampler2D source;
+ uniform mat3 transform;
+ uniform vec4 background;
+ varying vec2 uv;
+
+ void main() {
+ vec2 suv = (transform * vec3(uv, 1.0)).xy;
+ vec2 inside = step(vec2(0.0), suv) * step(suv, vec2(1.0));
+ gl_FragColor = mix(background, texture2D(source, suv), inside.x * inside.y);
+ }
+ `,
+ uniforms: {
+ source: (props) => props.source,
+ transform: (props) => props.transform,
+ background: (props) => props.background,
+ },
+};
+
+// Every geometry operation is a plan: a target size plus an affine mapping from
+// target uv back to source uv. Resize, extract, extend, flip and rotate differ
+// only in the plan they produce.
+export class SampleOperation extends Operation {
+ constructor(private readonly plan: (source: Size) => SamplePlan) {
+ super();
+ }
+
+ run(ctx: OperationContext) {
+ const { size, transform, background } = this.plan({
+ width: ctx.source.width,
+ height: ctx.source.height,
+ });
+
+ ctx.target.texture.resize(size.width, size.height);
+ ctx.target.use(() => {
+ ctx.renderer.program(SAMPLE).draw({
+ source: ctx.source,
+ transform,
+ background,
+ });
+ });
+ }
+}
diff --git a/src/output.ts b/src/output.ts
new file mode 100644
index 0000000..1ecad69
--- /dev/null
+++ b/src/output.ts
@@ -0,0 +1,21 @@
+import { Size } from "./geometry";
+
+// WebGL reads rows bottom-up; ImageData expects them top-down.
+export function flipRows(pixels: Uint8ClampedArray, size: Size) {
+ const stride = size.width * 4;
+ const flipped = new Uint8ClampedArray(pixels.length);
+
+ for (let row = 0; row < size.height; row++) {
+ const from = row * stride;
+ flipped.set(
+ pixels.subarray(from, from + stride),
+ (size.height - 1 - row) * stride,
+ );
+ }
+
+ return flipped;
+}
+
+export function toImageData(pixels: Uint8ClampedArray, size: Size) {
+ return new ImageData(flipRows(pixels, size), size.width, size.height);
+}
diff --git a/src/programs.ts b/src/programs.ts
deleted file mode 100644
index 51a1bf8..0000000
--- a/src/programs.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { GLProgramDefinition, GLTexture } from "./gl";
-import { Vec4 } from "./utils/vector";
-
-export const COPY: GLProgramDefinition<{ source: GLTexture }> = {
- frag: /* glsl */ `
- precision mediump float;
- uniform sampler2D source;
- varying vec2 uv;
-
- void main() {
- gl_FragColor = texture2D(source, uv);
- }
- `,
- uniforms: {
- source: (props) => props.source,
- },
-};
-
-export const COLOR: GLProgramDefinition<{ color: Vec4 }> = {
- frag: /* glsl */ `
- precision mediump float;
- uniform vec4 color;
-
- void main() {
- gl_FragColor = color;
- }
- `,
- uniforms: {
- color: (props) => props.color,
- },
- blend: {
- enabled: true,
- srcFactor: "srcColor",
- dstFactor: "oneMinusSrcColor",
- equation: "add",
- },
-};
diff --git a/src/utils/vector.ts b/src/utils/vector.ts
index 9c3f192..2568e49 100644
--- a/src/utils/vector.ts
+++ b/src/utils/vector.ts
@@ -1,3 +1,4 @@
+export type Vec2 = [number, number];
export type Vec3 = [number, number, number];
export type Vec4 = [number, number, number, number];
@@ -6,11 +7,7 @@ export function toVec3(
fallback: number,
): Vec3 {
if (Array.isArray(value)) {
- return [
- value[0] ?? fallback,
- value[1] ?? fallback,
- value[2] ?? fallback,
- ];
+ return [value[0] ?? fallback, value[1] ?? fallback, value[2] ?? fallback];
}
if (typeof value === "number") {
@@ -27,12 +24,7 @@ export function toVec4(
): Vec4 {
if (Array.isArray(value)) {
const [x, y, z, w] = value;
- return [
- x ?? fallback,
- y ?? fallback,
- z ?? fallback,
- w ?? fallbackAlpha,
- ];
+ return [x ?? fallback, y ?? fallback, z ?? fallback, w ?? fallbackAlpha];
}
if (typeof value === "number") {
diff --git a/test/browser/harness.ts b/test/browser/harness.ts
new file mode 100644
index 0000000..4118b58
--- /dev/null
+++ b/test/browser/harness.ts
@@ -0,0 +1,115 @@
+import { SharpGPU } from "../../src";
+
+type TestFn = () => Promise;
+
+const tests: { name: string; fn: TestFn }[] = [];
+
+export function test(name: string, fn: TestFn) {
+ tests.push({ name, fn });
+}
+
+export function assert(condition: boolean, message: string) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+export function assertEqual(actual: unknown, expected: unknown, label: string) {
+ assert(
+ actual === expected,
+ `${label}: expected ${String(expected)}, got ${String(actual)}`,
+ );
+}
+
+// GPU roundtrips are lossy (uint8 β float β uint8), so compare with tolerance.
+export function expectPixel(
+ data: ImageData,
+ x: number,
+ y: number,
+ rgba: number[],
+ tolerance = 6,
+) {
+ const i = (y * data.width + x) * 4;
+ const actual = [
+ data.data[i],
+ data.data[i + 1],
+ data.data[i + 2],
+ data.data[i + 3],
+ ];
+ for (let c = 0; c < 4; c++) {
+ assert(
+ Math.abs(actual[c] - rgba[c]) <= tolerance,
+ `pixel (${x}, ${y}) channel ${c}: expected ${rgba[c]}Β±${tolerance}, got ${actual[c]}`,
+ );
+ }
+}
+
+export async function renderToImageData(image: SharpGPU): Promise {
+ const canvas = document.createElement("canvas");
+ await image.toCanvas(canvas);
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("Failed to get 2D context");
+ }
+ return ctx.getImageData(0, 0, canvas.width, canvas.height);
+}
+
+// Quadrant pattern: red top-left, green top-right, blue bottom-left,
+// white bottom-right. Makes orientation and geometry bugs obvious.
+export function patternURL(width = 64, height = 64): string {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("Failed to get 2D context");
+ }
+
+ const w = width / 2;
+ const h = height / 2;
+ ctx.fillStyle = "#ff0000";
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = "#00ff00";
+ ctx.fillRect(w, 0, w, h);
+ ctx.fillStyle = "#0000ff";
+ ctx.fillRect(0, h, w, h);
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(w, h, w, h);
+
+ return canvas.toDataURL();
+}
+
+export function solidURL(width: number, height: number, color: string): string {
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ throw new Error("Failed to get 2D context");
+ }
+
+ ctx.fillStyle = color;
+ ctx.fillRect(0, 0, width, height);
+ return canvas.toDataURL();
+}
+
+export async function run() {
+ const results: string[] = [];
+ let failed = 0;
+
+ for (const { name, fn } of tests) {
+ try {
+ await fn();
+ results.push(`PASS ${name}`);
+ } catch (error) {
+ failed += 1;
+ results.push(`FAIL ${name}: ${(error as Error).message}`);
+ }
+ }
+
+ const summary = `${tests.length - failed}/${tests.length} passed`;
+ document.title = failed === 0 ? "PASS" : `FAIL (${failed})`;
+ document.getElementById("results")!.textContent = [summary, ...results].join(
+ "\n",
+ );
+}
diff --git a/test/browser/index.html b/test/browser/index.html
new file mode 100644
index 0000000..b64bf90
--- /dev/null
+++ b/test/browser/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+ sharp-gpu browser tests
+
+
+
+ runningβ¦
+
+
+
diff --git a/test/browser/main.ts b/test/browser/main.ts
new file mode 100644
index 0000000..45890c8
--- /dev/null
+++ b/test/browser/main.ts
@@ -0,0 +1,512 @@
+import { SharpGPU } from "../../src";
+import {
+ assert,
+ assertEqual,
+ expectPixel,
+ patternURL,
+ renderToImageData,
+ run,
+ solidURL,
+ test,
+} from "./harness";
+
+const RED = [255, 0, 0, 255];
+const GREEN = [0, 255, 0, 255];
+const BLUE = [0, 0, 255, 255];
+const WHITE = [255, 255, 255, 255];
+
+test("load preserves size, colors and orientation", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image);
+
+ assertEqual(data.width, 64, "width");
+ assertEqual(data.height, 64, "height");
+ expectPixel(data, 16, 16, RED);
+ expectPixel(data, 48, 16, GREEN);
+ expectPixel(data, 16, 48, BLUE);
+ expectPixel(data, 48, 48, WHITE);
+});
+
+test("resize by width keeps aspect ratio", async () => {
+ const image = await SharpGPU.from(patternURL(64, 32));
+ const data = await renderToImageData(image.resize({ width: 32 }));
+
+ assertEqual(data.width, 32, "width");
+ assertEqual(data.height, 16, "height");
+ expectPixel(data, 8, 4, RED);
+ expectPixel(data, 24, 12, WHITE);
+});
+
+test("resize to explicit dimensions", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.resize({ width: 20, height: 40 }));
+
+ assertEqual(data.width, 20, "width");
+ assertEqual(data.height, 40, "height");
+});
+
+test("resize cover crops the longer axis", async () => {
+ const image = await SharpGPU.from(patternURL(64, 32));
+ const data = await renderToImageData(image.resize({ width: 32, height: 32 }));
+
+ assertEqual(data.width, 32, "width");
+ assertEqual(data.height, 32, "height");
+ expectPixel(data, 8, 8, RED);
+ expectPixel(data, 24, 24, WHITE);
+});
+
+test("resize contain pads with the background", async () => {
+ const image = await SharpGPU.from(patternURL(64, 32));
+ const data = await renderToImageData(
+ image.resize({
+ width: 64,
+ height: 64,
+ fit: "contain",
+ background: [0, 0, 0, 1],
+ }),
+ );
+
+ assertEqual(data.height, 64, "height");
+ expectPixel(data, 32, 4, [0, 0, 0, 255]);
+ expectPixel(data, 32, 60, [0, 0, 0, 255]);
+ expectPixel(data, 16, 24, RED);
+});
+
+test("resize inside keeps the aspect ratio without padding", async () => {
+ const image = await SharpGPU.from(patternURL(64, 32));
+ const data = await renderToImageData(
+ image.resize({ width: 64, height: 64, fit: "inside" }),
+ );
+
+ assertEqual(data.width, 64, "width");
+ assertEqual(data.height, 32, "height");
+});
+
+test("extract crops a region", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.extract({ left: 32, top: 0, width: 32, height: 32 }),
+ );
+
+ assertEqual(data.width, 32, "width");
+ assertEqual(data.height, 32, "height");
+ expectPixel(data, 16, 16, GREEN);
+});
+
+test("extend pads with the background", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.extend({ left: 10, top: 20, background: [0, 0, 0, 1] }),
+ );
+
+ assertEqual(data.width, 74, "width");
+ assertEqual(data.height, 84, "height");
+ expectPixel(data, 2, 2, [0, 0, 0, 255]);
+ // The original top-left corner now sits at (10, 20).
+ expectPixel(data, 20, 30, RED);
+});
+
+test("flip mirrors vertically", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.flip());
+ expectPixel(data, 16, 16, BLUE);
+ expectPixel(data, 48, 48, GREEN);
+});
+
+test("flop mirrors horizontally", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.flop());
+ expectPixel(data, 16, 16, GREEN);
+ expectPixel(data, 48, 48, BLUE);
+});
+
+test("rotate 90 turns clockwise", async () => {
+ const image = await SharpGPU.from(patternURL(64, 32));
+ const data = await renderToImageData(image.rotate(90));
+
+ assertEqual(data.width, 32, "width");
+ assertEqual(data.height, 64, "height");
+ // Red was top-left, so it lands top-right.
+ expectPixel(data, 24, 16, RED);
+ expectPixel(data, 8, 16, BLUE);
+});
+
+test("rotate 180 flips both axes", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.rotate(180));
+ expectPixel(data, 16, 16, WHITE);
+ expectPixel(data, 48, 48, RED);
+});
+
+test("rotate 45 grows the canvas and fills corners", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.rotate(45, [0, 0, 0, 1]));
+
+ assertEqual(data.width, 91, "width");
+ expectPixel(data, 2, 2, [0, 0, 0, 255]);
+});
+
+test("blur mixes colors across edges", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.blur(4));
+
+ // Center of the red/green boundary blends both.
+ expectPixel(data, 32, 16, [128, 128, 0, 255], 40);
+ // Quadrant centers stay close to their color.
+ expectPixel(data, 8, 8, RED, 30);
+});
+
+test("modulate brightness 0 gives black", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.modulate({ brightness: 0 }));
+ expectPixel(data, 48, 48, [0, 0, 0, 255]);
+});
+
+test("modulate hue 120 rotates red to green", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.modulate({ hue: 120 }));
+ expectPixel(data, 16, 16, GREEN);
+});
+
+test("linear multiplies and adds", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.linear(0.5, 0.25));
+ // White: 1 * 0.5 + 0.25 = 0.75.
+ expectPixel(data, 48, 48, [191, 191, 191, 255]);
+});
+
+test("gamma brightens midtones", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.linear(0.5).gamma(2.2));
+ // 0.5 ^ (1 / 2.2) β 0.73.
+ expectPixel(data, 48, 48, [186, 186, 186, 255]);
+});
+
+test("negate inverts colors", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.negate());
+ expectPixel(data, 16, 16, [0, 255, 255, 255]);
+ expectPixel(data, 48, 48, [0, 0, 0, 255]);
+});
+
+test("grayscale removes saturation", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.grayscale());
+ const i = (16 * 64 + 16) * 4;
+ const [r, g, b] = [data.data[i], data.data[i + 1], data.data[i + 2]];
+ assert(
+ Math.abs(r - g) <= 4 && Math.abs(g - b) <= 4,
+ `not gray: ${r},${g},${b}`,
+ );
+});
+
+test("tint scales channels", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.tint([1, 0.5, 0.5]));
+ expectPixel(data, 48, 48, [255, 128, 128, 255]);
+});
+
+test("lut inverts luminance", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.lut([1, 0]));
+ expectPixel(data, 48, 48, [0, 0, 0, 255]);
+});
+
+test("lut identity keeps colors", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.lut([0, 1]));
+ expectPixel(data, 48, 48, WHITE);
+ expectPixel(data, 16, 16, RED, 10);
+});
+
+test("convolve with an identity kernel keeps the image", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.convolve({
+ width: 3,
+ height: 3,
+ kernel: [0, 0, 0, 0, 1, 0, 0, 0, 0],
+ }),
+ );
+ expectPixel(data, 16, 16, RED);
+ expectPixel(data, 48, 48, WHITE);
+});
+
+test("convolve with a box kernel blurs edges", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.convolve({
+ width: 3,
+ height: 3,
+ kernel: new Array(9).fill(1),
+ }),
+ );
+ // Quadrant interiors are untouched; the boundary mixes red and green.
+ expectPixel(data, 8, 8, RED);
+ expectPixel(data, 31, 16, [170, 85, 0, 255], 20);
+});
+
+test("convolve honors offset", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.convolve({
+ width: 1,
+ height: 1,
+ kernel: [0],
+ scale: 1,
+ offset: 0.5,
+ }),
+ );
+ expectPixel(data, 16, 16, [128, 128, 128, 255]);
+});
+
+test("sharpen keeps flat areas unchanged", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.sharpen(1));
+ expectPixel(data, 8, 8, RED);
+ expectPixel(data, 40, 40, WHITE);
+});
+
+test("median removes single-pixel noise", async () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = 16;
+ canvas.height = 16;
+ const ctx = canvas.getContext("2d")!;
+ ctx.fillStyle = "#ffffff";
+ ctx.fillRect(0, 0, 16, 16);
+ ctx.fillStyle = "#000000";
+ ctx.fillRect(8, 8, 1, 1);
+
+ const image = await SharpGPU.from(canvas.toDataURL());
+ const noisy = await renderToImageData(image.clone());
+ const filtered = await renderToImageData(image.median());
+
+ expectPixel(noisy, 8, 8, [0, 0, 0, 255]);
+ expectPixel(filtered, 8, 8, WHITE);
+ expectPixel(filtered, 2, 2, WHITE);
+});
+
+test("threshold splits on luminance", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.threshold(0.5));
+ // Red luma is 0.21, white is 1.
+ expectPixel(data, 16, 16, [0, 0, 0, 255]);
+ expectPixel(data, 48, 48, WHITE);
+});
+
+test("threshold per channel keeps channels independent", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.threshold(0.5, { grayscale: false }),
+ );
+ expectPixel(data, 16, 16, RED);
+});
+
+test("recomb swaps channels", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.recomb([
+ [0, 1, 0],
+ [0, 0, 1],
+ [1, 0, 0],
+ ]),
+ );
+ // Output red takes green and output blue takes red, so red rotates to blue
+ // and green rotates to red.
+ expectPixel(data, 16, 16, BLUE);
+ expectPixel(data, 48, 16, RED);
+});
+
+test("grayscale uses luma weights", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.grayscale());
+ expectPixel(data, 16, 16, [54, 54, 54, 255], 3);
+ expectPixel(data, 48, 16, [182, 182, 182, 255], 3);
+});
+
+test("extractChannel isolates one channel", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.extractChannel("green"));
+ expectPixel(data, 16, 16, [0, 0, 0, 255]);
+ expectPixel(data, 48, 16, WHITE);
+});
+
+test("flatten composites over a background", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.extend({ left: 10, background: [0, 0, 0, 0] }).flatten([1, 0, 0, 1]),
+ );
+ expectPixel(data, 2, 32, RED);
+});
+
+test("removeAlpha makes the image opaque", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.extend({ left: 10, background: [0, 0, 0, 0] }).removeAlpha(),
+ );
+ expectPixel(data, 2, 32, [0, 0, 0, 255]);
+});
+
+test("normalize stretches a low-contrast image", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(image.linear(0.5, 0.25).normalize());
+ // The compressed 0.25..0.75 range is pushed back out to 0..1: the brightest
+ // pixel returns to white and the raised black floor drops back down.
+ expectPixel(data, 48, 48, WHITE, 12);
+ const floor = (48 * 64 + 16) * 4;
+ assert(
+ data.data[floor] < 40,
+ `expected the black floor to drop, got ${data.data[floor]}`,
+ );
+});
+
+test("composite places an overlay by gravity", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.composite([
+ { input: solidURL(16, 16, "#000000"), gravity: "top-left" },
+ ]),
+ );
+
+ expectPixel(data, 8, 8, [0, 0, 0, 255]);
+ expectPixel(data, 48, 48, WHITE);
+});
+
+test("composite places an overlay at top/left", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.composite([{ input: solidURL(8, 8, "#000000"), left: 40, top: 40 }]),
+ );
+
+ expectPixel(data, 44, 44, [0, 0, 0, 255]);
+ expectPixel(data, 8, 8, RED);
+});
+
+test("composite multiply darkens the base", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.composite([
+ { input: solidURL(64, 64, "#808080"), blend: "multiply" },
+ ]),
+ );
+
+ // White * 0.5 stays mid grey; red keeps only its red channel halved.
+ expectPixel(data, 48, 48, [128, 128, 128, 255], 4);
+ expectPixel(data, 16, 16, [128, 0, 0, 255], 4);
+});
+
+test("composite screen brightens the base", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.composite([{ input: solidURL(64, 64, "#808080"), blend: "screen" }]),
+ );
+
+ expectPixel(data, 16, 16, [255, 128, 128, 255], 4);
+});
+
+test("composite stacks multiple overlays", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await renderToImageData(
+ image.composite([
+ { input: solidURL(32, 32, "#000000"), gravity: "top-left" },
+ { input: solidURL(16, 16, "#00ff00"), gravity: "top-left" },
+ ]),
+ );
+
+ expectPixel(data, 8, 8, GREEN);
+ expectPixel(data, 24, 24, [0, 0, 0, 255]);
+});
+
+test("metadata reports the loaded size", async () => {
+ const image = await SharpGPU.from(patternURL(48, 24));
+ const meta = image.metadata();
+ assertEqual(meta.width, 48, "width");
+ assertEqual(meta.height, 24, "height");
+
+ // Pending operations do not change it, matching sharp.
+ image.resize({ width: 12 });
+ assertEqual(image.metadata().width, 48, "width after resize");
+});
+
+test("toImageData returns top-down rows", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const data = await image.toImageData();
+
+ assertEqual(data.width, 64, "width");
+ assertEqual(data.height, 64, "height");
+ expectPixel(data, 16, 16, RED);
+ expectPixel(data, 48, 48, WHITE);
+});
+
+test("accepts a Blob as input", async () => {
+ const source = await SharpGPU.from(patternURL());
+ const blob = await source.toBlob("image/png");
+ const data = await renderToImageData(await SharpGPU.from(blob));
+
+ expectPixel(data, 16, 16, RED);
+ expectPixel(data, 48, 48, WHITE);
+});
+
+test("accepts ImageData as input", async () => {
+ const source = await SharpGPU.from(patternURL());
+ const imageData = await source.toImageData();
+ const data = await renderToImageData(await SharpGPU.from(imageData));
+
+ expectPixel(data, 16, 16, RED);
+ expectPixel(data, 48, 48, WHITE);
+});
+
+test("clone isolates operations", async () => {
+ const base = await SharpGPU.from(patternURL());
+ const negated = await renderToImageData(base.clone().negate());
+ const original = await renderToImageData(base);
+
+ expectPixel(negated, 16, 16, [0, 255, 255, 255]);
+ expectPixel(original, 16, 16, RED);
+});
+
+test("repeated renders are stable", async () => {
+ const image = await SharpGPU.from(patternURL());
+ image.negate();
+ const first = await renderToImageData(image);
+ const second = await renderToImageData(image);
+
+ expectPixel(first, 16, 16, [0, 255, 255, 255]);
+ expectPixel(second, 16, 16, [0, 255, 255, 255]);
+});
+
+test("destroy releases every texture it allocated", async () => {
+ const image = new SharpGPU();
+ const gl = image.renderer.gl;
+
+ // Record every handle the pipeline allocates, including input, overlay and
+ // ping-pong textures.
+ const textures: WebGLTexture[] = [];
+ const createTexture = gl.createTexture.bind(gl);
+ gl.createTexture = () => {
+ const handle = createTexture();
+ textures.push(handle);
+ return handle;
+ };
+
+ await image.loadImage(patternURL());
+ await image
+ .composite([{ input: solidURL(8, 8, "#000000") }])
+ .lut([0, 1])
+ .toImageData();
+
+ assert(textures.length > 0, "no textures were allocated");
+
+ image.destroy();
+ const alive = textures.filter((texture) => gl.isTexture(texture));
+ assertEqual(alive.length, 0, `${alive.length} textures alive after destroy`);
+});
+
+test("toBlob exports an image", async () => {
+ const image = await SharpGPU.from(patternURL());
+ const blob = await image.toBlob("image/png");
+ assertEqual(blob.type, "image/png", "blob type");
+ assert(blob.size > 0, "blob is empty");
+});
+
+run();
diff --git a/test/composite.test.ts b/test/composite.test.ts
new file mode 100644
index 0000000..f687ed9
--- /dev/null
+++ b/test/composite.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, test } from "bun:test";
+import { placement } from "../src/operations/composite";
+import { flipRows } from "../src/output";
+
+const BASE = { width: 100, height: 100 };
+const OVERLAY = { width: 20, height: 10 };
+
+describe("placement", () => {
+ test("centers by default", () => {
+ expect(placement(BASE, OVERLAY, { input: "" })).toEqual({
+ left: 40,
+ top: 45,
+ ...OVERLAY,
+ });
+ });
+
+ test("anchors to a gravity corner", () => {
+ expect(
+ placement(BASE, OVERLAY, { input: "", gravity: "bottom-right" }),
+ ).toEqual({ left: 80, top: 90, ...OVERLAY });
+ });
+
+ test("explicit offsets win over gravity", () => {
+ expect(
+ placement(BASE, OVERLAY, {
+ input: "",
+ gravity: "bottom-right",
+ left: 5,
+ top: 7,
+ }),
+ ).toEqual({ left: 5, top: 7, ...OVERLAY });
+ });
+
+ test("a single explicit offset leaves the other axis to gravity", () => {
+ expect(placement(BASE, OVERLAY, { input: "", top: 0 })).toEqual({
+ left: 40,
+ top: 0,
+ ...OVERLAY,
+ });
+ });
+});
+
+describe("flipRows", () => {
+ test("reverses row order", () => {
+ // Two rows of one pixel each.
+ const pixels = new Uint8ClampedArray([1, 2, 3, 4, 5, 6, 7, 8]);
+ const flipped = flipRows(pixels, { width: 1, height: 2 });
+ expect(Array.from(flipped)).toEqual([5, 6, 7, 8, 1, 2, 3, 4]);
+ });
+
+ test("keeps pixel order within a row", () => {
+ const pixels = new Uint8ClampedArray([1, 1, 1, 1, 2, 2, 2, 2]);
+ const flipped = flipRows(pixels, { width: 2, height: 1 });
+ expect(Array.from(flipped)).toEqual([1, 1, 1, 1, 2, 2, 2, 2]);
+ });
+});
diff --git a/test/effects.test.ts b/test/effects.test.ts
new file mode 100644
index 0000000..21dd4af
--- /dev/null
+++ b/test/effects.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, test } from "bun:test";
+import { kernelScale, padKernel } from "../src/operations/convolve";
+import { luminanceRange, stretch } from "../src/operations/normalize";
+
+describe("padKernel", () => {
+ test("lays rows out on a fixed stride", () => {
+ const padded = padKernel({
+ width: 3,
+ height: 2,
+ kernel: [1, 2, 3, 4, 5, 6],
+ });
+
+ expect(padded.length).toBe(49);
+ expect(Array.from(padded.slice(0, 3))).toEqual([1, 2, 3]);
+ expect(Array.from(padded.slice(7, 10))).toEqual([4, 5, 6]);
+ expect(padded[3]).toBe(0);
+ });
+
+ test("rejects a kernel that does not match its shape", () => {
+ expect(() => padKernel({ width: 3, height: 3, kernel: [1] })).toThrow(
+ "must hold 9 values",
+ );
+ });
+
+ test("rejects kernels larger than the shader supports", () => {
+ expect(() =>
+ padKernel({ width: 9, height: 9, kernel: new Array(81).fill(0) }),
+ ).toThrow("at most 7x7");
+ });
+});
+
+describe("kernelScale", () => {
+ test("defaults to the kernel sum", () => {
+ expect(kernelScale({ width: 2, height: 1, kernel: [1, 3] })).toBe(4);
+ });
+
+ test("falls back to 1 for zero-sum kernels", () => {
+ expect(kernelScale({ width: 2, height: 1, kernel: [1, -1] })).toBe(1);
+ });
+
+ test("honors an explicit scale", () => {
+ expect(kernelScale({ width: 2, height: 1, kernel: [1, 3], scale: 2 })).toBe(
+ 2,
+ );
+ });
+});
+
+// Pixels are RGBA quads: [r, g, b, a].
+function pixels(...values: number[][]) {
+ return new Uint8ClampedArray(values.flat());
+}
+
+describe("luminanceRange", () => {
+ test("spans the darkest and brightest pixels", () => {
+ const range = luminanceRange(
+ pixels([0, 0, 0, 255], [255, 255, 255, 255], [128, 128, 128, 255]),
+ );
+ expect(range.min).toBeCloseTo(0, 3);
+ expect(range.max).toBeCloseTo(255, 3);
+ });
+
+ test("ignores fully transparent pixels", () => {
+ const range = luminanceRange(
+ pixels([0, 0, 0, 0], [128, 128, 128, 255], [200, 200, 200, 255]),
+ );
+ expect(range.min).toBeCloseTo(128, 3);
+ expect(range.max).toBeCloseTo(200, 3);
+ });
+
+ test("falls back to the full range when everything is transparent", () => {
+ expect(luminanceRange(pixels([10, 10, 10, 0]))).toEqual({
+ min: 0,
+ max: 255,
+ });
+ });
+});
+
+describe("stretch", () => {
+ test("maps the range onto 0..1", () => {
+ const { multiply, add } = stretch({ min: 64, max: 192 });
+ const apply = (value: number) => (value / 255) * multiply[0] + add[0];
+ expect(apply(64)).toBeCloseTo(0, 5);
+ expect(apply(192)).toBeCloseTo(1, 5);
+ });
+
+ test("is a no-op for a flat image", () => {
+ expect(stretch({ min: 100, max: 100 })).toEqual({
+ multiply: [1, 1, 1, 1],
+ add: [0, 0, 0, 0],
+ });
+ });
+
+ test("leaves alpha untouched", () => {
+ const { multiply, add } = stretch({ min: 0, max: 255 });
+ expect(multiply[3]).toBe(1);
+ expect(add[3]).toBe(0);
+ });
+});
diff --git a/test/geometry.test.ts b/test/geometry.test.ts
new file mode 100644
index 0000000..e1d9eac
--- /dev/null
+++ b/test/geometry.test.ts
@@ -0,0 +1,197 @@
+import { describe, expect, test } from "bun:test";
+import {
+ anchor,
+ computeSize,
+ extendPlan,
+ extractPlan,
+ Mat3,
+ resizePlan,
+ rotatePlan,
+ Size,
+} from "../src/geometry";
+
+// Applies a plan's transform the same way the shader does.
+function mapUV(transform: Mat3, u: number, v: number): [number, number] {
+ return [
+ transform[0] * u + transform[3] * v + transform[6],
+ transform[1] * u + transform[4] * v + transform[7],
+ ];
+}
+
+function expectUV(actual: [number, number], expected: [number, number]) {
+ expect(actual[0]).toBeCloseTo(expected[0], 5);
+ expect(actual[1]).toBeCloseTo(expected[1], 5);
+}
+
+const LANDSCAPE: Size = { width: 100, height: 50 };
+
+describe("computeSize", () => {
+ test("uses both dimensions when given", () => {
+ expect(computeSize(LANDSCAPE, { width: 10, height: 20 })).toEqual({
+ width: 10,
+ height: 20,
+ });
+ });
+
+ test("derives the missing dimension from the aspect ratio", () => {
+ expect(computeSize(LANDSCAPE, { width: 50 })).toEqual({
+ width: 50,
+ height: 25,
+ });
+ expect(computeSize(LANDSCAPE, { height: 25 })).toEqual({
+ width: 50,
+ height: 25,
+ });
+ });
+
+ test("returns the source size without params", () => {
+ expect(computeSize(LANDSCAPE, {})).toEqual(LANDSCAPE);
+ });
+
+ test("rounds and never returns less than one pixel", () => {
+ expect(computeSize({ width: 3, height: 100 }, { width: 1 })).toEqual({
+ width: 1,
+ height: 33,
+ });
+ expect(computeSize({ width: 1000, height: 2 }, { height: 1 })).toEqual({
+ width: 500,
+ height: 1,
+ });
+ });
+});
+
+describe("anchor", () => {
+ test("defaults to the center", () => {
+ expect(anchor()).toEqual([0.5, 0.5]);
+ });
+
+ test("reads both axes from the name", () => {
+ expect(anchor("top-left")).toEqual([0, 0]);
+ expect(anchor("bottom-right")).toEqual([1, 1]);
+ expect(anchor("top")).toEqual([0.5, 0]);
+ expect(anchor("right")).toEqual([1, 0.5]);
+ });
+});
+
+describe("resizePlan", () => {
+ test("fill stretches to the requested size", () => {
+ const plan = resizePlan(LANDSCAPE, { width: 40, height: 40, fit: "fill" });
+ expect(plan.size).toEqual({ width: 40, height: 40 });
+ expectUV(mapUV(plan.transform, 0, 0), [0, 0]);
+ expectUV(mapUV(plan.transform, 1, 1), [1, 1]);
+ });
+
+ test("cover crops the longer axis and centers it", () => {
+ const plan = resizePlan(LANDSCAPE, { width: 50, height: 50 });
+ expect(plan.size).toEqual({ width: 50, height: 50 });
+ // A square crop of a 100x50 source keeps x in [0.25, 0.75].
+ expectUV(mapUV(plan.transform, 0, 0), [0.25, 0]);
+ expectUV(mapUV(plan.transform, 1, 1), [0.75, 1]);
+ });
+
+ test("cover honors position", () => {
+ const plan = resizePlan(LANDSCAPE, {
+ width: 50,
+ height: 50,
+ position: "left",
+ });
+ expectUV(mapUV(plan.transform, 0, 0), [0, 0]);
+ expectUV(mapUV(plan.transform, 1, 1), [0.5, 1]);
+ });
+
+ test("contain letterboxes inside the requested size", () => {
+ const plan = resizePlan(LANDSCAPE, {
+ width: 100,
+ height: 100,
+ fit: "contain",
+ });
+ expect(plan.size).toEqual({ width: 100, height: 100 });
+ // The image occupies the middle half vertically; edges fall outside [0, 1].
+ expectUV(mapUV(plan.transform, 0, 0.25), [0, 0]);
+ expectUV(mapUV(plan.transform, 1, 0.75), [1, 1]);
+ expect(mapUV(plan.transform, 0.5, 0)[1]).toBeLessThan(0);
+ expect(mapUV(plan.transform, 0.5, 1)[1]).toBeGreaterThan(1);
+ });
+
+ test("inside fits within the box without padding", () => {
+ const plan = resizePlan(LANDSCAPE, {
+ width: 100,
+ height: 100,
+ fit: "inside",
+ });
+ expect(plan.size).toEqual({ width: 100, height: 50 });
+ });
+
+ test("outside covers the box", () => {
+ const plan = resizePlan(LANDSCAPE, {
+ width: 100,
+ height: 100,
+ fit: "outside",
+ });
+ expect(plan.size).toEqual({ width: 200, height: 100 });
+ });
+
+ test("a single dimension ignores fit", () => {
+ const plan = resizePlan(LANDSCAPE, { width: 50, fit: "contain" });
+ expect(plan.size).toEqual({ width: 50, height: 25 });
+ expectUV(mapUV(plan.transform, 1, 1), [1, 1]);
+ });
+});
+
+describe("extractPlan", () => {
+ test("maps the target onto the requested rect", () => {
+ const plan = extractPlan(
+ { width: 100, height: 100 },
+ { left: 10, top: 20, width: 20, height: 40 },
+ );
+ expect(plan.size).toEqual({ width: 20, height: 40 });
+ // uv y is bottom-up: the rect spans y in [0.4, 0.8] from the bottom.
+ expectUV(mapUV(plan.transform, 0, 0), [0.1, 0.4]);
+ expectUV(mapUV(plan.transform, 1, 1), [0.3, 0.8]);
+ });
+});
+
+describe("extendPlan", () => {
+ test("grows the canvas and keeps the image in place", () => {
+ const plan = extendPlan({ width: 100, height: 100 }, { left: 10, top: 10 });
+ expect(plan.size).toEqual({ width: 110, height: 110 });
+ // Padding is on the left and top, so the image keeps the bottom-right of
+ // bottom-up uv space; the padded edges fall outside [0, 1].
+ expectUV(mapUV(plan.transform, 10 / 110, 0), [0, 0]);
+ expectUV(mapUV(plan.transform, 1, 100 / 110), [1, 1]);
+ expect(mapUV(plan.transform, 0, 0.5)[0]).toBeLessThan(0);
+ expect(mapUV(plan.transform, 0.5, 1)[1]).toBeGreaterThan(1);
+ });
+});
+
+describe("rotatePlan", () => {
+ test("keeps the size for full turns", () => {
+ expect(rotatePlan(LANDSCAPE, 0).size).toEqual(LANDSCAPE);
+ expect(rotatePlan(LANDSCAPE, 360).size).toEqual(LANDSCAPE);
+ });
+
+ test("swaps the size on quarter turns", () => {
+ expect(rotatePlan(LANDSCAPE, 90).size).toEqual({ width: 50, height: 100 });
+ expect(rotatePlan(LANDSCAPE, 270).size).toEqual({ width: 50, height: 100 });
+ });
+
+ test("grows to the bounding box on arbitrary angles", () => {
+ const plan = rotatePlan({ width: 100, height: 100 }, 45);
+ expect(plan.size.width).toBe(141);
+ expect(plan.size.height).toBe(141);
+ });
+
+ test("moves the source top-left corner to the top-right at 90 degrees", () => {
+ const plan = rotatePlan({ width: 100, height: 100 }, 90);
+ // Target top-right in bottom-up uv is (1, 1); it must sample the source
+ // top-left, which is (0, 1).
+ expectUV(mapUV(plan.transform, 1, 1), [0, 1]);
+ expectUV(mapUV(plan.transform, 0, 0), [1, 0]);
+ });
+
+ test("is identity at zero degrees", () => {
+ const plan = rotatePlan({ width: 100, height: 100 }, 0);
+ expectUV(mapUV(plan.transform, 0, 0), [0, 0]);
+ expectUV(mapUV(plan.transform, 1, 1), [1, 1]);
+ });
+});
diff --git a/test/lut.test.ts b/test/lut.test.ts
new file mode 100644
index 0000000..65d22a7
--- /dev/null
+++ b/test/lut.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test";
+import { sampleCurve } from "../src/operations/lut";
+
+describe("sampleCurve", () => {
+ test("samples a function over 256 entries", () => {
+ const data = sampleCurve((x) => x);
+ expect(data.length).toBe(256);
+ expect(data[0]).toBe(0);
+ expect(data[128]).toBe(128);
+ expect(data[255]).toBe(255);
+ });
+
+ test("interpolates control points linearly", () => {
+ const data = sampleCurve([0, 1]);
+ expect(data[0]).toBe(0);
+ expect(data[255]).toBe(255);
+ expect(data[64]).toBeGreaterThan(60);
+ expect(data[64]).toBeLessThan(69);
+ });
+
+ test("inverts with a descending curve", () => {
+ const data = sampleCurve([1, 0]);
+ expect(data[0]).toBe(255);
+ expect(data[255]).toBe(0);
+ });
+
+ test("clamps values outside [0, 1]", () => {
+ const data = sampleCurve(() => 2);
+ expect(data[0]).toBe(255);
+ });
+});
diff --git a/test/vector.test.ts b/test/vector.test.ts
new file mode 100644
index 0000000..4859ea7
--- /dev/null
+++ b/test/vector.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test";
+import { clampVec3Min, toVec3, toVec4 } from "../src/utils/vector";
+
+describe("toVec3", () => {
+ test("broadcasts a number", () => {
+ expect(toVec3(2, 1)).toEqual([2, 2, 2]);
+ });
+
+ test("passes an array through", () => {
+ expect(toVec3([1, 2, 3], 0)).toEqual([1, 2, 3]);
+ });
+
+ test("returns the fallback when undefined", () => {
+ expect(toVec3(undefined, 5)).toEqual([5, 5, 5]);
+ });
+});
+
+describe("toVec4", () => {
+ test("broadcasts a number with a separate alpha fallback", () => {
+ expect(toVec4(2, 1, 9)).toEqual([2, 2, 2, 9]);
+ });
+
+ test("fills missing alpha from a vec3", () => {
+ expect(toVec4([1, 2, 3], 0, 7)).toEqual([1, 2, 3, 7]);
+ });
+
+ test("keeps an explicit alpha", () => {
+ expect(toVec4([1, 2, 3, 4], 0, 7)).toEqual([1, 2, 3, 4]);
+ });
+});
+
+describe("clampVec3Min", () => {
+ test("clamps each component", () => {
+ expect(clampVec3Min([0, 0.5, -1], 0.1)).toEqual([0.1, 0.5, 0.1]);
+ });
+});