From 81be1f02c47ed77166de846a507ce7143f4391e8 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 29 Jul 2026 04:29:38 +0300 Subject: [PATCH 1/7] fix: pipeline leaks, orientation and program disposal - Reuse a single framebuffer pair instead of allocating two per render - Dispose cached programs (WeakMap iteration was a no-op) - Decode inputs via createImageBitmap: img.decode() never resolves for detached images, and WebGL ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap - Accept Blob/ImageData/ImageBitmap/canvas/img as input, not just URLs - Round derived resize dimensions and keep them >= 1 - Reject toBlob with an Error instead of undefined - Collapse the operation hierarchy into ShaderOperation + program consts - Drop unused GL abstractions (buffers, blending, attributes, elements) Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 17 ++ .gitignore | 2 +- bun.lock | 88 +++++- package.json | 6 +- src/core.ts | 152 +++++----- src/geometry.ts | 55 ++++ src/gl.ts | 582 +++++++------------------------------ src/input.ts | 32 ++ src/operations/base.ts | 58 ++-- src/operations/blur.ts | 68 +---- src/operations/color.ts | 15 - src/operations/gamma.ts | 30 +- src/operations/linear.ts | 34 +-- src/operations/lut.ts | 90 ++---- src/operations/modulate.ts | 50 +--- src/operations/resize.ts | 61 ---- src/operations/sample.ts | 59 ++++ src/programs.ts | 37 --- src/utils/vector.ts | 14 +- test/browser/harness.ts | 101 +++++++ test/browser/index.html | 17 ++ test/browser/main.ts | 146 ++++++++++ test/geometry.test.ts | 56 ++++ test/lut.test.ts | 31 ++ test/vector.test.ts | 36 +++ 25 files changed, 917 insertions(+), 920 deletions(-) create mode 100644 .claude/launch.json create mode 100644 src/geometry.ts create mode 100644 src/input.ts delete mode 100644 src/operations/color.ts delete mode 100644 src/operations/resize.ts create mode 100644 src/operations/sample.ts delete mode 100644 src/programs.ts create mode 100644 test/browser/harness.ts create mode 100644 test/browser/index.html create mode 100644 test/browser/main.ts create mode 100644 test/geometry.test.ts create mode 100644 test/lut.test.ts create mode 100644 test/vector.test.ts 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/.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/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/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..12d5794 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,36 +1,39 @@ -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 { resizePlan, ResizeParams, Size } from "./geometry"; +import { decodeImage, ImageInput } from "./input"; + +import { + COPY, + InputOperation, + Operation, + ShaderOperation, +} from "./operations/base"; +import { BLUR } from "./operations/blur"; +import { GAMMA } from "./operations/gamma"; +import { LINEAR } from "./operations/linear"; +import { LUTInput, LUTOperation } from "./operations/lut"; +import { MODULATE, ModulateParams } from "./operations/modulate"; +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[]; 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() { @@ -51,75 +54,71 @@ export class SharpGPU { }); } - 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); + 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; - } - - copy(src: GLTexture) { - this.addOperation(new CopyOperation(src)); - return this; + return this.addOperation( + new SampleOperation((src) => resizePlan(src, params)), + ); } + // Effects blur(radius: number) { - if (radius > 0) { - this.addOperation(new BlurOperation({ radius, direction: [1, 0] })); - this.addOperation(new BlurOperation({ radius, direction: [0, 1] })); + if (radius <= 0) { + return this; } - - return this; - } - - modulate(props: ModulateParams) { - this.addOperation(new ModulateOperation(props)); - return this; - } - - multiply(multiply: LinearInput) { - return this.linear(multiply, 0); + const horizontal: Vec2 = [1, 0]; + const vertical: Vec2 = [0, 1]; + this.addOperation( + new ShaderOperation(BLUR, { radius, direction: horizontal }), + ); + return this.addOperation( + new ShaderOperation(BLUR, { radius, direction: vertical }), + ); } - add(add: LinearInput) { - return this.linear(1, add); + // 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) { - this.addOperation( - new LinearOperation({ + 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() { @@ -130,27 +129,20 @@ export class SharpGPU { return this.modulate({ saturation: 0 }); } - tint(tint: ModulateParams["tint"]) { - const multiply = toVec4(tint, 1, 1); - return this.linear(multiply, toVec4(0, 0, 0)); + tint(tint: number | Vec3) { + return this.linear(toVec4(tint, 1, 1)); } - lut(lut: LUTParams["lut"]) { - this.addOperation(new LUTOperation({ lut })); - return this; + lut(lut: LUTInput) { + return this.addOperation(new LUTOperation(lut)); } - color(color: Vec4) { - this.addOperation(new ColorOperation(color)); - return this; - } + // Output + private async render() { + await Promise.all(this.operations.map((op) => op.prepare?.(this.renderer))); - // Render - private render() { - let src = this.renderer.framebuffer(); - let dst = this.renderer.framebuffer(); + let [src, dst] = this.renderer.pingpong(); - // Run operations for (const operation of this.operations) { operation.run({ renderer: this.renderer, @@ -158,27 +150,18 @@ 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); - - // Copy source to canvas - this.renderer.program(COPY).draw({ - source: src.texture, - }); + this.renderer.program(COPY).draw({ source: src.texture }); } async toCanvas(target: HTMLCanvasElement) { - this.render(); + await this.render(); const ctx = target.getContext("2d"); - if (!ctx) { throw new Error("Failed to get 2D context"); } @@ -190,10 +173,11 @@ export class SharpGPU { } 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..8fcf74d --- /dev/null +++ b/src/geometry.ts @@ -0,0 +1,55 @@ +import { Vec2, Vec4 } from "./utils/vector"; + +export type Size = { + width: number; + height: number; +}; + +export type ResizeParams = { + width?: number; + height?: number; +}; + +// How SampleOperation maps target pixels back to source uv: +// sourceUV = uv * scale + offset; outside [0, 1] renders `background`. +export type SamplePlan = { + size: Size; + scale: Vec2; + offset: Vec2; + background?: Vec4; +}; + +export function computeSize(src: Size, params: ResizeParams): Size { + if (params.width && params.height) { + return { + width: Math.round(params.width), + height: Math.round(params.height), + }; + } + + const aspect = src.width / src.height; + + if (params.width) { + return { + width: Math.round(params.width), + height: Math.max(1, Math.round(params.width / aspect)), + }; + } + + if (params.height) { + return { + width: Math.max(1, Math.round(params.height * aspect)), + height: Math.round(params.height), + }; + } + + return src; +} + +export function resizePlan(src: Size, params: ResizeParams): SamplePlan { + return { + size: computeSize(src, params), + scale: [1, 1], + offset: [0, 0], + }; +} diff --git a/src/gl.ts b/src/gl.ts index 25d2156..09d2ce7 100644 --- a/src/gl.ts +++ b/src/gl.ts @@ -1,4 +1,3 @@ -// Generic type for WebGL context export type GLContext = WebGLRenderingContext | WebGL2RenderingContext; // Map WebGL constants to human-readable values @@ -8,7 +7,6 @@ export const glMap = (gl: GLContext) => ({ rgb: gl.RGB, alpha: gl.ALPHA, luminance: gl.LUMINANCE, - luminanceAlpha: gl.LUMINANCE_ALPHA, }, type: { uint8: gl.UNSIGNED_BYTE, @@ -23,56 +21,6 @@ export const glMap = (gl: GLContext) => ({ 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; @@ -90,7 +38,6 @@ export type GLTextureParams = { wrapT: keyof GLMap["wrap"]; minFilter: keyof GLMap["filter"]; magFilter: keyof GLMap["filter"]; - flipY: boolean; }; export class GLTexture { @@ -107,7 +54,6 @@ export class GLTexture { wrapT: "clamp", minFilter: "linear", magFilter: "linear", - flipY: false, }; constructor(gl: GLContext, params: Partial = {}) { @@ -154,7 +100,6 @@ export class GLTexture { 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); @@ -180,7 +125,7 @@ export class GLTexture { if (width === this.width && height === this.height) { return; } - this.update({ width, height }); + this.update({ width, height, data: null }); } dispose() { @@ -188,71 +133,7 @@ export class GLTexture { } } -// 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); - } -} - +// Framebuffer export class GLFramebuffer { readonly gl: GLContext; readonly texture: GLTexture; @@ -263,7 +144,6 @@ export class GLFramebuffer { this.texture = texture; this.handle = gl.createFramebuffer(); - // bind gl.bindFramebuffer(gl.FRAMEBUFFER, this.handle); gl.framebufferTexture2D( gl.FRAMEBUFFER, @@ -282,40 +162,36 @@ export class GLFramebuffer { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); } + readPixels(): Uint8ClampedArray { + const { width, height } = this.texture; + const pixels = new Uint8ClampedArray(width * height * 4); + this.use(() => { + this.gl.readPixels( + 0, + 0, + width, + height, + this.gl.RGBA, + this.gl.UNSIGNED_BYTE, + new Uint8Array(pixels.buffer), + ); + }); + return pixels; + } + 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) ->; - +// Program: every draw is a full-screen quad; only the fragment shader +// and its uniforms vary per program. export type GLUniformValue = | number | boolean | readonly number[] | Float32Array - | Int32Array | GLTexture; export type GLUniforms = Record< @@ -324,133 +200,60 @@ export type GLUniforms = Record< >; export type GLProgramDefinition = { - vert?: string; - frag?: string; - primitive?: keyof GLMap["primitive"]; - count?: number; - offset?: number; - indexType?: keyof GLMap["indexType"]; - elements?: GLBuffer; - attributes?: GLAttributes; + frag: string; 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); -}; +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; +} export class GLProgram { readonly gl: GLContext; private readonly handle: WebGLProgram; + private readonly position: number; + private readonly uniforms: GLProgramUniform[] = []; - 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 = {}) { + constructor(gl: GLContext, definition: GLProgramDefinition) { this.gl = gl; + this.handle = this.buildProgram(gl, VERT, definition.frag); + this.position = gl.getAttribLocation(this.handle, "position"); - 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)) { + for (const [name, value] of Object.entries(definition.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 }; + this.uniforms.push({ location, value }); } - return result; } private compileShader(gl: GLContext, type: number, source: string) { @@ -462,8 +265,7 @@ export class GLProgram { gl.shaderSource(shader, source); gl.compileShader(shader); - const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS); - if (!compiled) { + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const log = gl.getShaderInfoLog(shader) ?? ""; gl.deleteShader(shader); throw new Error(`Shader compile failed: ${log}`); @@ -477,94 +279,23 @@ export class GLProgram { 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); + gl.deleteShader(vert); + gl.deleteShader(frag); - const linked = gl.getProgramParameter(program, gl.LINK_STATUS); - if (!linked) { + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 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; - } + private writeUniform(location: WebGLUniformLocation, value: GLUniformValue) { + const gl = this.gl; if (typeof value === "number") { gl.uniform1f(location, value); @@ -576,24 +307,32 @@ export class GLProgram { return; } - if (Array.isArray(value)) { - this.writeUniformArray(gl, location, new Float32Array(value)); - return; - } + const array = + value instanceof Float32Array + ? value + : new Float32Array(value as readonly number[]); - if (value instanceof Float32Array || value instanceof Int32Array) { - this.writeUniformArray(gl, location, value); - return; + switch (array.length) { + case 1: + return gl.uniform1fv(location, array); + case 2: + return gl.uniform2fv(location, array); + case 3: + return gl.uniform3fv(location, array); + case 4: + return gl.uniform4fv(location, array); + case 9: + return gl.uniformMatrix3fv(location, false, array); + case 16: + return gl.uniformMatrix4fv(location, false, array); + default: + throw new Error(`Unsupported uniform array length: ${array.length}`); } - - 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)) { + for (const uniform of this.uniforms) { const value = typeof uniform.value === "function" ? uniform.value(props) @@ -601,123 +340,24 @@ export class GLProgram { if (value instanceof GLTexture) { value.bind(textureUnit); - gl.uniform1i(uniform.location, textureUnit); + this.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"); + this.writeUniform(uniform.location, value); } - - 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) { + draw(props: Props = {} as Props) { 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); - } - } + this.applyUniforms(props); - 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(); - } - }); + 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() { @@ -734,16 +374,18 @@ export type GLRendererParams = { export class GLRenderer { gl: GLContext; - programs: WeakMap; + private programs = new Map(); + private scratch?: [GLFramebuffer, GLFramebuffer]; constructor(params: GLRendererParams = {}) { const canvas = params.canvas ?? document.createElement("canvas"); const context = params.context ?? canvas.getContext("webgl2", { - antialias: true, alpha: true, + premultipliedAlpha: false, preserveDrawingBuffer: true, + antialias: false, depth: false, ...params.attributes, }); @@ -753,7 +395,6 @@ export class GLRenderer { } this.gl = context; - this.programs = new WeakMap(); } get canvas() { @@ -770,20 +411,14 @@ export class GLRenderer { 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; + return cached as GLProgram; } const program = new GLProgram(this.gl, definition); this.programs.set(definition as GLProgramDefinition, program as GLProgram); - return program as GLProgram; + return program; } texture(params: Partial = {}) { @@ -791,23 +426,28 @@ export class GLRenderer { } framebuffer(texture?: GLTexture): GLFramebuffer { - if (!texture) { - texture = this.texture({ - width: this.canvas.width, - height: this.canvas.height, - }); - } - return new GLFramebuffer(this.gl, texture); + return new GLFramebuffer( + this.gl, + texture ?? + this.texture({ + width: this.canvas.width, + height: this.canvas.height, + }), + ); } - buffer(params: Partial = {}) { - return new GLBuffer(this.gl, params); + // Reusable framebuffer pair for multi-pass ping-pong rendering. + pingpong(): [GLFramebuffer, GLFramebuffer] { + this.scratch ??= [this.framebuffer(), this.framebuffer()]; + return this.scratch; } dispose() { - for (const program of Object.values(this.programs)) { + for (const program of this.programs.values()) { program.dispose(); } - this.programs = new WeakMap(); + this.programs.clear(); + this.scratch?.forEach((framebuffer) => framebuffer.dispose()); + this.scratch = undefined; } } 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 deleted file mode 100644 index e82c99a..0000000 --- a/src/operations/color.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { COLOR } from "../programs"; -import { ProgramOperation } from "./base"; -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 }; - } -} 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/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/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..5b3444b --- /dev/null +++ b/src/operations/sample.ts @@ -0,0 +1,59 @@ +import { GLProgramDefinition, GLTexture } from "../gl"; +import { Size, SamplePlan } from "../geometry"; +import { Vec2, Vec4 } from "../utils/vector"; +import { Operation, OperationContext } from "./base"; + +type SampleUniforms = { + source: GLTexture; + scale: Vec2; + offset: Vec2; + background: Vec4; +}; + +const SAMPLE: GLProgramDefinition = { + frag: /* glsl */ ` + precision mediump float; + uniform sampler2D source; + uniform vec2 scale; + uniform vec2 offset; + uniform vec4 background; + varying vec2 uv; + + void main() { + vec2 suv = uv * scale + offset; + 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, + scale: (props) => props.scale, + offset: (props) => props.offset, + background: (props) => props.background, + }, +}; + +// Resizes the target and draws the source through an affine uv mapping. +// All geometry operations (resize, extract, extend, flip, flop) are plans. +export class SampleOperation extends Operation { + constructor(private readonly plan: (source: Size) => SamplePlan) { + super(); + } + + run(ctx: OperationContext) { + const { size, scale, offset, 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, + scale, + offset, + background: background ?? [0, 0, 0, 0], + }); + }); + } +} 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..d59c528 --- /dev/null +++ b/test/browser/harness.ts @@ -0,0 +1,101 @@ +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 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..14cf38c --- /dev/null +++ b/test/browser/main.ts @@ -0,0 +1,146 @@ +import { SharpGPU } from "../../src"; +import { + assert, + assertEqual, + expectPixel, + patternURL, + renderToImageData, + run, + 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("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("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("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/geometry.test.ts b/test/geometry.test.ts new file mode 100644 index 0000000..f7e8063 --- /dev/null +++ b/test/geometry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { computeSize } from "../src/geometry"; + +describe("computeSize", () => { + test("uses both dimensions when given", () => { + expect( + computeSize({ width: 64, height: 32 }, { width: 10, height: 20 }), + ).toEqual({ + width: 10, + height: 20, + }); + }); + + test("derives height from aspect ratio", () => { + expect(computeSize({ width: 64, height: 32 }, { width: 32 })).toEqual({ + width: 32, + height: 16, + }); + }); + + test("derives width from aspect ratio", () => { + expect(computeSize({ width: 64, height: 32 }, { height: 16 })).toEqual({ + width: 32, + height: 16, + }); + }); + + test("returns source size without params", () => { + expect(computeSize({ width: 64, height: 32 }, {})).toEqual({ + width: 64, + height: 32, + }); + }); + + test("rounds derived dimensions to integers", () => { + expect(computeSize({ width: 100, height: 30 }, { width: 50 })).toEqual({ + width: 50, + height: 15, + }); + expect(computeSize({ width: 3, height: 100 }, { width: 1 })).toEqual({ + width: 1, + height: 33, + }); + }); + + test("never returns dimensions below 1", () => { + expect(computeSize({ width: 1000, height: 2 }, { height: 1 })).toEqual({ + width: 500, + height: 1, + }); + expect(computeSize({ width: 2, height: 1000 }, { width: 1 })).toEqual({ + width: 1, + height: 500, + }); + }); +}); 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]); + }); +}); From 6b7c01054b6a5753788a0a7278ce18dfd3921283 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 29 Jul 2026 04:33:39 +0300 Subject: [PATCH 2/7] feat: geometry operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add extract, extend, flip, flop and rotate, plus resize fit modes (cover/contain/fill/inside/outside) with gravity and background. Every geometry operation is a pure plan — a target size plus an affine map from target uv back to source uv — rendered by a single shader. Co-Authored-By: Claude Opus 5 --- src/core.ts | 42 ++++++- src/geometry.ts | 249 +++++++++++++++++++++++++++++++++++---- src/operations/sample.ts | 27 ++--- test/browser/main.ts | 101 ++++++++++++++++ test/geometry.test.ts | 201 ++++++++++++++++++++++++++----- 5 files changed, 551 insertions(+), 69 deletions(-) diff --git a/src/core.ts b/src/core.ts index 12d5794..508f66b 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,6 +1,18 @@ import { GLRenderer } from "./gl"; import { clampVec3Min, toVec3, toVec4, Vec2, Vec3, Vec4 } from "./utils/vector"; -import { resizePlan, ResizeParams, Size } from "./geometry"; +import { + extendPlan, + ExtendParams, + extractPlan, + flipPlan, + flopPlan, + Rect, + resizePlan, + ResizeParams, + rotatePlan, + SamplePlan, + Size, +} from "./geometry"; import { decodeImage, ImageInput } from "./input"; import { @@ -73,9 +85,31 @@ export class SharpGPU { // Geometry resize(params: ResizeParams) { - return this.addOperation( - new SampleOperation((src) => resizePlan(src, params)), - ); + return this.sample((src) => resizePlan(src, params)); + } + + extract(rect: Rect) { + return this.sample((src) => extractPlan(src, rect)); + } + + extend(params: ExtendParams) { + return this.sample((src) => extendPlan(src, params)); + } + + flip() { + return this.sample(flipPlan); + } + + flop() { + return this.sample(flopPlan); + } + + rotate(degrees: number, background?: Vec4) { + return this.sample((src) => rotatePlan(src, degrees, background)); + } + + private sample(plan: (source: Size) => SamplePlan) { + return this.addOperation(new SampleOperation(plan)); } // Effects diff --git a/src/geometry.ts b/src/geometry.ts index 8fcf74d..6d30c87 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -1,55 +1,264 @@ -import { Vec2, Vec4 } from "./utils/vector"; +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; }; -// How SampleOperation maps target pixels back to source uv: -// sourceUV = uv * scale + offset; outside [0, 1] renders `background`. +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; - scale: Vec2; - offset: Vec2; - background?: Vec4; + 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]; +} + +// Anchor as a 0..1 fraction, y measured from the top. +export function anchor(gravity: Gravity = "center"): [number, number] { + const x = gravity.includes("left") ? 0 : gravity.includes("right") ? 1 : 0.5; + const y = gravity.includes("top") ? 0 : gravity.includes("bottom") ? 1 : 0.5; + return [x, y]; +} + +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; +} + +export function resizePlan(source: Size, params: ResizeParams): SamplePlan { + const background = params.background ?? TRANSPARENT; + const fit = params.fit ?? "cover"; + const [alignX, alignY] = anchor(params.position); + + // A single dimension always scales uniformly; so does an unconstrained resize. + if (!params.width || !params.height) { return { - width: Math.round(params.width), - height: Math.round(params.height), + size: computeSize(source, params), + transform: IDENTITY, + background, }; } - const aspect = src.width / src.height; + const size = computeSize(source, params); - if (params.width) { + if (fit === "fill") { + return { size, transform: IDENTITY, background }; + } + + if (fit === "inside" || fit === "outside") { + const ratios = [size.width / source.width, size.height / source.height]; + const scale = fit === "inside" ? Math.min(...ratios) : Math.max(...ratios); return { - width: Math.round(params.width), - height: Math.max(1, Math.round(params.width / aspect)), + size: round({ + width: source.width * scale, + height: source.height * scale, + }), + transform: IDENTITY, + background, }; } - if (params.height) { + if (fit === "cover") { + const scale = Math.max( + size.width / source.width, + size.height / source.height, + ); + const crop = { width: size.width / scale, height: size.height / scale }; + const src: Rect = { + ...crop, + left: (source.width - crop.width) * alignX, + top: (source.height - crop.height) * alignY, + }; return { - width: Math.max(1, Math.round(params.height * aspect)), - height: Math.round(params.height), + size, + transform: place(source, size, src, fullRect(size)), + background, }; } - return src; + const scale = Math.min( + size.width / source.width, + size.height / source.height, + ); + const inner = { width: source.width * scale, height: source.height * scale }; + const dst: Rect = { + ...inner, + left: (size.width - inner.width) * alignX, + top: (size.height - inner.height) * alignY, + }; + 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 resizePlan(src: Size, params: ResizeParams): SamplePlan { +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: computeSize(src, params), - scale: [1, 1], - offset: [0, 0], + size, + transform: affine(a, b, c, d, 0.5 - (a + c) / 2, 0.5 - (b + d) / 2), + background, }; } diff --git a/src/operations/sample.ts b/src/operations/sample.ts index 5b3444b..8000505 100644 --- a/src/operations/sample.ts +++ b/src/operations/sample.ts @@ -1,12 +1,11 @@ import { GLProgramDefinition, GLTexture } from "../gl"; -import { Size, SamplePlan } from "../geometry"; -import { Vec2, Vec4 } from "../utils/vector"; +import { Mat3, SamplePlan, Size } from "../geometry"; +import { Vec4 } from "../utils/vector"; import { Operation, OperationContext } from "./base"; type SampleUniforms = { source: GLTexture; - scale: Vec2; - offset: Vec2; + transform: Mat3; background: Vec4; }; @@ -14,34 +13,33 @@ const SAMPLE: GLProgramDefinition = { frag: /* glsl */ ` precision mediump float; uniform sampler2D source; - uniform vec2 scale; - uniform vec2 offset; + uniform mat3 transform; uniform vec4 background; varying vec2 uv; void main() { - vec2 suv = uv * scale + offset; + 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, - scale: (props) => props.scale, - offset: (props) => props.offset, + transform: (props) => props.transform, background: (props) => props.background, }, }; -// Resizes the target and draws the source through an affine uv mapping. -// All geometry operations (resize, extract, extend, flip, flop) are plans. +// 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, scale, offset, background } = this.plan({ + const { size, transform, background } = this.plan({ width: ctx.source.width, height: ctx.source.height, }); @@ -50,9 +48,8 @@ export class SampleOperation extends Operation { ctx.target.use(() => { ctx.renderer.program(SAMPLE).draw({ source: ctx.source, - scale, - offset, - background: background ?? [0, 0, 0, 0], + transform, + background, }); }); } diff --git a/test/browser/main.ts b/test/browser/main.ts index 14cf38c..180041a 100644 --- a/test/browser/main.ts +++ b/test/browser/main.ts @@ -44,6 +44,107 @@ test("resize to explicit dimensions", async () => { 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)); diff --git a/test/geometry.test.ts b/test/geometry.test.ts index f7e8063..e1d9eac 100644 --- a/test/geometry.test.ts +++ b/test/geometry.test.ts @@ -1,56 +1,197 @@ import { describe, expect, test } from "bun:test"; -import { computeSize } from "../src/geometry"; +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({ width: 64, height: 32 }, { width: 10, height: 20 }), - ).toEqual({ + expect(computeSize(LANDSCAPE, { width: 10, height: 20 })).toEqual({ width: 10, height: 20, }); }); - test("derives height from aspect ratio", () => { - expect(computeSize({ width: 64, height: 32 }, { width: 32 })).toEqual({ - width: 32, - height: 16, + test("derives the missing dimension from the aspect ratio", () => { + expect(computeSize(LANDSCAPE, { width: 50 })).toEqual({ + width: 50, + height: 25, }); - }); - - test("derives width from aspect ratio", () => { - expect(computeSize({ width: 64, height: 32 }, { height: 16 })).toEqual({ - width: 32, - height: 16, + expect(computeSize(LANDSCAPE, { height: 25 })).toEqual({ + width: 50, + height: 25, }); }); - test("returns source size without params", () => { - expect(computeSize({ width: 64, height: 32 }, {})).toEqual({ - width: 64, - height: 32, - }); + test("returns the source size without params", () => { + expect(computeSize(LANDSCAPE, {})).toEqual(LANDSCAPE); }); - test("rounds derived dimensions to integers", () => { - expect(computeSize({ width: 100, height: 30 }, { width: 50 })).toEqual({ - width: 50, - height: 15, - }); + test("rounds and never returns less than one pixel", () => { expect(computeSize({ width: 3, height: 100 }, { width: 1 })).toEqual({ width: 1, height: 33, }); - }); - - test("never returns dimensions below 1", () => { expect(computeSize({ width: 1000, height: 2 }, { height: 1 })).toEqual({ width: 500, height: 1, }); - expect(computeSize({ width: 2, height: 1000 }, { width: 1 })).toEqual({ - width: 1, - height: 500, + }); +}); + +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]); }); }); From 52efb46bc5c2aa095f8cde264c0ff62071938994 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 29 Jul 2026 04:39:38 +0300 Subject: [PATCH 3/7] feat: convolution, channel and tone operations Add convolve, sharpen, median, threshold, recomb, extractChannel, flatten, removeAlpha and normalize. grayscale now uses BT.709 luma to match sharp instead of zeroing HSL saturation. Uniforms are written according to the type declared in the shader, so a float[49] kernel is no longer mistaken for a matrix. Co-Authored-By: Claude Opus 5 --- src/core.ts | 97 ++++++++++++++++++++++++- src/gl.ts | 101 ++++++++++++++++++-------- src/operations/color.ts | 106 +++++++++++++++++++++++++++ src/operations/convolve.ts | 92 +++++++++++++++++++++++ src/operations/median.ts | 44 +++++++++++ src/operations/normalize.ts | 54 ++++++++++++++ test/browser/main.ts | 141 ++++++++++++++++++++++++++++++++++++ test/effects.test.ts | 98 +++++++++++++++++++++++++ 8 files changed, 703 insertions(+), 30 deletions(-) create mode 100644 src/operations/color.ts create mode 100644 src/operations/convolve.ts create mode 100644 src/operations/median.ts create mode 100644 src/operations/normalize.ts create mode 100644 test/effects.test.ts diff --git a/src/core.ts b/src/core.ts index 508f66b..921e2a8 100644 --- a/src/core.ts +++ b/src/core.ts @@ -6,6 +6,7 @@ import { extractPlan, flipPlan, flopPlan, + Mat3, Rect, resizePlan, ResizeParams, @@ -22,10 +23,27 @@ import { ShaderOperation, } from "./operations/base"; import { BLUR } from "./operations/blur"; +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; @@ -113,6 +131,30 @@ export class SharpGPU { } // 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; @@ -159,18 +201,71 @@ export class SharpGPU { 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 })); + } + + greyscale() { + return this.grayscale(); + } + + 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]); + } + // Output private async render() { await Promise.all(this.operations.map((op) => op.prepare?.(this.renderer))); diff --git a/src/gl.ts b/src/gl.ts index 09d2ce7..bb884ce 100644 --- a/src/gl.ts +++ b/src/gl.ts @@ -162,23 +162,6 @@ export class GLFramebuffer { this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); } - readPixels(): Uint8ClampedArray { - const { width, height } = this.texture; - const pixels = new Uint8ClampedArray(width * height * 4); - this.use(() => { - this.gl.readPixels( - 0, - 0, - width, - height, - this.gl.RGBA, - this.gl.UNSIGNED_BYTE, - new Uint8Array(pixels.buffer), - ); - }); - return pixels; - } - dispose() { this.gl.deleteFramebuffer(this.handle); this.texture.dispose(); @@ -206,6 +189,7 @@ export type GLProgramDefinition = { type GLProgramUniform = { location: WebGLUniformLocation; + type: number; value: GLUniformValue | ((props: Props) => GLUniformValue); }; @@ -247,15 +231,35 @@ export class GLProgram { this.handle = this.buildProgram(gl, VERT, definition.frag); this.position = gl.getAttribLocation(this.handle, "position"); + const declared = this.declaredTypes(); + for (const [name, value] of Object.entries(definition.uniforms ?? {})) { const location = gl.getUniformLocation(this.handle, name); - if (!location) { + const type = declared.get(name); + if (!location || type === undefined) { throw new Error(`Uniform not found: ${name}`); } - this.uniforms.push({ location, value }); + this.uniforms.push({ location, type, value }); } } + // The shader is the source of truth for how each uniform must be written; + // a JS array alone cannot tell a mat3 from a float[9]. + private declaredTypes() { + const gl = this.gl; + const count = gl.getProgramParameter(this.handle, gl.ACTIVE_UNIFORMS); + const types = new Map(); + + for (let i = 0; i < count; i++) { + const info = gl.getActiveUniform(this.handle, i); + if (info) { + types.set(info.name.replace("[0]", ""), info.type); + } + } + + return types; + } + private compileShader(gl: GLContext, type: number, source: string) { const shader = gl.createShader(type); if (!shader) { @@ -294,7 +298,11 @@ export class GLProgram { return program; } - private writeUniform(location: WebGLUniformLocation, value: GLUniformValue) { + private writeUniform( + location: WebGLUniformLocation, + type: number, + value: GLUniformValue, + ) { const gl = this.gl; if (typeof value === "number") { @@ -312,21 +320,21 @@ export class GLProgram { ? value : new Float32Array(value as readonly number[]); - switch (array.length) { - case 1: + switch (type) { + case gl.FLOAT: return gl.uniform1fv(location, array); - case 2: + case gl.FLOAT_VEC2: return gl.uniform2fv(location, array); - case 3: + case gl.FLOAT_VEC3: return gl.uniform3fv(location, array); - case 4: + case gl.FLOAT_VEC4: return gl.uniform4fv(location, array); - case 9: + case gl.FLOAT_MAT3: return gl.uniformMatrix3fv(location, false, array); - case 16: + case gl.FLOAT_MAT4: return gl.uniformMatrix4fv(location, false, array); default: - throw new Error(`Unsupported uniform array length: ${array.length}`); + throw new Error(`Unsupported uniform type: ${type}`); } } @@ -345,7 +353,7 @@ export class GLProgram { continue; } - this.writeUniform(uniform.location, value); + this.writeUniform(uniform.location, uniform.type, value); } } @@ -376,6 +384,7 @@ export class GLRenderer { gl: GLContext; private programs = new Map(); private scratch?: [GLFramebuffer, GLFramebuffer]; + private readback?: WebGLFramebuffer; constructor(params: GLRendererParams = {}) { const canvas = params.canvas ?? document.createElement("canvas"); @@ -442,6 +451,35 @@ export class GLRenderer { 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(); @@ -449,5 +487,10 @@ export class GLRenderer { this.programs.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/operations/color.ts b/src/operations/color.ts new file mode 100644 index 0000000..5800d53 --- /dev/null +++ b/src/operations/color.ts @@ -0,0 +1,106 @@ +import { GLProgramDefinition, GLTexture } from "../gl"; +import { Mat3 } from "../geometry"; +import { Vec4 } from "../utils/vector"; + +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/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/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/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/test/browser/main.ts b/test/browser/main.ts index 180041a..255ee3a 100644 --- a/test/browser/main.ts +++ b/test/browser/main.ts @@ -218,6 +218,147 @@ test("lut identity keeps colors", async () => { 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("clone isolates operations", async () => { const base = await SharpGPU.from(patternURL()); const negated = await renderToImageData(base.clone().negate()); 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); + }); +}); From 60ff8f6690d97a987e2485e8d13f1c1aee3d2701 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 29 Jul 2026 04:42:06 +0300 Subject: [PATCH 4/7] feat: composite, metadata and ImageData output Add composite() with PDF blend modes and gravity/offset placement, metadata() for the loaded image, and toImageData() for pixel access. Co-Authored-By: Claude Opus 5 --- src/core.ts | 41 +++++++--- src/operations/composite.ts | 150 ++++++++++++++++++++++++++++++++++++ src/output.ts | 21 +++++ test/browser/harness.ts | 14 ++++ test/browser/main.ts | 97 +++++++++++++++++++++++ test/composite.test.ts | 56 ++++++++++++++ 6 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 src/operations/composite.ts create mode 100644 src/output.ts create mode 100644 test/composite.test.ts diff --git a/src/core.ts b/src/core.ts index 921e2a8..7aced59 100644 --- a/src/core.ts +++ b/src/core.ts @@ -15,6 +15,7 @@ import { Size, } from "./geometry"; import { decodeImage, ImageInput } from "./input"; +import { toImageData } from "./output"; import { COPY, @@ -23,6 +24,7 @@ import { ShaderOperation, } from "./operations/base"; import { BLUR } from "./operations/blur"; +import { CompositeOperation, CompositeParams } from "./operations/composite"; import { Channel, CHANNEL_MASKS, @@ -56,6 +58,7 @@ type SharpGPUParams = { export class SharpGPU { renderer: GLRenderer; operations: Operation[]; + private source?: Size; constructor(params: SharpGPUParams = {}) { this.renderer = params.renderer ?? new GLRenderer(); @@ -70,18 +73,22 @@ export class SharpGPU { 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(input: ImageInput) { @@ -93,6 +100,7 @@ export class SharpGPU { data: image, }); + this.source = { width: image.width, height: image.height }; return this.addOperation(new InputOperation(texture)); } @@ -266,6 +274,14 @@ export class SharpGPU { return this.linear([1, 1, 1, 0], [0, 0, 0, 1]); } + // Composition + composite(overlays: CompositeParams[]) { + for (const overlay of overlays) { + this.addOperation(new CompositeOperation(overlay)); + } + return this; + } + // Output private async render() { await Promise.all(this.operations.map((op) => op.prepare?.(this.renderer))); @@ -285,22 +301,29 @@ export class SharpGPU { this.renderer.resize(src.texture.width, src.texture.height); this.renderer.program(COPY).draw({ source: src.texture }); + + return src.texture; } async toCanvas(target: HTMLCanvasElement) { - await 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 { await this.render(); return new Promise((resolve, reject) => { 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/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/test/browser/harness.ts b/test/browser/harness.ts index d59c528..4118b58 100644 --- a/test/browser/harness.ts +++ b/test/browser/harness.ts @@ -79,6 +79,20 @@ export function patternURL(width = 64, height = 64): string { 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; diff --git a/test/browser/main.ts b/test/browser/main.ts index 255ee3a..ad28643 100644 --- a/test/browser/main.ts +++ b/test/browser/main.ts @@ -6,6 +6,7 @@ import { patternURL, renderToImageData, run, + solidURL, test, } from "./harness"; @@ -359,6 +360,102 @@ test("normalize stretches a low-contrast image", async () => { ); }); +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()); 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]); + }); +}); From 794b876d6def91ef92dd364af1427bd3394893cf Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 29 Jul 2026 04:47:03 +0300 Subject: [PATCH 5/7] refactor: split the GL layer and document the API Break gl.ts into texture/framebuffer/program/renderer so each file answers one question, and flatten resizePlan into named helpers. Rewrite the README around sharp parity, add the MIT license file and a CI workflow running format, typecheck, tests and build. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 21 ++ LICENSE | 21 ++ README.md | 210 +++++++++-------- example/bun.lock | 1 + src/geometry.ts | 70 +++--- src/gl.ts | 496 --------------------------------------- src/gl/framebuffer.ts | 36 +++ src/gl/index.ts | 4 + src/gl/program.ts | 204 ++++++++++++++++ src/gl/renderer.ts | 122 ++++++++++ src/gl/texture.ts | 145 ++++++++++++ 11 files changed, 699 insertions(+), 631 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE delete mode 100644 src/gl.ts create mode 100644 src/gl/framebuffer.ts create mode 100644 src/gl/index.ts create mode 100644 src/gl/program.ts create mode 100644 src/gl/renderer.ts create mode 100644 src/gl/texture.ts 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/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/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/src/geometry.ts b/src/geometry.ts index 6d30c87..2586178 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -74,11 +74,19 @@ function affine( 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] { - const x = gravity.includes("left") ? 0 : gravity.includes("right") ? 1 : 0.5; - const y = gravity.includes("top") ? 0 : gravity.includes("bottom") ? 1 : 0.5; - return [x, y]; + return [align(gravity, "left", "right"), align(gravity, "top", "bottom")]; } function round(size: Size): Size { @@ -127,50 +135,50 @@ export function computeSize(src: Size, params: ResizeParams): Size { return src; } -export function resizePlan(source: Size, params: ResizeParams): SamplePlan { - const background = params.background ?? TRANSPARENT; - const fit = params.fit ?? "cover"; - const [alignX, alignY] = anchor(params.position); +// 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, + }; +} - // A single dimension always scales uniformly; so does an unconstrained resize. - if (!params.width || !params.height) { - return { - size: computeSize(source, params), - transform: IDENTITY, - background, - }; - } +// `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"; - if (fit === "fill") { + // 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") { - const ratios = [size.width / source.width, size.height / source.height]; - const scale = fit === "inside" ? Math.min(...ratios) : Math.max(...ratios); return { - size: round({ - width: source.width * scale, - height: source.height * scale, - }), + 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: Rect = { - ...crop, - left: (source.width - crop.width) * alignX, - top: (source.height - crop.height) * alignY, - }; + const src = anchoredRect(source, crop, params.position); return { size, transform: place(source, size, src, fullRect(size)), @@ -178,16 +186,14 @@ export function resizePlan(source: Size, params: ResizeParams): SamplePlan { }; } + // 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: Rect = { - ...inner, - left: (size.width - inner.width) * alignX, - top: (size.height - inner.height) * alignY, - }; + const dst = anchoredRect(size, inner, params.position); return { size, transform: place(source, size, fullRect(source), dst), diff --git a/src/gl.ts b/src/gl.ts deleted file mode 100644 index bb884ce..0000000 --- a/src/gl.ts +++ /dev/null @@ -1,496 +0,0 @@ -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, - }, - 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; - -// 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"]; -}; - -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 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); - - 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, data: null }); - } - - dispose() { - this.gl.deleteTexture(this.handle); - } -} - -// Framebuffer -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, - 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: every draw is a full-screen quad; only the fragment shader -// and its uniforms vary per program. -export type GLUniformValue = - | number - | boolean - | readonly number[] - | Float32Array - | GLTexture; - -export type GLUniforms = Record< - string, - GLUniformValue | ((props: Props) => GLUniformValue) ->; - -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; -} - -export class GLProgram { - readonly gl: GLContext; - private readonly handle: WebGLProgram; - private readonly position: number; - private readonly uniforms: GLProgramUniform[] = []; - - constructor(gl: GLContext, definition: GLProgramDefinition) { - this.gl = gl; - this.handle = this.buildProgram(gl, VERT, definition.frag); - this.position = gl.getAttribLocation(this.handle, "position"); - - const declared = this.declaredTypes(); - - for (const [name, value] of Object.entries(definition.uniforms ?? {})) { - const location = gl.getUniformLocation(this.handle, name); - const type = declared.get(name); - if (!location || type === undefined) { - throw new Error(`Uniform not found: ${name}`); - } - this.uniforms.push({ location, type, value }); - } - } - - // The shader is the source of truth for how each uniform must be written; - // a JS array alone cannot tell a mat3 from a float[9]. - private declaredTypes() { - const gl = this.gl; - const count = gl.getProgramParameter(this.handle, gl.ACTIVE_UNIFORMS); - const types = new Map(); - - for (let i = 0; i < count; i++) { - const info = gl.getActiveUniform(this.handle, i); - if (info) { - types.set(info.name.replace("[0]", ""), info.type); - } - } - - return types; - } - - 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); - - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - 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(); - 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; - } - - private writeUniform( - location: WebGLUniformLocation, - type: number, - value: GLUniformValue, - ) { - const gl = this.gl; - - if (typeof value === "number") { - gl.uniform1f(location, value); - return; - } - - if (typeof value === "boolean") { - gl.uniform1i(location, value ? 1 : 0); - return; - } - - const array = - value instanceof Float32Array - ? value - : new Float32Array(value as readonly number[]); - - 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); - } -} - -// Renderer -export type GLRendererParams = { - context?: GLContext; - canvas?: HTMLCanvasElement; - attributes?: WebGLContextAttributes; -}; - -export class GLRenderer { - gl: GLContext; - private programs = new Map(); - 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, - 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); - } - - 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 = {}) { - return new GLTexture(this.gl, params); - } - - framebuffer(texture?: GLTexture): GLFramebuffer { - return new GLFramebuffer( - this.gl, - texture ?? - this.texture({ - width: this.canvas.width, - height: this.canvas.height, - }), - ); - } - - // 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(); - 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/framebuffer.ts b/src/gl/framebuffer.ts new file mode 100644 index 0000000..0f9cb02 --- /dev/null +++ b/src/gl/framebuffer.ts @@ -0,0 +1,36 @@ +import { GLContext, GLTexture } from "./texture"; + +// Renders into a texture. Owns the texture it was given. +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); + this.texture.dispose(); + } +} 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..e9eefdc --- /dev/null +++ b/src/gl/renderer.ts @@ -0,0 +1,122 @@ +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 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 = {}) { + return new GLTexture(this.gl, params); + } + + 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(); + + 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); + } +} From 86b93d0cbeddd6b7c8d1c5704269715bc1d16932 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 29 Jul 2026 04:49:19 +0300 Subject: [PATCH 6/7] fix: release input, overlay and LUT textures on destroy The renderer now owns every texture it hands out, so destroy() frees them instead of leaking the ones held by operations. Co-Authored-By: Claude Opus 5 --- src/gl/framebuffer.ts | 3 +-- src/gl/renderer.ts | 10 +++++++++- test/browser/main.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/gl/framebuffer.ts b/src/gl/framebuffer.ts index 0f9cb02..33881c3 100644 --- a/src/gl/framebuffer.ts +++ b/src/gl/framebuffer.ts @@ -1,6 +1,6 @@ import { GLContext, GLTexture } from "./texture"; -// Renders into a texture. Owns the texture it was given. +// Renders into a texture. The renderer owns both; this is just the binding. export class GLFramebuffer { readonly gl: GLContext; readonly texture: GLTexture; @@ -31,6 +31,5 @@ export class GLFramebuffer { dispose() { this.gl.deleteFramebuffer(this.handle); - this.texture.dispose(); } } diff --git a/src/gl/renderer.ts b/src/gl/renderer.ts index e9eefdc..7c38553 100644 --- a/src/gl/renderer.ts +++ b/src/gl/renderer.ts @@ -12,6 +12,7 @@ export type GLRendererParams = { export class GLRenderer { readonly gl: GLContext; private programs = new Map(); + private textures = new Set(); private scratch?: [GLFramebuffer, GLFramebuffer]; private readback?: WebGLFramebuffer; @@ -63,7 +64,9 @@ export class GLRenderer { } texture(params: Partial = {}) { - return new GLTexture(this.gl, params); + const texture = new GLTexture(this.gl, params); + this.textures.add(texture); + return texture; } framebuffer(texture = this.texture()) { @@ -111,6 +114,11 @@ export class GLRenderer { } this.programs.clear(); + for (const texture of this.textures) { + texture.dispose(); + } + this.textures.clear(); + this.scratch?.forEach((framebuffer) => framebuffer.dispose()); this.scratch = undefined; diff --git a/test/browser/main.ts b/test/browser/main.ts index ad28643..45890c8 100644 --- a/test/browser/main.ts +++ b/test/browser/main.ts @@ -475,6 +475,33 @@ test("repeated renders are stable", async () => { 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"); From aeeca9adb30079bad82c5618be5ecab09f0b0c5c Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Wed, 29 Jul 2026 04:50:44 +0300 Subject: [PATCH 7/7] feat: export the public type surface Fit, Gravity, BlendMode, ImageInput and the parameter types were only reachable through the class, so consumers could not import them. Co-Authored-By: Claude Opus 5 --- src/index.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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";