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]); + }); +});