diff --git a/.changeset/six-birds-own.md b/.changeset/six-birds-own.md deleted file mode 100644 index a845151c..00000000 --- a/.changeset/six-birds-own.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index eaa89c62..8f282293 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -21,7 +21,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Build gl-react packages run: | - for d in packages/gl-react packages/gl-react-dom packages/gl-react-headless; do + for d in packages/gl-react packages/gl-react-dom packages/gl-react-headless packages/gl-react-image; do pnpm exec babel --root-mode upward --source-maps --extensions '.ts,.tsx' -d "$d/lib" "$d/src" done - run: xvfb-run -s "-ac -screen 0 1280x1024x24" pnpm test diff --git a/CLAUDE.md b/CLAUDE.md index 60948672..3db15f2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Releases are managed by [changesets](https://github.com/changesets/changesets). - **`packages/gl-react-native/`** — React Native standalone implementation. - **`packages/gl-react-expo/`** — React Native via Expo GLView. - **`packages/gl-react-headless/`** — Node.js implementation using headless-gl. +- **`packages/gl-react-image/`** — `GLImage` component implementing `resizeMode` (cover/contain/free/stretch) in OpenGL. - **`packages/tests/`** — Shared Jest test suite using `gl-react-headless` + `react-test-renderer`. - **`packages/cookbook/`** — Modern examples (Vite + TypeScript + Tailwind). diff --git a/packages/cookbook/package.json b/packages/cookbook/package.json index 28c5f987..999e6a39 100644 --- a/packages/cookbook/package.json +++ b/packages/cookbook/package.json @@ -17,6 +17,7 @@ "@heroicons/react": "^2.2.0", "gl-react": "workspace:^", "gl-react-dom": "workspace:^", + "gl-react-image": "workspace:^", "gl-shader": "^4.2.1", "gl-texture2d": "^2.1.0", "gl-transitions": "^1.43.0", diff --git a/packages/cookbook/src/examples/imageeffects.tsx b/packages/cookbook/src/examples/imageeffects.tsx new file mode 100644 index 00000000..f5ed3f84 --- /dev/null +++ b/packages/cookbook/src/examples/imageeffects.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Surface } from "gl-react-dom"; +import GLImage from "gl-react-image"; +import { Saturate } from "./saturation"; + +// GLImage renders a regular gl-react Node, so it composes like any other: +// here the cover-cropped image is the texture input of the Saturate effect. +export default function ImageEffects({ + saturation = 1, + zoom = 0.6, + centerX = 0.5, + centerY = 0.5, +}: { + saturation?: number; + zoom?: number; + centerX?: number; + centerY?: number; +}) { + return ( + + + + + + ); +} diff --git a/packages/cookbook/src/examples/imageresizemodes.tsx b/packages/cookbook/src/examples/imageresizemodes.tsx new file mode 100644 index 00000000..4393681b --- /dev/null +++ b/packages/cookbook/src/examples/imageresizemodes.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Surface } from "gl-react-dom"; +import GLImage from "gl-react-image"; + +// The surface is wider than the image ratio, so each resizeMode +// behaves differently: cover crops, contain letterboxes, stretch distorts. +export default function ImageResizeModes({ + resizeMode = "cover", + zoom = 1, + centerX = 0.5, + centerY = 0.5, +}: { + resizeMode?: "cover" | "free" | "contain" | "stretch"; + zoom?: number; + centerX?: number; + centerY?: number; +}) { + return ( + + + + ); +} diff --git a/packages/cookbook/src/examples/index.ts b/packages/cookbook/src/examples/index.ts index 700de5cf..9a64089b 100644 --- a/packages/cookbook/src/examples/index.ts +++ b/packages/cookbook/src/examples/index.ts @@ -139,6 +139,42 @@ export const examples: ExampleEntry[] = [ category: "Composition", Component: lazy(() => import("./diamondanim")), }, + { + id: "imageresizemodes", + title: "Image Resize Modes", + description: "gl-react-image: cover, free, contain and stretch resizeMode implemented in OpenGL", + category: "Composition", + Component: lazy(() => import("./imageresizemodes")), + controls: { + resizeMode: { + type: "select", + label: "Resize Mode", + options: [ + { key: "cover", label: "cover" }, + { key: "free", label: "free" }, + { key: "contain", label: "contain" }, + { key: "stretch", label: "stretch" }, + ], + default: "cover", + }, + zoom: { type: "float", label: "Zoom", min: 0.05, max: 1, step: 0.01, default: 1 }, + centerX: { type: "float", label: "Center X", min: 0, max: 1, step: 0.01, default: 0.5 }, + centerY: { type: "float", label: "Center Y", min: 0, max: 1, step: 0.01, default: 0.5 }, + }, + }, + { + id: "imageeffects", + title: "Image + Effects", + description: "GLImage cover-crop composed with a color effect: a Node like any other", + category: "Composition", + Component: lazy(() => import("./imageeffects")), + controls: { + saturation: { type: "float", label: "Saturation", min: 0, max: 2, step: 0.01, default: 1 }, + zoom: { type: "float", label: "Zoom", min: 0.05, max: 1, step: 0.01, default: 0.6 }, + centerX: { type: "float", label: "Center X", min: 0, max: 1, step: 0.01, default: 0.5 }, + centerY: { type: "float", label: "Center Y", min: 0, max: 1, step: 0.01, default: 0.5 }, + }, + }, // === Blur === { diff --git a/packages/cookbook/vite.config.ts b/packages/cookbook/vite.config.ts index f79f2798..55e441c2 100644 --- a/packages/cookbook/vite.config.ts +++ b/packages/cookbook/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ '@': resolve(__dirname, './src'), 'gl-react': resolve(__dirname, '../gl-react/src'), 'gl-react-dom': resolve(__dirname, '../gl-react-dom/src'), + 'gl-react-image': resolve(__dirname, '../gl-react-image/src'), buffer: resolve(__dirname, './src/shims/buffer.ts'), }, }, diff --git a/packages/gl-react-image/LICENSE b/packages/gl-react-image/LICENSE new file mode 100755 index 00000000..6cdbf257 --- /dev/null +++ b/packages/gl-react-image/LICENSE @@ -0,0 +1,19 @@ +The MIT License (MIT) +Copyright (c) 2016 - 2017 + +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/packages/gl-react-image/README.md b/packages/gl-react-image/README.md new file mode 100644 index 00000000..aecb4a72 --- /dev/null +++ b/packages/gl-react-image/README.md @@ -0,0 +1,27 @@ +# gl-react-image + +Universal [gl-react](https://github.com/gre/gl-react) image component that implements various `resizeMode` in OpenGL: **cover**, **contain**, **free** and **stretch**. + +```jsx +import { Surface } from "gl-react-dom"; // or gl-react-expo / gl-react-native / gl-react-headless +import GLImage from "gl-react-image"; + + + +; +``` + +## Props + +- **`source`** *(required)*: any gl-react texture input (image URL, DOM element, child Node, Bus ref, ...). +- **`resizeMode`**: one of: + - `"cover"` *(default)*: fills the area, cropping the image. `center` and `zoom` move the crop window, clamped so the image always covers the area. + - `"free"`: like `"cover"` but without edge clamping; areas outside the image are transparent. + - `"contain"`: letterboxes the image so it fits entirely in the area. + - `"stretch"`: distorts the image to the area size. +- **`center`**: `[x, y]` crop window center in image coordinates (default `[0.5, 0.5]`). Only for `"cover"` and `"free"`. +- **`zoom`**: crop window zoom level (default `1`). Only for `"cover"` and `"free"`. + +Any other prop is passed through to the underlying gl-react `Node`. + +Because GLImage renders a Node, it can be composed anywhere in a gl-react shader tree: use it as the texture input of an effect, or give it effects as `source`. diff --git a/packages/gl-react-image/package.json b/packages/gl-react-image/package.json new file mode 100644 index 00000000..7726c7c6 --- /dev/null +++ b/packages/gl-react-image/package.json @@ -0,0 +1,37 @@ +{ + "name": "gl-react-image", + "version": "6.0.0", + "license": "MIT", + "author": "Gaëtan Renaudeau ", + "description": "Universal gl-react image that implements various resizeMode in OpenGL (cover, contain, free, stretch)", + "keywords": [ + "gl-react", + "image", + "resizeMode", + "crop", + "gl", + "opengl", + "react", + "react-component" + ], + "repository": { + "type": "git", + "url": "https://github.com/gre/gl-react", + "directory": "packages/gl-react-image" + }, + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "src", + "lib", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "gl-react": "*", + "react": ">=18" + }, + "devDependencies": { + "gl-react": "workspace:^" + } +} diff --git a/packages/gl-react-image/src/index.tsx b/packages/gl-react-image/src/index.tsx new file mode 100644 index 00000000..55370036 --- /dev/null +++ b/packages/gl-react-image/src/index.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import { Shaders, Node, GLSL, LinearCopy, Uniform } from "gl-react"; + +const shaders = Shaders.create({ + contain: { + vert: GLSL` +attribute vec2 _p; +varying vec2 uv; +uniform float tR; +uniform vec2 res; +float r; +void main() { + r = res.x / res.y; + gl_Position = vec4(_p,0.0,1.0); + uv = .5+.5*_p*vec2(max(r/tR,1.),max(tR/r,1.)); +} + `, + frag: GLSL` +precision highp float; +varying vec2 uv; +uniform sampler2D t; +void main () { + gl_FragColor = + step(0.0, uv.x) * + step(0.0, uv.y) * + step(uv.x, 1.0) * + step(uv.y, 1.0) * + texture2D(t, uv); +} + `, + }, + free: { + vert: GLSL` +attribute vec2 _p; +varying vec2 uv; +uniform float zoom; +uniform vec2 center; +uniform float tR; +uniform vec2 res; +float r; +vec2 invert (vec2 p) { + return vec2(p.x, 1.0-p.y); +} +void main() { + r = res.x / res.y; + gl_Position = vec4(_p,0.0,1.0); + // crop with zoom & center in a cover mode. preserving image ratio + float maxR = max(r, tR); + vec2 zoomedCanvasSize = vec2( + (r / maxR) * zoom, + (tR / maxR) * zoom + ); + vec4 crop = vec4( + center.x - zoomedCanvasSize.x / 2., + center.y - zoomedCanvasSize.y / 2., + zoomedCanvasSize.x, + zoomedCanvasSize.y + ); + // apply the crop rectangle + uv = invert(invert(.5+.5*_p) * crop.zw + crop.xy); +} + `, + frag: GLSL` +precision highp float; +varying vec2 uv; +uniform sampler2D t; +void main () { + gl_FragColor = + step(0.0, uv.x) * + step(0.0, uv.y) * + step(uv.x, 1.0) * + step(uv.y, 1.0) * + texture2D(t, uv); +} + `, + }, + cover: { + // NB the cover vertex code can probably be simplified. good enough for now. + vert: GLSL` +attribute vec2 _p; +varying vec2 uv; +uniform float zoom; +uniform vec2 center; +uniform float tR; +uniform vec2 res; +float r; + +vec2 invert (vec2 p) { + return vec2(p.x, 1.0-p.y); +} +void main() { + r = res.x / res.y; + gl_Position = vec4(_p,0.0,1.0); + // crop with zoom & center in a cover mode. preserving image ratio + float maxR = max(r, tR); + vec2 zoomedCanvasSize = vec2( + (r / maxR) * zoom, + (tR / maxR) * zoom + ); + vec4 crop = vec4( + center.x - zoomedCanvasSize.x / 2., + center.y - zoomedCanvasSize.y / 2., + zoomedCanvasSize.x, + zoomedCanvasSize.y + ); + // clamp to not escape the edges + float w = crop[2], h = crop[3]; + float ratio = w / h; + if (w > 1.) { + w = 1.; + h = w / ratio; + } + if (h > 1.) { + h = 1.; + w = h * ratio; + } + crop = vec4( + max(0., min(crop.x, 1.-w)), + max(0., min(crop.y, 1.-h)), + w, + h + ); + // apply the crop rectangle + uv = invert(invert(.5+.5*_p) * crop.zw + crop.xy); +} + `, + frag: GLSL` +precision highp float; +varying vec2 uv; +uniform sampler2D t; +void main () { + gl_FragColor = texture2D(t, uv); +} + `, + }, +}); + +export type GLImageResizeMode = "cover" | "free" | "contain" | "stretch"; + +export type GLImageProps = { + /** any gl-react texture input (URL, image element, Node, Bus ref, ...) */ + source: any; + /** + * - "cover": fills the area, cropping the image (default). `center`/`zoom` + * move the crop window, clamped so the image always covers the area. + * - "free": like "cover" but without clamping; out-of-image areas are + * transparent. + * - "contain": letterboxes the image to fit entirely in the area. + * - "stretch": distorts the image to the area size. + */ + resizeMode?: GLImageResizeMode; + /** crop window center in image coordinates, only for "cover" and "free" */ + center?: [number, number]; + /** crop window zoom level, only for "cover" and "free" */ + zoom?: number; +} & Record; + +export default function GLImage({ + source, + resizeMode = "cover", + center, + zoom, + ...rest +}: GLImageProps) { + if (resizeMode === "cover" || resizeMode === "free") { + return ( + + ); + } + + if (resizeMode === "contain") { + return ( + + ); + } + + // fallback on stretch, most basic thing + return {source}; +} diff --git a/packages/gl-react-image/tsconfig.json b/packages/gl-react-image/tsconfig.json new file mode 100644 index 00000000..6593d576 --- /dev/null +++ b/packages/gl-react-image/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "declarationDir": "lib", + "outDir": "lib", + "rootDir": "src", + "composite": true + }, + "references": [{ "path": "../gl-react" }], + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["lib", "node_modules"] +} diff --git a/packages/tests/__tests__/all.js b/packages/tests/__tests__/all.js index 4eea47dd..9c613280 100755 --- a/packages/tests/__tests__/all.js +++ b/packages/tests/__tests__/all.js @@ -2512,3 +2512,68 @@ test("VisitorLogger + bunch of funky extreme tests", () => { // eslint-disable-next-line no-global-assign console = oldConsole; }); + +test("gl-react-image GLImage resizeMode", async () => { + const GLImage = require("gl-react-image").default; + // yellow3x2 has a 1.5 ratio; on a square surface: + // - cover crops horizontally and fills everything in yellow + // - contain letterboxes vertically: transparent bands at top/bottom + // - stretch distorts and fills everything in yellow + const renderWithResizeMode = async (props) => { + const loader = createOneTextureLoader( + (gl) => createNDArrayTexture(gl, yellow3x2), + [3, 2] + ); + globalRegistry.add(loader.Loader); + const inst = create( + + + + ); + const surface = inst.getInstance(); + await loader.resolve(); + surface.flush(); + // copy the capture data: Node.capture() reuses a pooled buffer, + // so each capture call overwrites the previously returned array + const capture = (x, y) => new Uint8Array(surface.capture(x, y, 1, 1).data); + const result = { + center: capture(32, 32), + bottomLeft: capture(0, 0), + topLeft: capture(0, 63), + }; + inst.unmount(); + globalRegistry.remove(loader.Loader); + return result; + }; + + const yellow = new Uint8Array([255, 255, 0, 255]); + const transparent = new Uint8Array([0, 0, 0, 0]); + + const cover = await renderWithResizeMode({}); + expectToBeCloseToColorArray(cover.center, yellow); + expectToBeCloseToColorArray(cover.bottomLeft, yellow); + expectToBeCloseToColorArray(cover.topLeft, yellow); + + const contain = await renderWithResizeMode({ resizeMode: "contain" }); + expectToBeCloseToColorArray(contain.center, yellow); + expectToBeCloseToColorArray(contain.bottomLeft, transparent); + expectToBeCloseToColorArray(contain.topLeft, transparent); + + const stretch = await renderWithResizeMode({ resizeMode: "stretch" }); + expectToBeCloseToColorArray(stretch.center, yellow); + expectToBeCloseToColorArray(stretch.bottomLeft, yellow); + expectToBeCloseToColorArray(stretch.topLeft, yellow); + + // free mode with a small zoom centered in a corner: part of the square + // escapes the image edges and stays transparent + const free = await renderWithResizeMode({ + resizeMode: "free", + zoom: 0.5, + center: [0, 0], + }); + expectToBeCloseToColorArray(free.topLeft, transparent); +}); diff --git a/packages/tests/package.json b/packages/tests/package.json index a080a6fe..9968d330 100755 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -20,6 +20,7 @@ "dependencies": { "gl-react": "workspace:^", "gl-react-headless": "workspace:^", + "gl-react-image": "workspace:^", "gl-texture2d": "^2.0.12", "invariant": "^2.2.4", "jest": "^29.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 741b5030..44cf274b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: gl-react-dom: specifier: workspace:^ version: link:../gl-react-dom + gl-react-image: + specifier: workspace:^ + version: link:../gl-react-image gl-shader: specifier: ^4.2.1 version: 4.3.1 @@ -388,6 +391,16 @@ importers: specifier: workspace:^ version: link:../gl-react + packages/gl-react-image: + dependencies: + react: + specifier: '>=18' + version: 19.1.0 + devDependencies: + gl-react: + specifier: workspace:^ + version: link:../gl-react + packages/gl-react-native: dependencies: expo-gl: @@ -421,6 +434,9 @@ importers: gl-react-headless: specifier: workspace:^ version: link:../gl-react-headless + gl-react-image: + specifier: workspace:^ + version: link:../gl-react-image gl-texture2d: specifier: ^2.0.12 version: 2.1.0 diff --git a/tsconfig.json b/tsconfig.json index e96f7a0e..f52426ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "paths": { "gl-react": ["./packages/gl-react/src"], "gl-react-dom": ["./packages/gl-react-dom/src"], + "gl-react-image": ["./packages/gl-react-image/src"], "gl-react-headless": ["./packages/gl-react-headless/src"], "gl-react-expo": ["./packages/gl-react-expo/src"], "gl-react-native": ["./packages/gl-react-native/src"] @@ -25,6 +26,7 @@ "references": [ { "path": "packages/gl-react" }, { "path": "packages/gl-react-dom" }, + { "path": "packages/gl-react-image" }, { "path": "packages/gl-react-headless" }, { "path": "packages/gl-react-expo" }, { "path": "packages/gl-react-native" }