├── .editorconfig ├── .github ├── dependabot.yml ├── logscan.sh └── workflows │ ├── ci.yml │ ├── gh-pages.yml │ ├── publish-wiki.yml │ ├── release.yml │ └── report.yml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .vscode ├── c_cpp_properties.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── examples ├── README.md ├── compress.ts ├── decode-from-jpeg-using-loader.ts ├── decode-from-jpeg.ts ├── decode-from-separate-data-using-loader.ts ├── decode-from-separate-data.ts ├── encode-and-compress.ts ├── encode-jpeg-metadata.ts ├── encode.ts ├── globals.d.ts ├── integrated │ ├── .editorconfig │ ├── README.md │ ├── index.html │ ├── jstest.html │ ├── ktx.html │ ├── libs │ │ └── basis │ │ │ ├── README.md │ │ │ ├── basis_transcoder.js │ │ │ └── basis_transcoder.wasm │ ├── main.css │ └── textures │ │ └── gainmap │ │ ├── qwantani_puresky_8k-gainmap.jpg │ │ ├── qwantani_puresky_8k-gainmap.ktx2 │ │ ├── qwantani_puresky_8k.jpg │ │ ├── qwantani_puresky_8k.json │ │ ├── qwantani_puresky_8k.ktx2 │ │ ├── spruit_sunrise_1k.hdr │ │ ├── spruit_sunrise_4k-gainmap.webp │ │ ├── spruit_sunrise_4k.jpg │ │ ├── spruit_sunrise_4k.json │ │ └── spruit_sunrise_4k.webp ├── tsconfig.json └── worker.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── release.config.cjs ├── reports └── .gitkeep ├── rollup.config.decodeonly.mjs ├── rollup.config.mjs ├── src ├── core │ ├── QuadRenderer.ts │ ├── get-data-texture.ts │ ├── index.ts │ └── types.ts ├── decode.ts ├── decode │ ├── decode.ts │ ├── errors │ │ ├── GainMapNotFoundError.ts │ │ └── XMPMetadataNotFoundError.ts │ ├── extract.ts │ ├── index.ts │ ├── loaders │ │ ├── GainMapLoader.ts │ │ ├── HDRJPGLoader.ts │ │ └── LoaderBase.ts │ ├── materials │ │ └── GainMapDecoderMaterial.ts │ ├── types.ts │ └── utils │ │ ├── MPFExtractor.ts │ │ ├── extractXMP.ts │ │ └── get-html-image-from-blob.ts ├── encode.ts ├── encode │ ├── compress.ts │ ├── encode-and-compress.ts │ ├── encode.ts │ ├── find-texture-min-max.ts │ ├── get-gainmap.ts │ ├── get-sdr-rendition.ts │ ├── index.ts │ ├── materials │ │ ├── GainMapEncoderMaterial.ts │ │ └── SDRMaterial.ts │ └── types.ts ├── libultrahdr.ts ├── libultrahdr │ ├── decode-jpeg-metadata.ts │ ├── encode-jpeg-metadata.ts │ └── library.ts ├── tsconfig.json ├── worker-interface.ts ├── worker-types.ts └── worker.ts ├── tests ├── __snapshots__ │ ├── decode │ │ ├── decode.test.ts │ │ │ ├── chromium-material-values.json │ │ │ └── chromium-render.png │ │ ├── loaders │ │ │ ├── GainMapLoader.test.ts │ │ │ │ └── chromium-render.png │ │ │ └── HDRJPGLoader.test.ts │ │ │ │ ├── chromium-render-no-create-image-bitmap.png │ │ │ │ ├── chromium-render-plain.png │ │ │ │ └── chromium-render.png │ │ └── utils │ │ │ ├── MPFExtractor.test.ts │ │ │ ├── chromium-01-jpg-gainmap.png │ │ │ └── chromium-01-jpg-sdr.png │ │ │ └── extractXMP.test.ts │ │ │ ├── chromium-extracts-xmp-from-01-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-02-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-03-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-04-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-05-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-06-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-07-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-08-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-09-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-10-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-abandoned-bakery-16k-jpg-1.txt │ │ │ ├── chromium-extracts-xmp-from-pisa-4k-jpg-1.txt │ │ │ └── chromium-extracts-xmp-from-spruit-sunrise-4k-jpg-1.txt │ ├── encode │ │ ├── encode-and-compress.test.ts │ │ │ ├── chromium-memorial-exr-encode-result.png │ │ │ └── chromium-odd-sized-exr-encode-result.png │ │ ├── encode.test.ts │ │ │ ├── chromium-memorial-exr-encode-result-custom-params-gainmap.png │ │ │ ├── chromium-memorial-exr-encode-result-custom-sdr-params.png │ │ │ ├── chromium-memorial-exr-encode-result-gainmap.png │ │ │ ├── chromium-memorial-exr-encode-result-with-tone-mapping-ACESFilmicToneMapping.png │ │ │ ├── chromium-memorial-exr-encode-result-with-tone-mapping-CineonToneMapping.png │ │ │ ├── chromium-memorial-exr-encode-result-with-tone-mapping-INVALID.png │ │ │ ├── chromium-memorial-exr-encode-result-with-tone-mapping-LinearToneMapping.png │ │ │ ├── chromium-memorial-exr-encode-result-with-tone-mapping-ReinhardToneMapping.png │ │ │ └── chromium-memorial-exr-encode-result.png │ │ └── find-texture-min-max.test.ts │ │ │ ├── chromium-finds-max-values-in-exr-1.txt │ │ │ └── chromium-finds-min-values-in-exr-1.txt │ └── examples │ │ └── integrated-example.test.ts │ │ ├── chromium-initial.png │ │ ├── chromium-zoomed-in.png │ │ └── chromium-zoomed-out-from-above.png ├── coverage-comment-template.md ├── decode │ ├── decode.test.ts │ ├── decode.ts │ ├── extract.test.ts │ ├── extract.ts │ ├── loaders │ │ ├── GainMapLoader.test.ts │ │ ├── HDRJPGLoader.test.ts │ │ ├── gainmap-loader.ts │ │ └── hdr-jpg-loader.ts │ └── utils │ │ ├── MPFExtractor.test.ts │ │ ├── MPFExtractor.ts │ │ ├── extractXMP.test.ts │ │ └── extractXMP.ts ├── disableBrowserFeatures.ts ├── encode │ ├── encode-and-compress.test.ts │ ├── encode-and-compress.ts │ ├── encode.test.ts │ ├── encode.ts │ ├── find-texture-min-max.test.ts │ └── find-texture-min-max.ts ├── examples │ └── integrated-example.test.ts ├── files │ ├── 01.jpg │ ├── 02.jpg │ ├── 03.jpg │ ├── 04.jpg │ ├── 05.jpg │ ├── 06.jpg │ ├── 07.jpg │ ├── 08.jpg │ ├── 09.jpg │ ├── 10.jpg │ ├── abandoned_bakery_16k.jpg │ ├── chcaus2-bloom.exr │ ├── chcaus2-bloom.hdr │ ├── gray.exr │ ├── grey.exr │ ├── invalid_image.png │ ├── memorial.exr │ ├── memorial.hdr │ ├── memorial.jpg │ ├── odd-sized.exr │ ├── pisa-4k.jpg │ ├── plain-jpeg.jpg │ ├── spruit_sunrise_1k.hdr │ ├── spruit_sunrise_4k-gainmap.webp │ ├── spruit_sunrise_4k.jpg │ ├── spruit_sunrise_4k.json │ └── spruit_sunrise_4k.webp ├── global.d.ts ├── testWithCoverage.ts ├── testbed.html └── tsconfig.json ├── tsconfig.json └── typedoc.config.cjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | allow: 13 | - dependency-name: "three" 14 | -------------------------------------------------------------------------------- /.github/logscan.sh: -------------------------------------------------------------------------------- 1 | # /bin/bash 2 | if grep -q error $1; then 3 | echo "errors in $1" 4 | out=$(cat $1) 5 | echo "$out" 6 | 7 | # first `sed` replaces newlines with \n 8 | # seconds `sed` replaces quotes with escaped quotes \" 9 | 10 | echo "summary=$(echo "$out" | sed ':a;N;$!ba;s/\n/\\n/g' | sed 's/"/\\"/g' )" >> "$GITHUB_OUTPUT" 11 | exit 1 12 | fi 13 | out="no errors in $1" 14 | echo "$out" 15 | echo "summary=$out" >> "$GITHUB_OUTPUT" 16 | exit 0 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | ################################ 11 | ## 12 | ## BUILD 13 | ## 14 | ################################ 15 | Build: 16 | name: Build 17 | runs-on: ubuntu-latest 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | steps: 21 | - name: 'Checkout' 22 | uses: actions/checkout@v4 23 | with: 24 | submodules: recursive 25 | 26 | - name: 'Setup Emscripten' 27 | uses: mymindstorm/setup-emsdk@v11 28 | with: 29 | version: 3.1.47 30 | 31 | - name: 'Setup Python' 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: '3.10' 35 | 36 | - name: 'Install Meson & Ninja' 37 | uses: BSFishy/pip-action@v1 38 | with: 39 | packages: | 40 | meson 41 | ninja 42 | 43 | - name: Write em.txt 44 | uses: "DamianReeves/write-file-action@master" 45 | with: 46 | path: libultrahdr-wasm/em.txt 47 | write-mode: overwrite 48 | contents: | 49 | [binaries] 50 | c = 'emcc' 51 | cpp = 'em++' 52 | ar = 'emar' 53 | nm = 'emnm' 54 | 55 | [host_machine] 56 | system = 'emscripten' 57 | cpu_family = 'wasm32' 58 | cpu = 'wasm32' 59 | endian = 'little' 60 | 61 | - name: 'Build libultrahdr WASM' 62 | run: | 63 | cd libultrahdr-wasm 64 | meson setup build --cross-file=em.txt 65 | meson compile -C build 66 | 67 | - name: 'Setup Nodejs' 68 | uses: actions/setup-node@v3 69 | with: 70 | node-version: 20 71 | 72 | - name: 'Install dependencies' 73 | run: npm ci 74 | 75 | - name: 'Build' 76 | run: npm run build 77 | 78 | - name: 'Upload Build artifacts' 79 | if: always() 80 | uses: actions/upload-artifact@v4 81 | with: 82 | name: build-artifact 83 | if-no-files-found: error 84 | path: | 85 | libultrahdr-wasm/build/*.ts 86 | libultrahdr-wasm/build/*.js 87 | libultrahdr-wasm/build/*.map 88 | libultrahdr-wasm/build/*.wasm 89 | dist/ 90 | 91 | ################################ 92 | ## 93 | ## CHECKS 94 | ## 95 | ################################ 96 | Check: 97 | name: Check 98 | needs: Build 99 | runs-on: ubuntu-latest 100 | steps: 101 | - name: 'Checkout' 102 | uses: actions/checkout@v4 103 | with: 104 | submodules: recursive 105 | 106 | - name: 'Download build artifacts' 107 | uses: actions/download-artifact@v4 108 | with: 109 | name: build-artifact 110 | 111 | - name: 'Setup Nodejs' 112 | uses: actions/setup-node@v3 113 | with: 114 | node-version: 20 115 | 116 | - name: 'Install dependencies' 117 | run: npm ci 118 | 119 | - name: 'Produce Reports & Logs' 120 | if: always() 121 | run: npm run ci:check 122 | 123 | - name: 'Upload Check artifacts' 124 | if: always() 125 | uses: actions/upload-artifact@v4 126 | with: 127 | name: check-artifact 128 | if-no-files-found: error 129 | path: | 130 | reports/ 131 | 132 | ################################ 133 | ## 134 | ## TEST 135 | ## 136 | ################################ 137 | Test: 138 | name: Test 139 | needs: Build 140 | runs-on: ubuntu-latest 141 | # container: 142 | # image: mcr.microsoft.com/playwright:v1.40.0-jammy 143 | steps: 144 | # - name: 'Initialize Git LFS' 145 | # run: curl -s https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | bash 146 | 147 | # - name: 'Install Git LFS' 148 | # run: apt-get install -y git-lfs 149 | 150 | - name: 'Checkout' 151 | uses: actions/checkout@v4 152 | with: 153 | submodules: recursive 154 | 155 | - name: 'Download build artifacts' 156 | uses: actions/download-artifact@v4 157 | with: 158 | name: build-artifact 159 | 160 | - name: 'Setup Nodejs' 161 | uses: actions/setup-node@v3 162 | with: 163 | node-version: 20 164 | 165 | - name: 'Install dependencies' 166 | run: npm ci 167 | 168 | - name: 'Install playwright Browsers' 169 | run: npx playwright install --with-deps 170 | 171 | - name: 'Run Playwright Tests' 172 | run: npm test 173 | # env: 174 | # HOME: /root 175 | 176 | - name: 'Upload Test artifacts' 177 | if: always() 178 | uses: actions/upload-artifact@v4 179 | with: 180 | name: test-artifact 181 | if-no-files-found: error 182 | path: | 183 | .nyc_output/ 184 | test-results/ 185 | coverage/ 186 | playwright-report/ 187 | tests/__snapshots__/ 188 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: 'Deploy Threejs Example' 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: [main] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | # Upload entire repository 40 | path: './examples/integrated' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.github/workflows/publish-wiki.yml: -------------------------------------------------------------------------------- 1 | name: 'Publish wiki' 2 | 3 | on: 4 | workflow_run: 5 | branches: [main] 6 | workflows: [CI] 7 | types: 8 | - completed 9 | 10 | concurrency: 11 | group: publish-wiki 12 | cancel-in-progress: true 13 | 14 | permissions: 15 | contents: write 16 | actions: read 17 | 18 | jobs: 19 | publish-wiki: 20 | name: 'Publish Wiki' 21 | runs-on: ubuntu-latest 22 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 23 | steps: 24 | - name: 'Checkout repo' 25 | uses: actions/checkout@v3 26 | with: 27 | submodules: recursive 28 | 29 | - name: 'Download build artifacts' 30 | uses: dawidd6/action-download-artifact@v6 31 | with: 32 | name: build-artifact 33 | run_id: ${{ github.event.workflow_run.id }} 34 | workflow_conclusion: success 35 | 36 | 37 | - name: 'Setup Nodejs' 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: 20 41 | 42 | - name: 'Install Packages' 43 | run: npm ci 44 | 45 | - name: 'Produce Wiki' 46 | run: npx typedoc 47 | 48 | - name: 'Publish Wiki' 49 | uses: Andrew-Chen-Wang/github-wiki-action@v4 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | # Allows you to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | # run when tests have passed 7 | workflow_run: 8 | branches: [main] 9 | workflows: [CI] 10 | types: 11 | - completed 12 | 13 | concurrency: 14 | group: release 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: write 19 | issues: write 20 | pull-requests: write 21 | actions: read 22 | 23 | jobs: 24 | release: 25 | name: Release 26 | runs-on: ubuntu-latest 27 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }} 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | steps: 32 | - name: 'Checkout' 33 | uses: nschloe/action-cached-lfs-checkout@v1 34 | with: 35 | submodules: recursive 36 | token: ${{ secrets.RELEASE_GITHUB_TOKEN }} 37 | 38 | - name: 'Download build artifacts' 39 | uses: dawidd6/action-download-artifact@v6 # download artifacts 40 | with: 41 | name: build-artifact 42 | run_id: ${{ github.event.workflow_run.id }} 43 | workflow_conclusion: success 44 | 45 | 46 | - name: 'Setup Nodejs' 47 | uses: actions/setup-node@v3 48 | with: 49 | node-version: 20 50 | 51 | - name: 'Install Packages' 52 | run: npm ci 53 | 54 | - name: 'Release' 55 | run: npx semantic-release 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/report.yml: -------------------------------------------------------------------------------- 1 | name: Report on pull requests 2 | 3 | 4 | on: 5 | workflow_run: 6 | workflows: [CI] 7 | types: 8 | - completed 9 | 10 | jobs: 11 | report: 12 | name: Report 13 | runs-on: ubuntu-latest 14 | if: github.event.workflow_run.event == 'pull_request' 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | steps: 18 | - name: 'Checkout' 19 | uses: actions/checkout@v4 20 | with: 21 | submodules: recursive 22 | 23 | - name: 'Download build artifacts' 24 | if: always() 25 | uses: dawidd6/action-download-artifact@v6 # download artifacts 26 | with: 27 | name: build-artifact 28 | run_id: ${{ github.event.workflow_run.id }} 29 | 30 | - name: 'Download check artifacts' 31 | if: always() 32 | uses: dawidd6/action-download-artifact@v6 # download artifacts 33 | with: 34 | name: check-artifact 35 | run_id: ${{ github.event.workflow_run.id }} 36 | 37 | - name: 'Download test artifacts' 38 | if: always() 39 | uses: dawidd6/action-download-artifact@v6 # download artifacts 40 | with: 41 | name: test-artifact 42 | run_id: ${{ github.event.workflow_run.id }} 43 | 44 | 45 | - name: 'Logscan ensure logscan is executable' 46 | if: always() 47 | run: 'chmod +x .github/logscan.sh' 48 | 49 | - name: 'Logscan TypeCheck Src' 50 | if: always() 51 | id: logscan_src 52 | run: '.github/logscan.sh reports/typecheck.log' 53 | 54 | - name: 'Logscan TypeCheck Examples' 55 | if: always() 56 | id: logscan_examples 57 | run: '.github/logscan.sh reports/typecheck-examples.log' 58 | 59 | - name: 'Logscan TypeCheck Tests' 60 | if: always() 61 | id: logscan_tests 62 | run: '.github/logscan.sh reports/typecheck-tests.log' 63 | 64 | - name: 'TypeCheck Src' 65 | if: always() 66 | uses: LouisBrunner/checks-action@v1.6.1 67 | with: 68 | token: ${{ secrets.GITHUB_TOKEN }} 69 | name: Typecheck Src 70 | conclusion: ${{steps.logscan_src.conclusion}} 71 | sha: ${{ github.event.workflow_run.head_sha }} 72 | output: | 73 | {"summary":"${{ steps.logscan_src.outputs.summary }}"} 74 | 75 | - name: 'TypeCheck Examples' 76 | if: always() 77 | uses: LouisBrunner/checks-action@v1.6.1 78 | with: 79 | token: ${{ secrets.GITHUB_TOKEN }} 80 | name: Typecheck Examples 81 | conclusion: ${{steps.logscan_examples.conclusion}} 82 | sha: ${{ github.event.workflow_run.head_sha }} 83 | output: | 84 | {"summary":"${{ steps.logscan_examples.outputs.summary }}"} 85 | 86 | - name: 'TypeCheck Tests' 87 | if: always() 88 | uses: LouisBrunner/checks-action@v1.6.1 89 | with: 90 | token: ${{ secrets.GITHUB_TOKEN }} 91 | name: Typecheck Tests 92 | conclusion: ${{steps.logscan_tests.conclusion}} 93 | sha: ${{ github.event.workflow_run.head_sha }} 94 | output: | 95 | {"summary":"${{ steps.logscan_tests.outputs.summary }}"} 96 | 97 | - name: 'Analyze Src Code Linting Results' 98 | if: always() 99 | id: eslint_src 100 | uses: ataylorme/eslint-annotate-action@v2 101 | with: 102 | report-json: "reports/eslint-src.json" 103 | 104 | - name: 'Analyze Examples Code Linting Results' 105 | if: always() 106 | id: eslint_examples 107 | uses: ataylorme/eslint-annotate-action@v2 108 | with: 109 | report-json: "reports/eslint-examples.json" 110 | 111 | - name: 'Analyze Tests Code Linting Results' 112 | if: always() 113 | id: eslint_tests 114 | uses: ataylorme/eslint-annotate-action@v2 115 | with: 116 | report-json: "reports/eslint-tests.json" 117 | 118 | - name: 'Report Eslint Src' 119 | if: always() 120 | uses: LouisBrunner/checks-action@v1.6.1 121 | with: 122 | token: ${{ secrets.GITHUB_TOKEN }} 123 | name: 'Eslint Src' 124 | conclusion: ${{steps.eslint_src.conclusion}} 125 | sha: ${{ github.event.workflow_run.head_sha }} 126 | output: | 127 | {"summary":"${{ steps.eslint_src.outputs.summary }}"} 128 | 129 | - name: 'Report Eslint Examples' 130 | if: always() 131 | uses: LouisBrunner/checks-action@v1.6.1 132 | with: 133 | token: ${{ secrets.GITHUB_TOKEN }} 134 | name: 'Eslint Examples' 135 | conclusion: ${{steps.eslint_examples.conclusion}} 136 | sha: ${{ github.event.workflow_run.head_sha }} 137 | output: | 138 | {"summary":"${{ steps.eslint_examples.outputs.summary }}"} 139 | 140 | - name: 'Report Eslint Tests' 141 | if: always() 142 | uses: LouisBrunner/checks-action@v1.6.1 143 | with: 144 | token: ${{ secrets.GITHUB_TOKEN }} 145 | name: 'Eslint Tests' 146 | conclusion: ${{steps.eslint_tests.conclusion}} 147 | sha: ${{ github.event.workflow_run.head_sha }} 148 | output: | 149 | {"summary":"${{ steps.eslint_tests.outputs.summary }}"} 150 | 151 | 152 | - name: 'Upload coverage reports to Codecov' 153 | if: always() 154 | uses: codecov/codecov-action@v3 155 | with: 156 | directory: ./coverage/ 157 | override_pr: ${{ github.event.workflow_run.pull_requests[0].number }} 158 | override_commit: ${{ github.event.workflow_run.head_sha }} 159 | 160 | 161 | # - name: 'Report playwright test results' 162 | # uses: daun/playwright-report-summary@v2 163 | # with: 164 | # report-file: playwright-report.json 165 | # env: 166 | # GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} 167 | # GITHUB_EVENT_NAME: ${{ github.event.workflow_run.event }} 168 | 169 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .thumbs.db 3 | node_modules 4 | 5 | # Log files 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Editor directories and files 11 | .idea 12 | *.suo 13 | *.ntvs* 14 | *.njsproj 15 | *.sln 16 | *.tgz 17 | 18 | dist/ 19 | doc/ 20 | wiki/ 21 | junit.xml 22 | reports/*.json 23 | reports/*.xml 24 | reports/*.txt 25 | reports/*.log 26 | test-results 27 | playwright-report 28 | .nyc_output 29 | coverage 30 | playwright-report.json 31 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "libultrahdr-wasm"] 2 | path = libultrahdr-wasm 3 | url = git@github.com:MONOGRID/libultrahdr-wasm.git 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.config.* 3 | *.tgz 4 | tests 5 | tsconfig.json 6 | src 7 | reports 8 | examples 9 | wiki 10 | libultrahdr-wasm/**/* 11 | !libultrahdr-wasm/build/*.ts 12 | !libultrahdr-wasm/build/*.js 13 | !libultrahdr-wasm/build/*.wasm 14 | -------------------------------------------------------------------------------- /.vscode/c_cpp_properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "name": "Emscripten", 5 | "includePath": [ 6 | "${workspaceFolder}/**" 7 | ], 8 | "defines": [], 9 | "compileCommands": "${workspaceFolder}/libultrahdr-wasm/build/compile_commands.json" 10 | } 11 | ], 12 | "version": 4 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cmake.configureOnOpen": false, 3 | "files.associations": { 4 | "*.controller": "yaml", 5 | "*.anim": "yaml", 6 | "*.asset": "yaml", 7 | "*.mat": "yaml", 8 | "*.prefab": "yaml", 9 | ".modernizrrc": "javascript", 10 | "*.jslib": "javascript", 11 | "*.jspre": "javascript", 12 | "array": "cpp", 13 | "string": "cpp", 14 | "string_view": "cpp", 15 | "vector": "cpp", 16 | "iterator": "cpp", 17 | "atomic": "cpp", 18 | "cstdint": "cpp", 19 | "limits": "cpp", 20 | "__bit_reference": "cpp", 21 | "__config": "cpp", 22 | "__debug": "cpp", 23 | "__errc": "cpp", 24 | "__hash_table": "cpp", 25 | "__locale": "cpp", 26 | "__mutex_base": "cpp", 27 | "__node_handle": "cpp", 28 | "__split_buffer": "cpp", 29 | "__threading_support": "cpp", 30 | "__tree": "cpp", 31 | "__verbose_abort": "cpp", 32 | "bitset": "cpp", 33 | "cctype": "cpp", 34 | "clocale": "cpp", 35 | "cmath": "cpp", 36 | "codecvt": "cpp", 37 | "condition_variable": "cpp", 38 | "cstdarg": "cpp", 39 | "cstddef": "cpp", 40 | "cstdio": "cpp", 41 | "cstdlib": "cpp", 42 | "cstring": "cpp", 43 | "ctime": "cpp", 44 | "cwchar": "cpp", 45 | "cwctype": "cpp", 46 | "deque": "cpp", 47 | "exception": "cpp", 48 | "fstream": "cpp", 49 | "initializer_list": "cpp", 50 | "iomanip": "cpp", 51 | "ios": "cpp", 52 | "iosfwd": "cpp", 53 | "iostream": "cpp", 54 | "istream": "cpp", 55 | "list": "cpp", 56 | "locale": "cpp", 57 | "map": "cpp", 58 | "mutex": "cpp", 59 | "new": "cpp", 60 | "optional": "cpp", 61 | "ostream": "cpp", 62 | "ratio": "cpp", 63 | "regex": "cpp", 64 | "set": "cpp", 65 | "sstream": "cpp", 66 | "stack": "cpp", 67 | "stdexcept": "cpp", 68 | "streambuf": "cpp", 69 | "system_error": "cpp", 70 | "thread": "cpp", 71 | "tuple": "cpp", 72 | "typeinfo": "cpp", 73 | "unordered_map": "cpp", 74 | "variant": "cpp", 75 | "algorithm": "cpp", 76 | "bit": "cpp", 77 | "*.tcc": "cpp", 78 | "chrono": "cpp", 79 | "cinttypes": "cpp", 80 | "compare": "cpp", 81 | "concepts": "cpp", 82 | "unordered_set": "cpp", 83 | "functional": "cpp", 84 | "memory": "cpp", 85 | "memory_resource": "cpp", 86 | "numeric": "cpp", 87 | "random": "cpp", 88 | "type_traits": "cpp", 89 | "utility": "cpp", 90 | "numbers": "cpp", 91 | "semaphore": "cpp", 92 | "shared_mutex": "cpp", 93 | "stop_token": "cpp", 94 | "cfenv": "cpp", 95 | "__nullptr": "cpp", 96 | "*.incl_cpp": "cpp" 97 | }, 98 | "C_Cpp.default.compileCommands": "builddir/compile_commands.json", 99 | "mesonbuild.configureOnOpen": false 100 | } 101 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | gainmap@monogrid.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 MONOGRID 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc' 2 | import { defineConfig } from 'eslint/config' 3 | // @ts-expect-error untyped lib 4 | import mdcs from 'eslint-config-mdcs' 5 | // @ts-expect-error untyped lib 6 | import htmlPlugin from 'eslint-plugin-html' 7 | import simpleImportSort from 'eslint-plugin-simple-import-sort' 8 | import unusedImports from 'eslint-plugin-unused-imports' 9 | import globals from 'globals' 10 | import neoStandard, { plugins } from 'neostandard' 11 | import path from 'path' 12 | import { fileURLToPath } from 'url' 13 | 14 | // HTML examples need a config of their own 15 | const mdcsCompat = new FlatCompat({ 16 | baseDirectory: path.dirname(fileURLToPath(import.meta.url)) 17 | }).config(mdcs) 18 | 19 | const integratedExamplesPath = 'examples/integrated/*.html' 20 | 21 | // Get the neostandard configs 22 | const neoStandardConfigs = neoStandard({ ts: true }) 23 | // Get TypeScript recommended type checking configs 24 | const typeCheckingConfigs = plugins['typescript-eslint'].configs['recommendedTypeChecked'] 25 | 26 | const config = defineConfig([ 27 | // Common ignores for all configs 28 | { 29 | ignores: [ 30 | 'node_modules/**/*', 31 | 'dist/**/*', 32 | '.vscode/**/*', 33 | 'libultrahdr-wasm/build/**/*' 34 | ] 35 | } 36 | ]) 37 | 38 | // Add mdcs configs for html files in examples 39 | for (const mdcsConfig of mdcsCompat) { 40 | config.push({ 41 | ...mdcsConfig, 42 | files: [integratedExamplesPath], 43 | languageOptions: { 44 | globals: { 45 | ...globals.browser, 46 | }, 47 | parserOptions: { 48 | extraFileExtensions: ['.html'], 49 | } 50 | }, 51 | plugins: { 52 | html: htmlPlugin 53 | } 54 | }) 55 | } 56 | 57 | // Add neostandard configs with proper file patterns 58 | for (const neoConfig of neoStandardConfigs) { 59 | config.push({ 60 | ...neoConfig, 61 | files: ['**/*.{ts,mts,cts,tsx,js,mjs,cjs}'], 62 | ignores: [ 63 | ...(neoConfig.ignores || []), 64 | integratedExamplesPath 65 | ] 66 | }) 67 | } 68 | 69 | // Add TypeScript type checking configs 70 | if (Array.isArray(typeCheckingConfigs)) { 71 | for (const tsConfig of typeCheckingConfigs) { 72 | // @ts-expect-error untyped lib 73 | config.push({ 74 | ...tsConfig, 75 | files: ['**/*.{ts,mts,cts,tsx}'], 76 | ignores: [ 77 | ...(tsConfig.ignores || []), 78 | integratedExamplesPath 79 | ] 80 | }) 81 | } 82 | } else { 83 | config.push({ 84 | // @ts-expect-error untyped lib 85 | ...typeCheckingConfigs, 86 | files: ['**/*.{ts,mts,cts,tsx}'], 87 | ignores: [ 88 | // @ts-expect-error untyped lib 89 | ...(typeCheckingConfigs.ignores || []), 90 | integratedExamplesPath 91 | ] 92 | }) 93 | } 94 | 95 | // Add our custom settings 96 | config.push({ 97 | name: 'app/settings', 98 | files: ['**/*.{ts,mts,cts,tsx,js,mjs,cjs}'], 99 | ignores: [integratedExamplesPath], 100 | languageOptions: { 101 | parserOptions: { 102 | projectService: true, 103 | tsconfigRootDir: import.meta.dirname, 104 | }, 105 | }, 106 | plugins: { 107 | 'simple-import-sort': simpleImportSort, 108 | 'unused-imports': unusedImports 109 | }, 110 | rules: { 111 | // https://github.com/lydell/eslint-plugin-simple-import-sort/#usage 112 | 'simple-import-sort/imports': 'error', 113 | 'simple-import-sort/exports': 'error', 114 | 'sort-imports': 'off', 115 | 'import/order': 'off', 116 | 117 | // https://github.com/sweepline/eslint-plugin-unused-imports 118 | '@typescript-eslint/no-unused-vars': 'off', 119 | 'unused-imports/no-unused-imports': 'error', 120 | 'unused-imports/no-unused-vars': ['error', { vars: 'all', args: 'none', caughtErrors: 'none' }], 121 | 122 | '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], 123 | }, 124 | }) 125 | 126 | export default config 127 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | Examples of use cases using this library 2 | 3 | Kept here for linting & typechecking purposes of the final use cases 4 | -------------------------------------------------------------------------------- /examples/compress.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unused-imports/no-unused-vars */ 2 | import { compress, encode, findTextureMinMax } from '@monogrid/gainmap-js/encode' 3 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 4 | 5 | // load an HDR file 6 | const loader = new EXRLoader() 7 | const image = await loader.loadAsync('image.exr') 8 | 9 | // find RAW RGB Max value of a texture 10 | const textureMax = findTextureMinMax(image) 11 | 12 | // Encode the gainmap 13 | const encodingResult = encode({ 14 | image, 15 | // this will encode the full HDR range 16 | maxContentBoost: Math.max.apply(this, textureMax) 17 | }) 18 | 19 | // obtain the RAW RGBA SDR buffer and create an ImageData 20 | const sdrImageData = new ImageData(encodingResult.sdr.toArray(), encodingResult.sdr.width, encodingResult.sdr.height) 21 | // obtain the RAW RGBA Gain map buffer and create an ImageData 22 | const gainMapImageData = new ImageData(encodingResult.gainMap.toArray(), encodingResult.gainMap.width, encodingResult.gainMap.height) 23 | 24 | // parallel compress the RAW buffers into the specified mimeType 25 | const mimeType = 'image/jpeg' 26 | const quality = 0.9 27 | const [sdr, gainMap] = await Promise.all([ 28 | compress({ 29 | source: sdrImageData, 30 | mimeType, 31 | quality, 32 | flipY: true // output needs to be flipped 33 | }), 34 | compress({ 35 | source: gainMapImageData, 36 | mimeType, 37 | quality, 38 | flipY: true // output needs to be flipped 39 | }) 40 | ]) 41 | 42 | // `sdr` will contain a JPEG which can be saved somewhere 43 | // `gainMap` will contain a JPEG which can be saved somewhere 44 | 45 | // renderers be manually disposed 46 | encodingResult.sdr.dispose() 47 | encodingResult.gainMap.dispose() 48 | -------------------------------------------------------------------------------- /examples/decode-from-jpeg-using-loader.ts: -------------------------------------------------------------------------------- 1 | import { HDRJPGLoader } from '@monogrid/gainmap-js' 2 | import { 3 | EquirectangularReflectionMapping, 4 | Mesh, 5 | MeshBasicMaterial, 6 | PerspectiveCamera, 7 | PlaneGeometry, 8 | Scene, 9 | WebGLRenderer 10 | } from 'three' 11 | 12 | const renderer = new WebGLRenderer() 13 | 14 | const loader = new HDRJPGLoader(renderer) 15 | 16 | const result = await loader.loadAsync('gainmap.jpeg') 17 | // `result` can be used to populate a Texture 18 | 19 | const scene = new Scene() 20 | const mesh = new Mesh( 21 | new PlaneGeometry(), 22 | new MeshBasicMaterial({ map: result.renderTarget.texture }) 23 | ) 24 | scene.add(mesh) 25 | renderer.render(scene, new PerspectiveCamera()) 26 | 27 | // Starting from three.js r159 28 | // `result.renderTarget.texture` can 29 | // also be used as Equirectangular scene background 30 | // 31 | // it was previously needed to convert it 32 | // to a DataTexture with `result.toDataTexture()` 33 | scene.background = result.renderTarget.texture 34 | scene.background.mapping = EquirectangularReflectionMapping 35 | 36 | // result must be manually disposed 37 | // when you are done using it 38 | result.dispose() 39 | -------------------------------------------------------------------------------- /examples/decode-from-jpeg.ts: -------------------------------------------------------------------------------- 1 | import { decode, extractGainmapFromJPEG } from '@monogrid/gainmap-js' 2 | import { 3 | ClampToEdgeWrapping, 4 | LinearFilter, 5 | LinearMipMapLinearFilter, 6 | LinearSRGBColorSpace, 7 | Mesh, 8 | MeshBasicMaterial, 9 | PerspectiveCamera, 10 | PlaneGeometry, 11 | RGBAFormat, 12 | Scene, 13 | SRGBColorSpace, 14 | Texture, 15 | UnsignedByteType, 16 | UVMapping, 17 | WebGLRenderer 18 | } from 'three' 19 | 20 | const renderer = new WebGLRenderer() 21 | 22 | // fetch a JPEG image containing a gainmap as ArrayBuffer 23 | const jpeg = new Uint8Array(await (await fetch('gainmap.jpeg')).arrayBuffer()) 24 | 25 | // extract data from the JPEG 26 | const { gainMap: gainMapBuffer, sdr: sdrBuffer, metadata } = await extractGainmapFromJPEG(jpeg) 27 | 28 | // create data blobs 29 | const gainMapBlob = new Blob([gainMapBuffer], { type: 'image/jpeg' }) 30 | const sdrBlob = new Blob([sdrBuffer], { type: 'image/jpeg' }) 31 | 32 | // create ImageBitmap data 33 | const [gainMapImageBitmap, sdrImageBitmap] = await Promise.all([ 34 | createImageBitmap(gainMapBlob, { imageOrientation: 'flipY' }), 35 | createImageBitmap(sdrBlob, { imageOrientation: 'flipY' }) 36 | ]) 37 | 38 | // create textures 39 | const gainMap = new Texture(gainMapImageBitmap, 40 | UVMapping, 41 | ClampToEdgeWrapping, 42 | ClampToEdgeWrapping, 43 | LinearFilter, 44 | LinearMipMapLinearFilter, 45 | RGBAFormat, 46 | UnsignedByteType, 47 | 1, 48 | LinearSRGBColorSpace 49 | ) 50 | 51 | gainMap.needsUpdate = true 52 | 53 | // create textures 54 | const sdr = new Texture(sdrImageBitmap, 55 | UVMapping, 56 | ClampToEdgeWrapping, 57 | ClampToEdgeWrapping, 58 | LinearFilter, 59 | LinearMipMapLinearFilter, 60 | RGBAFormat, 61 | UnsignedByteType, 62 | 1, 63 | SRGBColorSpace 64 | ) 65 | 66 | sdr.needsUpdate = true 67 | 68 | // restore the HDR texture 69 | const result = decode({ 70 | sdr, 71 | gainMap, 72 | // this allows to use `result.renderTarget.texture` directly 73 | renderer, 74 | // this will restore the full HDR range 75 | maxDisplayBoost: Math.pow(2, metadata.hdrCapacityMax), 76 | ...metadata 77 | }) 78 | 79 | const scene = new Scene() 80 | // `result` can be used to populate a Texture 81 | const mesh = new Mesh( 82 | new PlaneGeometry(), 83 | new MeshBasicMaterial({ map: result.renderTarget.texture }) 84 | ) 85 | scene.add(mesh) 86 | renderer.render(scene, new PerspectiveCamera()) 87 | 88 | // result must be manually disposed 89 | // when you are done using it 90 | result.dispose() 91 | -------------------------------------------------------------------------------- /examples/decode-from-separate-data-using-loader.ts: -------------------------------------------------------------------------------- 1 | import { GainMapLoader } from '@monogrid/gainmap-js' 2 | import { 3 | EquirectangularReflectionMapping, 4 | Mesh, 5 | MeshBasicMaterial, 6 | PerspectiveCamera, 7 | PlaneGeometry, 8 | Scene, 9 | WebGLRenderer 10 | } from 'three' 11 | 12 | const renderer = new WebGLRenderer() 13 | 14 | const loader = new GainMapLoader(renderer) 15 | 16 | const result = await loader.loadAsync(['sdr.jpeg', 'gainmap.jpeg', 'metadata.json']) 17 | // `result` can be used to populate a Texture 18 | 19 | const scene = new Scene() 20 | const mesh = new Mesh( 21 | new PlaneGeometry(), 22 | new MeshBasicMaterial({ map: result.renderTarget.texture }) 23 | ) 24 | scene.add(mesh) 25 | renderer.render(scene, new PerspectiveCamera()) 26 | 27 | // Starting from three.js r159 28 | // `result.renderTarget.texture` can 29 | // also be used as Equirectangular scene background 30 | // 31 | // it was previously needed to convert it 32 | // to a DataTexture with `result.toDataTexture()` 33 | scene.background = result.renderTarget.texture 34 | scene.background.mapping = EquirectangularReflectionMapping 35 | 36 | // result must be manually disposed 37 | // when you are done using it 38 | result.dispose() 39 | -------------------------------------------------------------------------------- /examples/decode-from-separate-data.ts: -------------------------------------------------------------------------------- 1 | import { decode, GainMapMetadata } from '@monogrid/gainmap-js' 2 | import { 3 | Mesh, 4 | MeshBasicMaterial, 5 | PerspectiveCamera, 6 | PlaneGeometry, 7 | Scene, 8 | TextureLoader, 9 | WebGLRenderer 10 | } from 'three' 11 | 12 | const renderer = new WebGLRenderer() 13 | 14 | const textureLoader = new TextureLoader() 15 | 16 | // load SDR Representation 17 | const sdr = await textureLoader.loadAsync('sdr.jpg') 18 | // load Gain map recovery image 19 | const gainMap = await textureLoader.loadAsync('gainmap.jpg') 20 | // load metadata 21 | const metadata = await (await fetch('metadata.json')).json() as GainMapMetadata 22 | 23 | const result = decode({ 24 | sdr, 25 | gainMap, 26 | // this allows to use `result.renderTarget.texture` directly 27 | renderer, 28 | // this will restore the full HDR range 29 | maxDisplayBoost: Math.pow(2, metadata.hdrCapacityMax), 30 | ...metadata 31 | }) 32 | 33 | const scene = new Scene() 34 | // `result` can be used to populate a Texture 35 | const mesh = new Mesh( 36 | new PlaneGeometry(), 37 | new MeshBasicMaterial({ map: result.renderTarget.texture }) 38 | ) 39 | scene.add(mesh) 40 | renderer.render(scene, new PerspectiveCamera()) 41 | 42 | // result must be manually disposed 43 | // when you are done using it 44 | result.dispose() 45 | -------------------------------------------------------------------------------- /examples/encode-and-compress.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unused-imports/no-unused-vars */ 2 | import { encodeAndCompress, findTextureMinMax } from '@monogrid/gainmap-js/encode' 3 | import { encodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' 4 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 5 | 6 | // load an HDR file 7 | const loader = new EXRLoader() 8 | const image = await loader.loadAsync('image.exr') 9 | 10 | // find RAW RGB Max value of a texture 11 | const textureMax = findTextureMinMax(image) 12 | 13 | // Encode the gainmap 14 | const encodingResult = await encodeAndCompress({ 15 | image, 16 | // this will encode the full HDR range 17 | maxContentBoost: Math.max.apply(this, textureMax), 18 | mimeType: 'image/jpeg' 19 | }) 20 | 21 | // embed the compressed images + metadata into a single 22 | // JPEG file 23 | const jpeg = await encodeJPEGMetadata({ 24 | ...encodingResult, 25 | sdr: encodingResult.sdr, 26 | gainMap: encodingResult.gainMap 27 | }) 28 | 29 | // `jpeg` will be an `Uint8Array` which can be saved somewhere 30 | -------------------------------------------------------------------------------- /examples/encode-jpeg-metadata.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unused-imports/no-unused-vars */ 2 | import { compress, encode, findTextureMinMax } from '@monogrid/gainmap-js/encode' 3 | import { encodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' 4 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 5 | 6 | // load an HDR file 7 | const loader = new EXRLoader() 8 | const image = await loader.loadAsync('image.exr') 9 | 10 | // find RAW RGB Max value of a texture 11 | const textureMax = findTextureMinMax(image) 12 | 13 | // Encode the gainmap 14 | const encodingResult = encode({ 15 | image, 16 | // this will encode the full HDR range 17 | maxContentBoost: Math.max.apply(this, textureMax) 18 | }) 19 | 20 | // obtain the RAW RGBA SDR buffer and create an ImageData 21 | const sdrImageData = new ImageData(encodingResult.sdr.toArray(), encodingResult.sdr.width, encodingResult.sdr.height) 22 | // obtain the RAW RGBA Gain map buffer and create an ImageData 23 | const gainMapImageData = new ImageData(encodingResult.gainMap.toArray(), encodingResult.gainMap.width, encodingResult.gainMap.height) 24 | 25 | // parallel compress the RAW buffers into the specified mimeType 26 | const mimeType = 'image/jpeg' 27 | const quality = 0.9 28 | 29 | const [sdr, gainMap] = await Promise.all([ 30 | compress({ 31 | source: sdrImageData, 32 | mimeType, 33 | quality, 34 | flipY: true // output needs to be flipped 35 | }), 36 | compress({ 37 | source: gainMapImageData, 38 | mimeType, 39 | quality, 40 | flipY: true // output needs to be flipped 41 | }) 42 | ]) 43 | 44 | // obtain the metadata which will be embedded into 45 | // and XMP tag inside the final JPEG file 46 | const metadata = encodingResult.getMetadata() 47 | 48 | // embed the compressed images + metadata into a single 49 | // JPEG file 50 | const jpeg = await encodeJPEGMetadata({ 51 | ...encodingResult, 52 | ...metadata, 53 | sdr, 54 | gainMap 55 | }) 56 | 57 | // `jpeg` will be an `Uint8Array` which can be saved somewhere 58 | 59 | // encoder must be manually disposed 60 | // when no longer needed 61 | encodingResult.gainMap.dispose() 62 | encodingResult.sdr.dispose() 63 | -------------------------------------------------------------------------------- /examples/encode.ts: -------------------------------------------------------------------------------- 1 | import { encode, findTextureMinMax } from '@monogrid/gainmap-js/encode' 2 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 3 | 4 | // load an HDR file 5 | const loader = new EXRLoader() 6 | const image = await loader.loadAsync('image.exr') 7 | 8 | // find RAW RGB Max value of a texture 9 | const textureMax = findTextureMinMax(image) 10 | 11 | // Encode the gainmap 12 | const encodingResult = encode({ 13 | image, 14 | // this will encode the full HDR range 15 | maxContentBoost: Math.max.apply(this, textureMax) 16 | }) 17 | 18 | // can be re-encoded after changing parameters 19 | encodingResult.sdr.material.exposure = 0.9 20 | encodingResult.sdr.render() 21 | // or 22 | encodingResult.gainMap.material.gamma = [1.1, 1.1, 1.1] 23 | encodingResult.gainMap.render() 24 | 25 | // do something with encodingResult.gainMap.toArray() 26 | // and encodingResult.sdr.toArray() 27 | 28 | // renderers be manually disposed 29 | encodingResult.sdr.dispose() 30 | encodingResult.gainMap.dispose() 31 | -------------------------------------------------------------------------------- /examples/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*?worker' { 2 | const workerConstructor: { 3 | new (): Worker 4 | } 5 | export default workerConstructor 6 | } 7 | -------------------------------------------------------------------------------- /examples/integrated/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /examples/integrated/README.md: -------------------------------------------------------------------------------- 1 | Examples of integrated html pages using both three.js and this library 2 | -------------------------------------------------------------------------------- /examples/integrated/jstest.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 144 | 145 | 146 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /examples/integrated/libs/basis/README.md: -------------------------------------------------------------------------------- 1 | # Basis Universal GPU Texture Compression 2 | 3 | Basis Universal is a "[supercompressed](http://gamma.cs.unc.edu/GST/gst.pdf)" 4 | GPU texture and texture video compression system that outputs a highly 5 | compressed intermediate file format (.basis) that can be quickly transcoded to 6 | a wide variety of GPU texture compression formats. 7 | 8 | [GitHub](https://github.com/BinomialLLC/basis_universal) 9 | 10 | ## Transcoders 11 | 12 | Basis Universal texture data may be used in two different file formats: 13 | `.basis` and `.ktx2`, where `ktx2` is a standardized wrapper around basis texture data. 14 | 15 | For further documentation about the Basis compressor and transcoder, refer to 16 | the [Basis GitHub repository](https://github.com/BinomialLLC/basis_universal). 17 | 18 | The folder contains two files required for transcoding `.basis` or `.ktx2` textures: 19 | 20 | * `basis_transcoder.js` — JavaScript wrapper for the WebAssembly transcoder. 21 | * `basis_transcoder.wasm` — WebAssembly transcoder. 22 | 23 | Both are dependencies of `KTX2Loader`: 24 | 25 | ```js 26 | const ktx2Loader = new KTX2Loader(); 27 | ktx2Loader.setTranscoderPath( 'examples/jsm/libs/basis/' ); 28 | ktx2Loader.detectSupport( renderer ); 29 | ktx2Loader.load( 'diffuse.ktx2', function ( texture ) { 30 | 31 | const material = new THREE.MeshStandardMaterial( { map: texture } ); 32 | 33 | }, function () { 34 | 35 | console.log( 'onProgress' ); 36 | 37 | }, function ( e ) { 38 | 39 | console.error( e ); 40 | 41 | } ); 42 | ``` 43 | 44 | ## License 45 | 46 | [Apache License 2.0](https://github.com/BinomialLLC/basis_universal/blob/master/LICENSE) 47 | -------------------------------------------------------------------------------- /examples/integrated/libs/basis/basis_transcoder.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/libs/basis/basis_transcoder.wasm -------------------------------------------------------------------------------- /examples/integrated/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: #000; 4 | color: #fff; 5 | font-family: Monospace; 6 | font-size: 13px; 7 | line-height: 24px; 8 | overscroll-behavior: none; 9 | } 10 | 11 | a { 12 | color: #ff0; 13 | text-decoration: none; 14 | } 15 | 16 | a:hover { 17 | text-decoration: underline; 18 | } 19 | 20 | button { 21 | cursor: pointer; 22 | text-transform: uppercase; 23 | } 24 | 25 | #info { 26 | position: absolute; 27 | top: 0px; 28 | width: 100%; 29 | padding: 10px; 30 | box-sizing: border-box; 31 | text-align: center; 32 | -moz-user-select: none; 33 | -webkit-user-select: none; 34 | -ms-user-select: none; 35 | user-select: none; 36 | pointer-events: none; 37 | z-index: 1; /* TODO Solve this in HTML */ 38 | } 39 | 40 | a, button, input, select { 41 | pointer-events: auto; 42 | } 43 | 44 | .lil-gui { 45 | z-index: 2 !important; /* TODO Solve this in HTML */ 46 | } 47 | 48 | @media all and ( max-width: 640px ) { 49 | .lil-gui.root { 50 | right: auto; 51 | top: auto; 52 | max-height: 50%; 53 | max-width: 80%; 54 | bottom: 0; 55 | left: 0; 56 | } 57 | } 58 | 59 | #overlay { 60 | position: absolute; 61 | font-size: 16px; 62 | z-index: 2; 63 | top: 0; 64 | left: 0; 65 | width: 100%; 66 | height: 100%; 67 | display: flex; 68 | align-items: center; 69 | justify-content: center; 70 | flex-direction: column; 71 | background: rgba(0,0,0,0.7); 72 | } 73 | 74 | #overlay button { 75 | background: transparent; 76 | border: 0; 77 | border: 1px solid rgb(255, 255, 255); 78 | border-radius: 4px; 79 | color: #ffffff; 80 | padding: 12px 18px; 81 | text-transform: uppercase; 82 | cursor: pointer; 83 | } 84 | 85 | #notSupported { 86 | width: 50%; 87 | margin: auto; 88 | background-color: #f00; 89 | margin-top: 20px; 90 | padding: 10px; 91 | } 92 | -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/qwantani_puresky_8k-gainmap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/qwantani_puresky_8k-gainmap.jpg -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/qwantani_puresky_8k-gainmap.ktx2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/qwantani_puresky_8k-gainmap.ktx2 -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/qwantani_puresky_8k.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/qwantani_puresky_8k.jpg -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/qwantani_puresky_8k.json: -------------------------------------------------------------------------------- 1 | { 2 | "gainMapMax": [ 3 | 15.868822554774999, 4 | 15.868822554774999, 5 | 15.868822554774999 6 | ], 7 | "gainMapMin": [ 8 | 0, 9 | 0, 10 | 0 11 | ], 12 | "gamma": [ 13 | 1, 14 | 1, 15 | 1 16 | ], 17 | "hdrCapacityMax": 15.868822554774999, 18 | "hdrCapacityMin": 0, 19 | "offsetHdr": [ 20 | 0.015625, 21 | 0.015625, 22 | 0.015625 23 | ], 24 | "offsetSdr": [ 25 | 0.015625, 26 | 0.015625, 27 | 0.015625 28 | ] 29 | } -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/qwantani_puresky_8k.ktx2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/qwantani_puresky_8k.ktx2 -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/spruit_sunrise_1k.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/spruit_sunrise_1k.hdr -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/spruit_sunrise_4k-gainmap.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/spruit_sunrise_4k-gainmap.webp -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/spruit_sunrise_4k.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/spruit_sunrise_4k.jpg -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/spruit_sunrise_4k.json: -------------------------------------------------------------------------------- 1 | { 2 | "gainMapMax": [ 3 | 15.99929538702341, 4 | 15.99929538702341, 5 | 15.99929538702341 6 | ], 7 | "gainMapMin": [ 8 | 0, 9 | 0, 10 | 0 11 | ], 12 | "gamma": [ 13 | 1, 14 | 1, 15 | 1 16 | ], 17 | "hdrCapacityMax": 15.99929538702341, 18 | "hdrCapacityMin": 0, 19 | "offsetHdr": [ 20 | 0.015625, 21 | 0.015625, 22 | 0.015625 23 | ], 24 | "offsetSdr": [ 25 | 0.015625, 26 | 0.015625, 27 | 0.015625 28 | ] 29 | } -------------------------------------------------------------------------------- /examples/integrated/textures/gainmap/spruit_sunrise_4k.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/examples/integrated/textures/gainmap/spruit_sunrise_4k.webp -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "strictNullChecks": true, 5 | "strictFunctionTypes": true, 6 | "noErrorTruncation": true, 7 | "noImplicitAny": true, 8 | "noImplicitThis": true, 9 | "module": "ES2022", 10 | "target": "ES2018", 11 | "moduleResolution": "Bundler", 12 | "resolvePackageJsonExports": true, 13 | "resolvePackageJsonImports": true, 14 | "allowJs": true, 15 | "checkJs": true, 16 | "noEmit": true, 17 | "skipLibCheck": true, 18 | "lib": ["ES2022", "DOM"], 19 | "paths": { 20 | "@monogrid/gainmap-js": ["../dist"], 21 | "@monogrid/gainmap-js/libultrahdr": ["../dist/libultrahdr"], 22 | "@monogrid/gainmap-js/worker": ["../dist/worker"], 23 | "@monogrid/gainmap-js/worker-interface": ["../dist/worker-interface"] 24 | }, 25 | }, 26 | "exclude": ["./integrated/libs"] 27 | } 28 | -------------------------------------------------------------------------------- /examples/worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unused-imports/no-unused-vars */ 2 | import { encodeAndCompress, findTextureMinMax } from '@monogrid/gainmap-js/encode' 3 | import { encodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' 4 | // this assumes a vite-like bundler understands the `?worker` import 5 | import GainMapWorker from '@monogrid/gainmap-js/worker?worker' 6 | import { getPromiseWorker, getWorkerInterface } from '@monogrid/gainmap-js/worker-interface' 7 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 8 | 9 | // turn our Worker into a PromiseWorker 10 | const promiseWorker = getPromiseWorker(new GainMapWorker()) 11 | // get the interface 12 | const workerInterface = getWorkerInterface(promiseWorker) 13 | 14 | // load an HDR file 15 | const loader = new EXRLoader() 16 | const image = await loader.loadAsync('image.exr') 17 | 18 | // find RAW RGB Max value of a texture 19 | const textureMax = findTextureMinMax(image) 20 | 21 | // Encode the gainmap 22 | const encodingResult = await encodeAndCompress({ 23 | image, 24 | // this will encode the full HDR range 25 | maxContentBoost: Math.max.apply(this, textureMax), 26 | // use our worker for compressing the image 27 | withWorker: workerInterface, 28 | mimeType: 'image/jpeg' 29 | }) 30 | 31 | // embed the compressed images + metadata into a single 32 | // JPEG file 33 | const jpeg = await encodeJPEGMetadata({ 34 | ...encodingResult, 35 | sdr: encodingResult.sdr, 36 | gainMap: encodingResult.gainMap 37 | }) 38 | 39 | // `jpeg` will be an `Uint8Array` which can be saved somewhere 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monogrid/gainmap-js", 3 | "version": "3.1.0", 4 | "description": "A Javascript (TypeScript) Port of Adobe Gainmap Technology for storing HDR Images using an SDR Image + a gain map", 5 | "keywords": [ 6 | "hdr", 7 | "gain map", 8 | "gainmap", 9 | "three", 10 | "threejs" 11 | ], 12 | "homepage": "https://github.com/MONOGRID/gainmap-js#readme", 13 | "bugs": { 14 | "url": "https://github.com/MONOGRID/gainmap-js/issues" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+ssh://git@github.com/MONOGRID/gainmap-js.git" 19 | }, 20 | "license": "MIT", 21 | "author": "MONOGRID ", 22 | "sideEffects": false, 23 | "type": "module", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/decode.d.ts", 27 | "import": "./dist/decode.js", 28 | "default": "./dist/decode.umd.cjs" 29 | }, 30 | "./encode": { 31 | "types": "./dist/encode.d.ts", 32 | "import": "./dist/encode.js", 33 | "default": "./dist/encode.umd.cjs" 34 | }, 35 | "./libultrahdr": { 36 | "types": "./dist/libultrahdr.d.ts", 37 | "import": "./dist/libultrahdr.js", 38 | "default": "./dist/libultrahdr.umd.cjs" 39 | }, 40 | "./worker": { 41 | "types": "./dist/worker.d.ts", 42 | "import": "./dist/worker.js", 43 | "default": "./dist/worker.umd.cjs" 44 | }, 45 | "./worker-interface": { 46 | "types": "./dist/worker-interface.d.ts", 47 | "import": "./dist/worker-interface.js", 48 | "default": "./dist/worker-interface.umd.cjs" 49 | } 50 | }, 51 | "main": "dist/decode.umd.cjs", 52 | "module": "dist/decode.js", 53 | "types": "dist/decode.d.ts", 54 | "typesVersions": { 55 | "*": { 56 | ".": [ 57 | "./dist/decode.d.ts" 58 | ], 59 | "encode": [ 60 | "./dist/encode.d.ts" 61 | ], 62 | "libultrahdr": [ 63 | "./dist/libultrahdr.d.ts" 64 | ], 65 | "worker": [ 66 | "./dist/worker.d.ts" 67 | ], 68 | "worker-interface": [ 69 | "./dist/worker-interface.d.ts" 70 | ] 71 | } 72 | }, 73 | "scripts": { 74 | "build": "rollup -c", 75 | "check": "concurrently -c auto npm:check:*", 76 | "check:eslint-examples": "eslint \"examples/**/*.{ts,html}\"", 77 | "check:eslint-src": "eslint \"src/**/*.ts\"", 78 | "check:eslint-tests": "eslint \"tests/**/*.ts\"", 79 | "check:typecheck-examples": "tsc -p examples", 80 | "check:typecheck-src": "tsc -p src", 81 | "check:typecheck-tests": "tsc -p tests", 82 | "ci:check": "concurrently npm:ci:check:*", 83 | "ci:check:eslint-examples": "eslint --format json --output-file reports/eslint-examples.json \"examples/**/*.{ts,html}\"", 84 | "ci:check:eslint-src": "eslint --format json --output-file reports/eslint-src.json \"src/**/*.ts\"", 85 | "ci:check:eslint-tests": "eslint --format json --output-file reports/eslint-tests.json \"tests/**/*.ts\"", 86 | "ci:check:typecheck-examples": "tsc --pretty false -p examples > reports/typecheck-examples.log", 87 | "ci:check:typecheck-src": "tsc --pretty false -p src > reports/typecheck.log", 88 | "ci:check:typecheck-tests": "tsc --pretty false -p tests > reports/typecheck-tests.log", 89 | "dev": "concurrently -n rollup,servez -c magenta,green \"rollup -c -w\" \"servez\"", 90 | "prepack": "npm run build", 91 | "start": "concurrently -n rollup,servez -c magenta,green \"rollup -c -w\" \"servez\"", 92 | "test": "nyc --reporter=text --reporter=lcov playwright test", 93 | "test:codegen": "playwright codegen http://localhost:8080", 94 | "test:docker": "docker run --rm --network host -v $(pwd):/work -w /work -it -u $(id -u ${USER}):$(id -g ${USER}) mcr.microsoft.com/playwright:v1.51.1-jammy /bin/bash", 95 | "test:startserver": "rollup -c && servez" 96 | }, 97 | "config": { 98 | "commitizen": { 99 | "path": "./node_modules/cz-conventional-changelog" 100 | } 101 | }, 102 | "browserslist": [ 103 | "> 1%, not dead, not ie 11, not op_mini all" 104 | ], 105 | "dependencies": { 106 | "promise-worker-transferable": "^1.0.4" 107 | }, 108 | "devDependencies": { 109 | "@eslint/eslintrc": "^3.3.1", 110 | "@playwright/test": "^1.51.1", 111 | "@rollup/plugin-commonjs": "^26.0.1", 112 | "@rollup/plugin-json": "^6.1.0", 113 | "@rollup/plugin-node-resolve": "^15.2.3", 114 | "@rollup/plugin-terser": "^0.4.4", 115 | "@rollup/plugin-typescript": "^11.1.6", 116 | "@semantic-release/changelog": "^6.0.3", 117 | "@semantic-release/commit-analyzer": "^13.0.0", 118 | "@semantic-release/git": "github:semantic-release/git", 119 | "@semantic-release/github": "^10.1.3", 120 | "@semantic-release/npm": "^12.0.1", 121 | "@semantic-release/release-notes-generator": "^14.0.1", 122 | "@types/node": "^20.14.9", 123 | "@types/three": "^0.175.0", 124 | "concurrently": "^8.2.2", 125 | "conventional-changelog-conventionalcommits": "^8.0.0", 126 | "cz-conventional-changelog": "^3.3.0", 127 | "eslint": "^9.16.0", 128 | "eslint-config-mdcs": "^5.0.0", 129 | "eslint-plugin-html": "^8.0.0", 130 | "eslint-plugin-simple-import-sort": "^12.1.1", 131 | "eslint-plugin-unused-imports": "^4.1.4", 132 | "globals": "^16.0.0", 133 | "neostandard": "^0.12.1", 134 | "nyc": "^17.0.0", 135 | "rollup": "^4.20.0", 136 | "rollup-plugin-copy": "^3.5.0", 137 | "rollup-plugin-delete": "^2.0.0", 138 | "rollup-plugin-istanbul": "^5.0.0", 139 | "rollup-plugin-license": "^3.5.2", 140 | "semantic-release": "^24.0.0", 141 | "servez": "^2.2.3", 142 | "sharp": "^0.33.4", 143 | "three": "^0.175.0", 144 | "typedoc": "^0.28.0", 145 | "typedoc-github-wiki-theme": "^2.1.0", 146 | "typedoc-plugin-markdown": "^4.2.3", 147 | "typescript": "^5.5.4" 148 | }, 149 | "peerDependencies": { 150 | "three": ">= 0.159.0" 151 | }, 152 | "publishConfig": { 153 | "access": "public", 154 | "registry": "https://registry.npmjs.org/" 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 3 : 0, 20 | 21 | /* Opt out of parallel tests on CI. */ 22 | workers: process.env.CI ? 1 : undefined, 23 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 24 | reporter: [ 25 | ['html'], 26 | ['json', { outputFile: 'playwright-report.json' }] 27 | ], 28 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 29 | use: { 30 | /* Base URL to use in actions like `await page.goto('/')`. */ 31 | baseURL: 'http://127.0.0.1:8080', 32 | 33 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 34 | trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure', 35 | 36 | // Emulates the user locale. 37 | locale: 'en-GB', 38 | 39 | // Emulates the user timezone. 40 | timezoneId: 'Europe/Paris' 41 | }, 42 | 43 | // expect timeout to 40 seconds 44 | expect: { 45 | timeout: 40 * 1000 46 | }, 47 | 48 | // test timeout to 60 seconds 49 | timeout: 60 * 1000, 50 | 51 | // https://github.com/microsoft/playwright/issues/7575#issuecomment-1693400652 52 | // same screenshot name across platforms 53 | snapshotPathTemplate: 'tests/__snapshots__/{testFilePath}/{projectName}-{arg}{ext}', 54 | 55 | /* Configure projects for major browsers */ 56 | projects: [ 57 | { 58 | name: 'chromium', 59 | use: { ...devices['Desktop Chrome'], launchOptions: { args: ['--font-render-hinting=none'] }, viewport: { width: 500, height: 500 } } 60 | } 61 | 62 | // { 63 | // name: 'firefox', 64 | // use: { ...devices['Desktop Firefox'], launchOptions: { args: ['--font-render-hinting=none'] } }, 65 | // testIgnore: ['**/panolens/**'] // FIXME: these tests don't work in this browser 66 | // }, 67 | 68 | // { 69 | // name: 'webkit', 70 | // use: { ...devices['Desktop Safari'] }, 71 | // testIgnore: ['**/panolens/**'] // FIXME: these tests don't work in this browser 72 | // } 73 | 74 | /* Test against mobile viewports. */ 75 | // { 76 | // name: 'Mobile Chrome', 77 | // use: { ...devices['Pixel 5'] }, 78 | // }, 79 | // { 80 | // name: 'Mobile Safari', 81 | // use: { ...devices['iPhone 12'] }, 82 | // }, 83 | 84 | /* Test against branded browsers. */ 85 | // { 86 | // name: 'Microsoft Edge', 87 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 88 | // }, 89 | // { 90 | // name: 'Google Chrome', 91 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 92 | // }, 93 | ], 94 | 95 | /* Run your local dev server before starting the tests */ 96 | webServer: { 97 | command: 'npm run test:startserver', 98 | url: 'http://127.0.0.1:8080', 99 | reuseExistingServer: !process.env.CI, 100 | env: { 101 | PLAYWRIGHT_TESTING: 'true' 102 | } 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('semantic-release').Options} */ 2 | module.exports = { 3 | branches: ['main'], 4 | plugins: [ 5 | ['@semantic-release/commit-analyzer', { 6 | preset: 'conventionalcommits', 7 | releaseRules: [ 8 | { breaking: true, release: 'major' }, 9 | { type: 'deps', release: 'patch' } 10 | ], 11 | parserOpts: { 12 | noteKeywords: ['BREAKING CHANGE', 'BREAKING CHANGES'] 13 | } 14 | }], 15 | '@semantic-release/release-notes-generator', 16 | '@semantic-release/npm', 17 | '@semantic-release/github', 18 | ['@semantic-release/changelog', { changelogFile: 'CHANGELOG.md' }], 19 | ['@semantic-release/git', { assets: ['package.json', 'CHANGELOG.md'] }] 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /reports/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/reports/.gitkeep -------------------------------------------------------------------------------- /rollup.config.decodeonly.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import json from '@rollup/plugin-json' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import typescript from '@rollup/plugin-typescript' 5 | import { defineConfig } from 'rollup' 6 | import del from 'rollup-plugin-delete' 7 | // @ts-expect-error untyped library 8 | import istanbul from 'rollup-plugin-istanbul' 9 | import license from 'rollup-plugin-license' 10 | 11 | // @ts-expect-error tsc + rollup fight each other 12 | import pkgJSON from './package.json' assert { type: 'json' } 13 | 14 | const { author, name, version } = pkgJSON 15 | 16 | /** @type {import('rollup').OutputOptions} */ 17 | const settings = { 18 | globals: { 19 | three: 'three' 20 | }, 21 | sourcemap: !!process.env.PLAYWRIGHT_TESTING 22 | } 23 | 24 | const configBase = defineConfig({ 25 | external: ['three'] 26 | }) 27 | 28 | /** @type {import('rollup').InputPluginOption[]} */ 29 | const plugins = [ 30 | json(), 31 | typescript({ 32 | tsconfig: 'src/tsconfig.json', 33 | declaration: true, 34 | sourceMap: !!process.env.PLAYWRIGHT_TESTING, 35 | declarationDir: 'dist', 36 | include: ['src/**/*.ts'], 37 | exclude: ['src/libultrahdr.ts', 'src/libultrahdr/**/*.ts', 'src/encode.ts', 'src/encode/**/*.ts', 'src/worker*.ts'] 38 | }), 39 | resolve(), 40 | commonjs({ 41 | include: 'node_modules/**', 42 | extensions: ['.js'], 43 | ignoreGlobal: false, 44 | sourceMap: !!process.env.PLAYWRIGHT_TESTING 45 | }), 46 | license({ 47 | banner: ` 48 | ${name} v${version} 49 | With ❤️, by ${author} 50 | ` 51 | }) 52 | ] 53 | 54 | if (process.env.PLAYWRIGHT_TESTING) { 55 | plugins.push( 56 | istanbul({ 57 | include: ['src/**/*.ts'] 58 | 59 | }) 60 | ) 61 | } 62 | 63 | /** @type {import('rollup').RollupOptions[]} */ 64 | let configs = [ 65 | defineConfig({ 66 | input: { 67 | decode: './src/decode.ts' 68 | }, 69 | output: { 70 | dir: 'dist', 71 | name, 72 | format: 'es', 73 | ...settings 74 | }, 75 | plugins: [ 76 | del({ targets: 'dist/*' }), 77 | ...plugins 78 | ], 79 | ...configBase 80 | }) 81 | ] 82 | 83 | // configs to produce when not testing 84 | // with playwright 85 | if (!process.env.PLAYWRIGHT_TESTING) { 86 | configs = configs.concat([ 87 | // decode UMD 88 | defineConfig({ 89 | input: './src/decode.ts', 90 | output: { 91 | format: 'umd', 92 | name, 93 | file: 'dist/decode.umd.js', 94 | ...settings 95 | }, 96 | plugins, 97 | ...configBase 98 | }) 99 | ]) 100 | } 101 | 102 | export default configs 103 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import json from '@rollup/plugin-json' 3 | import resolve from '@rollup/plugin-node-resolve' 4 | import typescript from '@rollup/plugin-typescript' 5 | import { defineConfig } from 'rollup' 6 | import copy from 'rollup-plugin-copy' 7 | import del from 'rollup-plugin-delete' 8 | // @ts-expect-error untyped library 9 | import istanbul from 'rollup-plugin-istanbul' 10 | import license from 'rollup-plugin-license' 11 | 12 | // @ts-expect-error tsc + rollup fight each other 13 | import pkgJSON from './package.json' assert { type: 'json' } 14 | 15 | const { author, name, version } = pkgJSON 16 | 17 | /** @type {import('rollup').OutputOptions} */ 18 | const settings = { 19 | globals: { 20 | three: 'three' 21 | }, 22 | sourcemap: !!process.env.PLAYWRIGHT_TESTING 23 | } 24 | 25 | const configBase = defineConfig({ 26 | external: ['three'] 27 | }) 28 | 29 | /** @type {import('rollup').InputPluginOption[]} */ 30 | const plugins = [ 31 | json(), 32 | typescript({ 33 | tsconfig: 'src/tsconfig.json', 34 | declaration: true, 35 | sourceMap: !!process.env.PLAYWRIGHT_TESTING, 36 | declarationDir: 'dist', 37 | include: ['src/**/*.ts'] 38 | }), 39 | resolve(), 40 | commonjs({ 41 | include: 'node_modules/**', 42 | extensions: ['.js'], 43 | ignoreGlobal: false, 44 | sourceMap: !!process.env.PLAYWRIGHT_TESTING 45 | }), 46 | license({ 47 | banner: ` 48 | ${name} v${version} 49 | With ❤️, by ${author} 50 | ` 51 | }) 52 | ] 53 | 54 | if (process.env.PLAYWRIGHT_TESTING) { 55 | plugins.push( 56 | istanbul({ 57 | include: ['src/**/*.ts'] 58 | 59 | }) 60 | ) 61 | } 62 | 63 | /** @type {import('rollup').RollupOptions[]} */ 64 | let configs = [ 65 | defineConfig({ 66 | input: { 67 | encode: './src/encode.ts', 68 | decode: './src/decode.ts', 69 | libultrahdr: './src/libultrahdr.ts', 70 | worker: './src/worker.ts', 71 | 'worker-interface': './src/worker-interface.ts' 72 | }, 73 | output: { 74 | dir: 'dist', 75 | name, 76 | format: 'es', 77 | ...settings 78 | }, 79 | plugins: [ 80 | del({ targets: 'dist/*' }), 81 | copy({ 82 | targets: [ 83 | { src: 'libultrahdr-wasm/build/libultrahdr-esm.wasm', dest: 'dist' } 84 | ] 85 | }), 86 | ...plugins 87 | ], 88 | ...configBase 89 | }), 90 | 91 | // worker UMD 92 | defineConfig({ 93 | input: './src/worker.ts', 94 | output: { 95 | format: 'umd', 96 | name: 'worker', 97 | file: 'dist/worker.umd.cjs', 98 | ...settings 99 | }, 100 | plugins, 101 | ...configBase 102 | }) 103 | ] 104 | 105 | // configs to produce when not testing 106 | // with playwright 107 | if (!process.env.PLAYWRIGHT_TESTING) { 108 | configs = configs.concat([ 109 | 110 | // decode UMD 111 | defineConfig({ 112 | input: './src/decode.ts', 113 | output: { 114 | format: 'umd', 115 | name, 116 | file: 'dist/decode.umd.cjs', 117 | ...settings 118 | }, 119 | plugins, 120 | ...configBase 121 | }), 122 | 123 | // encode UMD 124 | defineConfig({ 125 | input: './src/encode.ts', 126 | output: { 127 | format: 'umd', 128 | name: 'encode', 129 | file: 'dist/encode.umd.cjs', 130 | ...settings 131 | }, 132 | plugins, 133 | ...configBase 134 | }), 135 | 136 | // libultrahdr UMD 137 | defineConfig({ 138 | input: './src/libultrahdr.ts', 139 | output: { 140 | format: 'umd', 141 | name: 'libultrahdr', 142 | file: 'dist/libultrahdr.umd.cjs', 143 | ...settings 144 | }, 145 | plugins, 146 | ...configBase 147 | }), 148 | 149 | // worker interface umd 150 | defineConfig({ 151 | input: './src/worker-interface.ts', 152 | output: { 153 | format: 'umd', 154 | name: 'worker-interface', 155 | file: 'dist/worker-interface.umd.cjs', 156 | ...settings 157 | }, 158 | plugins, 159 | ...configBase 160 | }) 161 | ]) 162 | } 163 | 164 | export default configs 165 | -------------------------------------------------------------------------------- /src/core/get-data-texture.ts: -------------------------------------------------------------------------------- 1 | import { DataTexture, LinearFilter, LinearSRGBColorSpace, RepeatWrapping, RGBAFormat, UVMapping } from 'three' 2 | import { EXR } from 'three/examples/jsm/loaders/EXRLoader' 3 | import { RGBE } from 'three/examples/jsm/loaders/RGBELoader' 4 | /** 5 | * Utility function to obtain a `DataTexture` from various input formats 6 | * 7 | * @category Utility 8 | * @group Utility 9 | * 10 | * @param image 11 | * @returns 12 | */ 13 | export const getDataTexture = (image: EXR | RGBE | DataTexture) => { 14 | let dataTexture: DataTexture 15 | 16 | if (image instanceof DataTexture) { 17 | if (!(image.image.data instanceof Uint16Array) && !(image.image.data instanceof Float32Array)) { 18 | throw new Error('Provided image is not HDR') 19 | } 20 | dataTexture = image 21 | } else { 22 | dataTexture = new DataTexture( 23 | image.data, 24 | image.width, 25 | image.height, 26 | 'format' in image ? image.format : RGBAFormat, 27 | image.type, 28 | UVMapping, 29 | RepeatWrapping, 30 | RepeatWrapping, 31 | LinearFilter, 32 | LinearFilter, 33 | 1, 34 | 'colorSpace' in image && image.colorSpace === 'srgb' ? image.colorSpace : LinearSRGBColorSpace 35 | ) 36 | 37 | // TODO: This tries to detect a raw RGBE and applies flipY 38 | // see if there's a better way to detect it? 39 | if ('header' in image && 'gamma' in image) { 40 | dataTexture.flipY = true 41 | } 42 | dataTexture.needsUpdate = true 43 | } 44 | 45 | return dataTexture 46 | } 47 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-data-texture' 2 | export * from './QuadRenderer' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { Mapping, RenderTargetOptions } from 'three' 2 | 3 | /** 4 | * This is the Metadata stored in an encoded Gainmap which is used 5 | * to decode it and return an HDR image 6 | * 7 | * @category Specs 8 | * @group Specs 9 | */ 10 | export type GainMapMetadata = { 11 | /** 12 | * This is the gamma to apply to the stored map values. 13 | * @defaultValue [1, 1, 1] 14 | * @remarks 15 | * * Typically you can use a gamma of 1.0. 16 | * * You can use a different value if your gain map has a very uneven distribution of log_recovery(x, y) values. 17 | * 18 | * For example, this might apply if a gain map has a lot of detail just above SDR range (represented as small log_recovery(x, y) values), 19 | * and a very large map_max_log2 for the top end of the HDR rendition's desired brightness (represented by large log_recovery(x, y) values). 20 | * In this case, you can use a map_gamma higher than 1.0 so that recovery(x, y) can precisely encode the detail in both the low end and high end of log_recovery(x, y). 21 | */ 22 | gamma: [number, number, number] 23 | /** 24 | * This is log2 of the minimum display boost value for which the map is applied at all. 25 | * 26 | * @remarks 27 | * * This value also affects how much to apply the gain map based on the display boost. 28 | * * Must be 0.0 or greater. 29 | * 30 | * Logarithmic space 31 | */ 32 | hdrCapacityMin: number 33 | /** 34 | * Stores the value of hdr_capacity_max. This is log2 of the maximum display boost value for which the map is applied completely. 35 | * 36 | * @remarks 37 | * * This value also affects how much to apply the gain map based on the display boost. 38 | * * Must be greater than hdrCapacityMin. 39 | * * Required. 40 | * 41 | * Logarithmic space 42 | */ 43 | hdrCapacityMax: number 44 | /** 45 | * This is the offset to apply to the SDR pixel values during gain map generation and application 46 | * @defaultValue [1/64, 1/64, 1/64] 47 | */ 48 | offsetSdr: [number, number, number] 49 | /** 50 | * This is the offset to apply to the HDR pixel values during gain map generation and application. 51 | * @defaultValue [1/64, 1/64, 1/64] 52 | */ 53 | offsetHdr: [number, number, number] 54 | /** 55 | * This is log2 of min content boost, which is the minimum allowed ratio of 56 | * the linear luminance for the target HDR rendition relative to 57 | * (divided by) that of the SDR image, at a given pixel. 58 | */ 59 | gainMapMin: [number, number, number] 60 | /** 61 | * This is log2 of max content boost, which is the maximum allowed ratio of 62 | * the linear luminance for the Target HDR rendition relative to 63 | * (divided by) that of the SDR image, at a given pixel. 64 | */ 65 | gainMapMax: [number, number, number] 66 | } 67 | 68 | /** 69 | * 70 | */ 71 | export type QuadRendererTextureOptions = Omit & { 72 | /** 73 | * @defaultValue 300 74 | */ 75 | mapping?: Mapping, 76 | /** 77 | * @defaultValue 1 78 | */ 79 | anisotropy?: number 80 | } 81 | -------------------------------------------------------------------------------- /src/decode.ts: -------------------------------------------------------------------------------- 1 | export * from './decode/index' 2 | -------------------------------------------------------------------------------- /src/decode/decode.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HalfFloatType, 3 | LinearSRGBColorSpace, 4 | SRGBColorSpace 5 | } from 'three' 6 | 7 | import { QuadRenderer } from '../core/QuadRenderer' 8 | import { GainMapDecoderMaterial } from './materials/GainMapDecoderMaterial' 9 | import { DecodeParameters } from './types' 10 | 11 | /** 12 | * Decodes a gain map using a WebGLRenderTarget 13 | * 14 | * @category Decoding Functions 15 | * @group Decoding Functions 16 | * @example 17 | * import { decode } from '@monogrid/gainmap-js' 18 | * import { 19 | * Mesh, 20 | * MeshBasicMaterial, 21 | * PerspectiveCamera, 22 | * PlaneGeometry, 23 | * Scene, 24 | * TextureLoader, 25 | * WebGLRenderer 26 | * } from 'three' 27 | * 28 | * const renderer = new WebGLRenderer() 29 | * 30 | * const textureLoader = new TextureLoader() 31 | * 32 | * // load SDR Representation 33 | * const sdr = await textureLoader.loadAsync('sdr.jpg') 34 | * // load Gain map recovery image 35 | * const gainMap = await textureLoader.loadAsync('gainmap.jpg') 36 | * // load metadata 37 | * const metadata = await (await fetch('metadata.json')).json() 38 | * 39 | * const result = await decode({ 40 | * sdr, 41 | * gainMap, 42 | * // this allows to use `result.renderTarget.texture` directly 43 | * renderer, 44 | * // this will restore the full HDR range 45 | * maxDisplayBoost: Math.pow(2, metadata.hdrCapacityMax), 46 | * ...metadata 47 | * }) 48 | * 49 | * const scene = new Scene() 50 | * // `result` can be used to populate a Texture 51 | * const mesh = new Mesh( 52 | * new PlaneGeometry(), 53 | * new MeshBasicMaterial({ map: result.renderTarget.texture }) 54 | * ) 55 | * scene.add(mesh) 56 | * renderer.render(scene, new PerspectiveCamera()) 57 | * 58 | * // result must be manually disposed 59 | * // when you are done using it 60 | * result.dispose() 61 | * 62 | * @param params 63 | * @returns 64 | * @throws {Error} if the WebGLRenderer fails to render the gain map 65 | */ 66 | export const decode = (params: DecodeParameters): InstanceType>> => { 67 | const { sdr, gainMap, renderer } = params 68 | 69 | if (sdr.colorSpace !== SRGBColorSpace) { 70 | console.warn('SDR Colorspace needs to be *SRGBColorSpace*, setting it automatically') 71 | sdr.colorSpace = SRGBColorSpace 72 | } 73 | sdr.needsUpdate = true 74 | 75 | if (gainMap.colorSpace !== LinearSRGBColorSpace) { 76 | console.warn('Gainmap Colorspace needs to be *LinearSRGBColorSpace*, setting it automatically') 77 | gainMap.colorSpace = LinearSRGBColorSpace 78 | } 79 | gainMap.needsUpdate = true 80 | 81 | const material = new GainMapDecoderMaterial({ 82 | ...params, 83 | sdr, 84 | gainMap 85 | }) 86 | const quadRenderer = new QuadRenderer({ 87 | // TODO: three types are generic, eslint complains here, see how we can solve 88 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 89 | width: sdr.image.width, 90 | // TODO: three types are generic, eslint complains here, see how we can solve 91 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access 92 | height: sdr.image.height, 93 | type: HalfFloatType, 94 | colorSpace: LinearSRGBColorSpace, 95 | material, 96 | renderer, 97 | renderTargetOptions: params.renderTargetOptions 98 | }) 99 | try { 100 | quadRenderer.render() 101 | } catch (e) { 102 | quadRenderer.disposeOnDemandRenderer() 103 | throw e 104 | } 105 | return quadRenderer 106 | } 107 | -------------------------------------------------------------------------------- /src/decode/errors/GainMapNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export class GainMapNotFoundError extends Error {} 2 | -------------------------------------------------------------------------------- /src/decode/errors/XMPMetadataNotFoundError.ts: -------------------------------------------------------------------------------- 1 | export class XMPMetadataNotFoundError extends Error {} 2 | -------------------------------------------------------------------------------- /src/decode/extract.ts: -------------------------------------------------------------------------------- 1 | import { GainMapNotFoundError } from './errors/GainMapNotFoundError' 2 | import { XMPMetadataNotFoundError } from './errors/XMPMetadataNotFoundError' 3 | import { extractXMP } from './utils/extractXMP' 4 | import { MPFExtractor } from './utils/MPFExtractor' 5 | /** 6 | * Extracts XMP Metadata and the gain map recovery image 7 | * from a single JPEG file. 8 | * 9 | * @category Decoding Functions 10 | * @group Decoding Functions 11 | * @param jpegFile an `Uint8Array` containing and encoded JPEG file 12 | * @returns an sdr `Uint8Array` compressed in JPEG, a gainMap `Uint8Array` compressed in JPEG and the XMP parsed XMP metadata 13 | * @throws Error if XMP Metadata is not found 14 | * @throws Error if Gain map image is not found 15 | * @example 16 | * import { FileLoader } from 'three' 17 | * import { extractGainmapFromJPEG } from '@monogrid/gainmap-js' 18 | * 19 | * const jpegFile = await new FileLoader() 20 | * .setResponseType('arraybuffer') 21 | * .loadAsync('image.jpg') 22 | * 23 | * const { sdr, gainMap, metadata } = extractGainmapFromJPEG(jpegFile) 24 | */ 25 | export const extractGainmapFromJPEG = async (jpegFile: Uint8Array) => { 26 | const metadata = extractXMP(jpegFile) 27 | if (!metadata) throw new XMPMetadataNotFoundError('Gain map XMP metadata not found') 28 | 29 | const mpfExtractor = new MPFExtractor({ extractFII: true, extractNonFII: true }) 30 | const images = await mpfExtractor.extract(jpegFile) 31 | if (images.length !== 2) throw new GainMapNotFoundError('Gain map recovery image not found') 32 | 33 | return { 34 | sdr: new Uint8Array(await images[0].arrayBuffer()), 35 | gainMap: new Uint8Array(await images[1].arrayBuffer()), 36 | metadata 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/decode/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../core/QuadRenderer' 2 | export * from '../core/types' 3 | export * from './decode' 4 | export * from './extract' 5 | export * from './loaders/GainMapLoader' 6 | export * from './loaders/HDRJPGLoader' 7 | // Legacy name, TODO: can be removed with next breaking change release 8 | export { HDRJPGLoader as JPEGRLoader } from './loaders/HDRJPGLoader' 9 | export * from './materials/GainMapDecoderMaterial' 10 | export * from './types' 11 | export * from './utils/extractXMP' 12 | export * from './utils/MPFExtractor' 13 | -------------------------------------------------------------------------------- /src/decode/loaders/HDRJPGLoader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileLoader, 3 | HalfFloatType 4 | } from 'three' 5 | 6 | import { QuadRenderer } from '../../core/QuadRenderer' 7 | import { GainMapNotFoundError } from '../errors/GainMapNotFoundError' 8 | import { XMPMetadataNotFoundError } from '../errors/XMPMetadataNotFoundError' 9 | import { extractGainmapFromJPEG } from '../extract' 10 | import { GainMapMetadata } from '../index' 11 | import { GainMapDecoderMaterial } from '../materials/GainMapDecoderMaterial' 12 | import { LoaderBase } from './LoaderBase' 13 | 14 | /** 15 | * A Three.js Loader for a JPEG with embedded gainmap metadata. 16 | * 17 | * @category Loaders 18 | * @group Loaders 19 | * 20 | * @example 21 | * import { HDRJPGLoader } from '@monogrid/gainmap-js' 22 | * import { 23 | * EquirectangularReflectionMapping, 24 | * LinearFilter, 25 | * Mesh, 26 | * MeshBasicMaterial, 27 | * PerspectiveCamera, 28 | * PlaneGeometry, 29 | * Scene, 30 | * WebGLRenderer 31 | * } from 'three' 32 | * 33 | * const renderer = new WebGLRenderer() 34 | * 35 | * const loader = new HDRJPGLoader(renderer) 36 | * 37 | * const result = await loader.loadAsync('gainmap.jpeg') 38 | * // `result` can be used to populate a Texture 39 | * 40 | * const scene = new Scene() 41 | * const mesh = new Mesh( 42 | * new PlaneGeometry(), 43 | * new MeshBasicMaterial({ map: result.renderTarget.texture }) 44 | * ) 45 | * scene.add(mesh) 46 | * renderer.render(scene, new PerspectiveCamera()) 47 | * 48 | * // Starting from three.js r159 49 | * // `result.renderTarget.texture` can 50 | * // also be used as Equirectangular scene background 51 | * // 52 | * // it was previously needed to convert it 53 | * // to a DataTexture with `result.toDataTexture()` 54 | * scene.background = result.renderTarget.texture 55 | * scene.background.mapping = EquirectangularReflectionMapping 56 | * 57 | * // result must be manually disposed 58 | * // when you are done using it 59 | * result.dispose() 60 | * 61 | */ 62 | export class HDRJPGLoader extends LoaderBase { 63 | /** 64 | * Loads a JPEG containing gain map metadata 65 | * Renders a normal SDR image if gainmap data is not found 66 | * 67 | * @param url An array in the form of [sdr.jpg, gainmap.jpg, metadata.json] 68 | * @param onLoad Load complete callback, will receive the result 69 | * @param onProgress Progress callback, will receive a `ProgressEvent` 70 | * @param onError Error callback 71 | * @returns 72 | */ 73 | public override load (url: string, onLoad?: (data: QuadRenderer) => void, onProgress?: (event: ProgressEvent) => void, onError?: (err: unknown) => void): QuadRenderer { 74 | const quadRenderer = this.prepareQuadRenderer() 75 | 76 | const loader = new FileLoader(this._internalLoadingManager) 77 | loader.setResponseType('arraybuffer') 78 | loader.setRequestHeader(this.requestHeader) 79 | loader.setPath(this.path) 80 | loader.setWithCredentials(this.withCredentials) 81 | this.manager.itemStart(url) 82 | loader.load(url, async (jpeg) => { 83 | /* istanbul ignore if 84 | this condition exists only because of three.js types + strict mode 85 | */ 86 | if (typeof jpeg === 'string') throw new Error('Invalid buffer, received [string], was expecting [ArrayBuffer]') 87 | const jpegBuffer = new Uint8Array(jpeg) 88 | let sdrJPEG: Uint8Array 89 | let gainMapJPEG: Uint8Array | undefined 90 | let metadata: GainMapMetadata 91 | try { 92 | const extractionResult = await extractGainmapFromJPEG(jpegBuffer) 93 | // gain map is successfully reconstructed 94 | sdrJPEG = extractionResult.sdr 95 | gainMapJPEG = extractionResult.gainMap 96 | metadata = extractionResult.metadata 97 | } catch (e: unknown) { 98 | // render the SDR version if this is not a gainmap 99 | if (e instanceof XMPMetadataNotFoundError || e instanceof GainMapNotFoundError) { 100 | console.warn(`Failure to reconstruct an HDR image from ${url}: Gain map metadata not found in the file, HDRJPGLoader will render the SDR jpeg`) 101 | metadata = { 102 | gainMapMin: [0, 0, 0], 103 | gainMapMax: [1, 1, 1], 104 | gamma: [1, 1, 1], 105 | hdrCapacityMin: 0, 106 | hdrCapacityMax: 1, 107 | offsetHdr: [0, 0, 0], 108 | offsetSdr: [0, 0, 0] 109 | } 110 | sdrJPEG = jpegBuffer 111 | } else { 112 | throw e 113 | } 114 | } 115 | 116 | // solves #16 117 | try { 118 | await this.render(quadRenderer, metadata, sdrJPEG, gainMapJPEG) 119 | } catch (error) { 120 | this.manager.itemError(url) 121 | if (typeof onError === 'function') onError(error) 122 | quadRenderer.disposeOnDemandRenderer() 123 | return 124 | } 125 | 126 | if (typeof onLoad === 'function') onLoad(quadRenderer) 127 | this.manager.itemEnd(url) 128 | quadRenderer.disposeOnDemandRenderer() 129 | }, onProgress 130 | , (error: unknown) => { 131 | this.manager.itemError(url) 132 | if (typeof onError === 'function') onError(error) 133 | }) 134 | 135 | return quadRenderer 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/decode/loaders/LoaderBase.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClampToEdgeWrapping, 3 | HalfFloatType, 4 | LinearFilter, 5 | LinearMipMapLinearFilter, 6 | LinearSRGBColorSpace, 7 | Loader, 8 | LoadingManager, 9 | RGBAFormat, 10 | SRGBColorSpace, 11 | Texture, 12 | UnsignedByteType, 13 | UVMapping, 14 | WebGLRenderer 15 | } from 'three' 16 | 17 | import { QuadRenderer } from '../../core/QuadRenderer' 18 | import { type GainMapMetadata, QuadRendererTextureOptions } from '../../core/types' 19 | import { GainMapDecoderMaterial } from '../materials/GainMapDecoderMaterial' 20 | import { getHTMLImageFromBlob } from '../utils/get-html-image-from-blob' 21 | 22 | export class LoaderBase extends Loader, TUrl> { 23 | private _renderer?: WebGLRenderer 24 | private _renderTargetOptions?: QuadRendererTextureOptions 25 | /** 26 | * @private 27 | */ 28 | protected _internalLoadingManager: LoadingManager 29 | /** 30 | * 31 | * @param renderer 32 | * @param manager 33 | */ 34 | constructor (renderer?: WebGLRenderer, manager?: LoadingManager) { 35 | super(manager) 36 | if (renderer) this._renderer = renderer 37 | this._internalLoadingManager = new LoadingManager() 38 | } 39 | 40 | /** 41 | * Specify the renderer to use when rendering the gain map 42 | * 43 | * @param renderer 44 | * @returns 45 | */ 46 | public setRenderer (renderer: WebGLRenderer) { 47 | this._renderer = renderer 48 | return this 49 | } 50 | 51 | /** 52 | * Specify the renderTarget options to use when rendering the gain map 53 | * 54 | * @param options 55 | * @returns 56 | */ 57 | public setRenderTargetOptions (options: QuadRendererTextureOptions) { 58 | this._renderTargetOptions = options 59 | return this 60 | } 61 | 62 | /** 63 | * @private 64 | * @returns 65 | */ 66 | protected prepareQuadRenderer () { 67 | if (!this._renderer) console.warn('WARNING: An existing WebGL Renderer was not passed to this Loader constructor or in setRenderer, the result of this Loader will need to be converted to a Data Texture with toDataTexture() before you can use it in your renderer.') 68 | 69 | // temporary values 70 | const material = new GainMapDecoderMaterial({ 71 | gainMapMax: [1, 1, 1], 72 | gainMapMin: [0, 0, 0], 73 | gamma: [1, 1, 1], 74 | offsetHdr: [1, 1, 1], 75 | offsetSdr: [1, 1, 1], 76 | hdrCapacityMax: 1, 77 | hdrCapacityMin: 0, 78 | maxDisplayBoost: 1, 79 | gainMap: new Texture(), 80 | sdr: new Texture() 81 | }) 82 | 83 | return new QuadRenderer({ 84 | width: 16, 85 | height: 16, 86 | type: HalfFloatType, 87 | colorSpace: LinearSRGBColorSpace, 88 | material, 89 | renderer: this._renderer, 90 | renderTargetOptions: this._renderTargetOptions 91 | }) 92 | } 93 | 94 | /** 95 | * @private 96 | * @param quadRenderer 97 | * @param metadata 98 | * @param sdrBuffer 99 | * @param gainMapBuffer 100 | */ 101 | protected async render (quadRenderer: QuadRenderer, metadata: GainMapMetadata, sdrBuffer: ArrayBuffer, gainMapBuffer?: ArrayBuffer) { 102 | // this is optional, will render a black gain-map if not present 103 | const gainMapBlob = gainMapBuffer ? new Blob([gainMapBuffer], { type: 'image/jpeg' }) : undefined 104 | 105 | const sdrBlob = new Blob([sdrBuffer], { type: 'image/jpeg' }) 106 | 107 | let sdrImage: ImageBitmap | HTMLImageElement 108 | let gainMapImage: ImageBitmap | HTMLImageElement | undefined 109 | 110 | let needsFlip = false 111 | 112 | if (typeof createImageBitmap === 'undefined') { 113 | const res = await Promise.all([ 114 | gainMapBlob ? getHTMLImageFromBlob(gainMapBlob) : Promise.resolve(undefined), 115 | getHTMLImageFromBlob(sdrBlob) 116 | ]) 117 | 118 | gainMapImage = res[0] 119 | sdrImage = res[1] 120 | 121 | needsFlip = true 122 | } else { 123 | const res = await Promise.all([ 124 | gainMapBlob ? createImageBitmap(gainMapBlob, { imageOrientation: 'flipY' }) : Promise.resolve(undefined), 125 | createImageBitmap(sdrBlob, { imageOrientation: 'flipY' }) 126 | ]) 127 | 128 | gainMapImage = res[0] 129 | sdrImage = res[1] 130 | } 131 | 132 | const gainMap = new Texture(gainMapImage || new ImageData(2, 2), 133 | UVMapping, 134 | ClampToEdgeWrapping, 135 | ClampToEdgeWrapping, 136 | LinearFilter, 137 | LinearMipMapLinearFilter, 138 | RGBAFormat, 139 | UnsignedByteType, 140 | 1, 141 | LinearSRGBColorSpace 142 | ) 143 | 144 | gainMap.flipY = needsFlip 145 | gainMap.needsUpdate = true 146 | 147 | const sdr = new Texture(sdrImage, 148 | UVMapping, 149 | ClampToEdgeWrapping, 150 | ClampToEdgeWrapping, 151 | LinearFilter, 152 | LinearMipMapLinearFilter, 153 | RGBAFormat, 154 | UnsignedByteType, 155 | 1, 156 | SRGBColorSpace 157 | ) 158 | 159 | sdr.flipY = needsFlip 160 | sdr.needsUpdate = true 161 | 162 | quadRenderer.width = sdrImage.width 163 | quadRenderer.height = sdrImage.height 164 | quadRenderer.material.gainMap = gainMap 165 | quadRenderer.material.sdr = sdr 166 | quadRenderer.material.gainMapMin = metadata.gainMapMin 167 | quadRenderer.material.gainMapMax = metadata.gainMapMax 168 | quadRenderer.material.offsetHdr = metadata.offsetHdr 169 | quadRenderer.material.offsetSdr = metadata.offsetSdr 170 | quadRenderer.material.gamma = metadata.gamma 171 | quadRenderer.material.hdrCapacityMin = metadata.hdrCapacityMin 172 | quadRenderer.material.hdrCapacityMax = metadata.hdrCapacityMax 173 | quadRenderer.material.maxDisplayBoost = Math.pow(2, metadata.hdrCapacityMax) 174 | quadRenderer.material.needsUpdate = true 175 | 176 | quadRenderer.render() 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/decode/materials/GainMapDecoderMaterial.ts: -------------------------------------------------------------------------------- 1 | import { NoBlending, ShaderMaterial, Texture, Vector3 } from 'three' 2 | 3 | import { GainMapMetadata } from '../../core/types' 4 | import { GainmapDecodingParameters } from '../types' 5 | 6 | const vertexShader = /* glsl */` 7 | varying vec2 vUv; 8 | 9 | void main() { 10 | vUv = uv; 11 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 12 | } 13 | ` 14 | 15 | const fragmentShader = /* glsl */` 16 | // min half float value 17 | #define HALF_FLOAT_MIN vec3( -65504, -65504, -65504 ) 18 | // max half float value 19 | #define HALF_FLOAT_MAX vec3( 65504, 65504, 65504 ) 20 | 21 | uniform sampler2D sdr; 22 | uniform sampler2D gainMap; 23 | uniform vec3 gamma; 24 | uniform vec3 offsetHdr; 25 | uniform vec3 offsetSdr; 26 | uniform vec3 gainMapMin; 27 | uniform vec3 gainMapMax; 28 | uniform float weightFactor; 29 | 30 | varying vec2 vUv; 31 | 32 | void main() { 33 | vec3 rgb = texture2D( sdr, vUv ).rgb; 34 | vec3 recovery = texture2D( gainMap, vUv ).rgb; 35 | vec3 logRecovery = pow( recovery, gamma ); 36 | vec3 logBoost = gainMapMin * ( 1.0 - logRecovery ) + gainMapMax * logRecovery; 37 | vec3 hdrColor = (rgb + offsetSdr) * exp2( logBoost * weightFactor ) - offsetHdr; 38 | vec3 clampedHdrColor = max( HALF_FLOAT_MIN, min( HALF_FLOAT_MAX, hdrColor )); 39 | gl_FragColor = vec4( clampedHdrColor , 1.0 ); 40 | } 41 | ` 42 | /** 43 | * A Material which is able to decode the Gainmap into a full HDR Representation 44 | * 45 | * @category Materials 46 | * @group Materials 47 | */ 48 | export class GainMapDecoderMaterial extends ShaderMaterial { 49 | private _maxDisplayBoost: GainmapDecodingParameters['maxDisplayBoost'] 50 | private _hdrCapacityMin: GainMapMetadata['hdrCapacityMin'] 51 | private _hdrCapacityMax: GainMapMetadata['hdrCapacityMax'] 52 | /** 53 | * 54 | * @param params 55 | */ 56 | constructor ({ gamma, offsetHdr, offsetSdr, gainMapMin, gainMapMax, maxDisplayBoost, hdrCapacityMin, hdrCapacityMax, sdr, gainMap }: GainMapMetadata & GainmapDecodingParameters & { sdr: Texture, gainMap: Texture }) { 57 | super({ 58 | name: 'GainMapDecoderMaterial', 59 | vertexShader, 60 | fragmentShader, 61 | uniforms: { 62 | sdr: { value: sdr }, 63 | gainMap: { value: gainMap }, 64 | gamma: { value: new Vector3(1.0 / gamma[0], 1.0 / gamma[1], 1.0 / gamma[2]) }, 65 | offsetHdr: { value: new Vector3().fromArray(offsetHdr) }, 66 | offsetSdr: { value: new Vector3().fromArray(offsetSdr) }, 67 | gainMapMin: { value: new Vector3().fromArray(gainMapMin) }, 68 | gainMapMax: { value: new Vector3().fromArray(gainMapMax) }, 69 | weightFactor: { 70 | value: (Math.log2(maxDisplayBoost) - hdrCapacityMin) / (hdrCapacityMax - hdrCapacityMin) 71 | } 72 | }, 73 | blending: NoBlending, 74 | depthTest: false, 75 | depthWrite: false 76 | }) 77 | 78 | this._maxDisplayBoost = maxDisplayBoost 79 | this._hdrCapacityMin = hdrCapacityMin 80 | this._hdrCapacityMax = hdrCapacityMax 81 | 82 | this.needsUpdate = true 83 | this.uniformsNeedUpdate = true 84 | } 85 | 86 | get sdr () { return this.uniforms.sdr.value as Texture } 87 | set sdr (value: Texture) { this.uniforms.sdr.value = value } 88 | 89 | get gainMap () { return this.uniforms.gainMap.value as Texture } 90 | set gainMap (value: Texture) { this.uniforms.gainMap.value = value } 91 | /** 92 | * @see {@link GainMapMetadata.offsetHdr} 93 | */ 94 | get offsetHdr () { return (this.uniforms.offsetHdr.value as Vector3).toArray() } 95 | set offsetHdr (value: [number, number, number]) { (this.uniforms.offsetHdr.value as Vector3).fromArray(value) } 96 | /** 97 | * @see {@link GainMapMetadata.offsetSdr} 98 | */ 99 | get offsetSdr () { return (this.uniforms.offsetSdr.value as Vector3).toArray() } 100 | set offsetSdr (value: [number, number, number]) { (this.uniforms.offsetSdr.value as Vector3).fromArray(value) } 101 | /** 102 | * @see {@link GainMapMetadata.gainMapMin} 103 | */ 104 | get gainMapMin () { return (this.uniforms.gainMapMin.value as Vector3).toArray() } 105 | set gainMapMin (value: [number, number, number]) { (this.uniforms.gainMapMin.value as Vector3).fromArray(value) } 106 | /** 107 | * @see {@link GainMapMetadata.gainMapMax} 108 | */ 109 | get gainMapMax () { return (this.uniforms.gainMapMax.value as Vector3).toArray() } 110 | set gainMapMax (value: [number, number, number]) { (this.uniforms.gainMapMax.value as Vector3).fromArray(value) } 111 | 112 | /** 113 | * @see {@link GainMapMetadata.gamma} 114 | */ 115 | get gamma () { 116 | const g = this.uniforms.gamma.value as Vector3 117 | return [1 / g.x, 1 / g.y, 1 / g.z] as [number, number, number] 118 | } 119 | 120 | set gamma (value: [number, number, number]) { 121 | const g = this.uniforms.gamma.value as Vector3 122 | g.x = 1.0 / value[0] 123 | g.y = 1.0 / value[1] 124 | g.z = 1.0 / value[2] 125 | } 126 | 127 | /** 128 | * @see {@link GainMapMetadata.hdrCapacityMin} 129 | * @remarks Logarithmic space 130 | */ 131 | get hdrCapacityMin () { return this._hdrCapacityMin } 132 | set hdrCapacityMin (value: number) { 133 | this._hdrCapacityMin = value 134 | this.calculateWeight() 135 | } 136 | 137 | /** 138 | * @see {@link GainMapMetadata.hdrCapacityMin} 139 | * @remarks Logarithmic space 140 | */ 141 | get hdrCapacityMax () { return this._hdrCapacityMax } 142 | set hdrCapacityMax (value: number) { 143 | this._hdrCapacityMax = value 144 | this.calculateWeight() 145 | } 146 | 147 | /** 148 | * @see {@link GainmapDecodingParameters.maxDisplayBoost} 149 | * @remarks Non Logarithmic space 150 | */ 151 | get maxDisplayBoost () { return this._maxDisplayBoost } 152 | set maxDisplayBoost (value: number) { 153 | this._maxDisplayBoost = Math.max(1, Math.min(65504, value)) 154 | this.calculateWeight() 155 | } 156 | 157 | private calculateWeight () { 158 | const val = (Math.log2(this._maxDisplayBoost) - this._hdrCapacityMin) / (this._hdrCapacityMax - this._hdrCapacityMin) 159 | this.uniforms.weightFactor.value = Math.max(0, Math.min(1, val)) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/decode/types.ts: -------------------------------------------------------------------------------- 1 | import { type Texture, type WebGLRenderer } from 'three' 2 | 3 | import { type GainMapMetadata, type QuadRendererTextureOptions } from '../core/types' 4 | 5 | /** 6 | * Necessary parameters for decoding a Gainmap 7 | * 8 | * @category Specs 9 | * @group Specs 10 | */ 11 | export type GainmapDecodingParameters = { 12 | /** 13 | * The maximum available boost supported by a display, at a given point in time. 14 | * 15 | * @remarks 16 | * This value can change over time based on device settings and other factors, 17 | * such as ambient light conditions, or how many bright pixels are on the screen. 18 | * 19 | * Non Logarithmic space 20 | */ 21 | maxDisplayBoost: number 22 | } 23 | /** 24 | * @category Decoding Functions 25 | * @group Decoding Functions 26 | */ 27 | export type DecodeParameters = { 28 | /** 29 | * An Texture containing the SDR Rendition 30 | */ 31 | sdr: Texture 32 | /** 33 | * An Texture containing the GainMap recovery image 34 | */ 35 | gainMap: Texture 36 | /** 37 | * WebGLRenderer used to decode the GainMap 38 | */ 39 | renderer?: WebGLRenderer, 40 | /** 41 | * Options to use when creating the output renderTarget 42 | */ 43 | renderTargetOptions?: QuadRendererTextureOptions 44 | 45 | } & GainmapDecodingParameters & GainMapMetadata 46 | -------------------------------------------------------------------------------- /src/decode/utils/extractXMP.ts: -------------------------------------------------------------------------------- 1 | import { GainMapMetadata } from '../../core/types' 2 | 3 | const getXMLValue = (xml: string, tag: string, defaultValue?: string): string | [string, string, string] => { 4 | // Check for attribute format first: tag="value" 5 | const attributeMatch = new RegExp(`${tag}="([^"]*)"`, 'i').exec(xml) 6 | if (attributeMatch) return attributeMatch[1] 7 | 8 | // Check for tag format: value or value... 9 | const tagMatch = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i').exec(xml) 10 | if (tagMatch) { 11 | // Check if it contains rdf:li elements 12 | const liValues = tagMatch[1].match(/([^<]*)<\/rdf:li>/g) 13 | if (liValues && liValues.length === 3) { 14 | return liValues.map(v => v.replace(/<\/?rdf:li>/g, '')) as [string, string, string] 15 | } 16 | return tagMatch[1].trim() 17 | } 18 | 19 | if (defaultValue !== undefined) return defaultValue 20 | throw new Error(`Can't find ${tag} in gainmap metadata`) 21 | } 22 | 23 | export const extractXMP = (input: Uint8Array): GainMapMetadata | undefined => { 24 | let str: string 25 | // support node test environment 26 | if (typeof TextDecoder !== 'undefined') str = new TextDecoder().decode(input) 27 | else str = input.toString() 28 | 29 | let start = str.indexOf('', start) 33 | const xmpBlock = str.slice(start, end + 10) 34 | 35 | try { 36 | const gainMapMin = getXMLValue(xmpBlock, 'hdrgm:GainMapMin', '0') 37 | const gainMapMax = getXMLValue(xmpBlock, 'hdrgm:GainMapMax') 38 | const gamma = getXMLValue(xmpBlock, 'hdrgm:Gamma', '1') 39 | const offsetSDR = getXMLValue(xmpBlock, 'hdrgm:OffsetSDR', '0.015625') 40 | const offsetHDR = getXMLValue(xmpBlock, 'hdrgm:OffsetHDR', '0.015625') 41 | 42 | // These are always attributes, so we can use a simpler regex 43 | const hdrCapacityMinMatch = /hdrgm:HDRCapacityMin="([^"]*)"/.exec(xmpBlock) 44 | const hdrCapacityMin = hdrCapacityMinMatch ? hdrCapacityMinMatch[1] : '0' 45 | 46 | const hdrCapacityMaxMatch = /hdrgm:HDRCapacityMax="([^"]*)"/.exec(xmpBlock) 47 | if (!hdrCapacityMaxMatch) throw new Error('Incomplete gainmap metadata') 48 | const hdrCapacityMax = hdrCapacityMaxMatch[1] 49 | 50 | return { 51 | gainMapMin: Array.isArray(gainMapMin) ? gainMapMin.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gainMapMin), parseFloat(gainMapMin), parseFloat(gainMapMin)], 52 | gainMapMax: Array.isArray(gainMapMax) ? gainMapMax.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gainMapMax), parseFloat(gainMapMax), parseFloat(gainMapMax)], 53 | gamma: Array.isArray(gamma) ? gamma.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gamma), parseFloat(gamma), parseFloat(gamma)], 54 | offsetSdr: Array.isArray(offsetSDR) ? offsetSDR.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(offsetSDR), parseFloat(offsetSDR), parseFloat(offsetSDR)], 55 | offsetHdr: Array.isArray(offsetHDR) ? offsetHDR.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(offsetHDR), parseFloat(offsetHDR), parseFloat(offsetHDR)], 56 | hdrCapacityMin: parseFloat(hdrCapacityMin), 57 | hdrCapacityMax: parseFloat(hdrCapacityMax) 58 | } 59 | } catch (e) { 60 | // Continue searching for another xmpmeta block if this one fails 61 | } 62 | start = str.indexOf(' { 8 | return new Promise((resolve, reject) => { 9 | const img = document.createElement('img') 10 | img.onload = () => { resolve(img) } 11 | // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors 12 | img.onerror = (e) => { reject(e) } 13 | img.src = URL.createObjectURL(blob) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /src/encode.ts: -------------------------------------------------------------------------------- 1 | export * from './encode/index' 2 | -------------------------------------------------------------------------------- /src/encode/compress.ts: -------------------------------------------------------------------------------- 1 | import { CompressedImage, CompressParameters } from './types' 2 | 3 | /** 4 | * Used internally 5 | * 6 | * @internal 7 | * @param canvas 8 | * @param mimeType 9 | * @param quality 10 | * @returns 11 | */ 12 | const canvasToBlob = async (canvas: OffscreenCanvas | HTMLCanvasElement, mimeType: CompressParameters['mimeType'], quality: CompressParameters['quality']) => { 13 | if (typeof OffscreenCanvas !== 'undefined' && canvas instanceof OffscreenCanvas) { 14 | return canvas.convertToBlob({ type: mimeType, quality: quality || 0.9 }) 15 | } else if (canvas instanceof HTMLCanvasElement) { 16 | return new Promise((resolve, reject) => { 17 | canvas.toBlob((res) => { 18 | if (res) resolve(res) 19 | else reject(new Error('Failed to convert canvas to blob')) 20 | }, mimeType, quality || 0.9) 21 | }) 22 | } 23 | /* istanbul ignore next 24 | as long as this function is not exported this is only here 25 | to satisfy TS strict mode internally 26 | */ 27 | throw new Error('Unsupported canvas element') 28 | } 29 | 30 | /** 31 | * Converts a RAW RGBA image buffer into the provided `mimeType` using the provided `quality` 32 | * 33 | * @category Compression 34 | * @group Compression 35 | * @param params 36 | * @throws {Error} if the browser does not support [createImageBitmap](https://caniuse.com/createimagebitmap) 37 | * @throws {Error} if the provided source image cannot be decoded 38 | * @throws {Error} if the function fails to create a canvas context 39 | */ 40 | export const compress = async (params: CompressParameters): Promise => { 41 | if (typeof createImageBitmap === 'undefined') throw new Error('createImageBitmap() not supported.') 42 | const { source, mimeType, quality, flipY } = params 43 | 44 | let imageBitmapSource: ImageBitmapSource 45 | if ((source instanceof Uint8Array || source instanceof Uint8ClampedArray) && 'sourceMimeType' in params) { 46 | imageBitmapSource = new Blob([source], { type: params.sourceMimeType }) 47 | } else if (source instanceof ImageData) { 48 | imageBitmapSource = source 49 | } else { 50 | throw new Error('Invalid source image') 51 | } 52 | const img = await createImageBitmap(imageBitmapSource) 53 | const width = img.width 54 | const height = img.height 55 | 56 | let canvas: OffscreenCanvas | HTMLCanvasElement 57 | if (typeof OffscreenCanvas !== 'undefined') { 58 | canvas = new OffscreenCanvas(width, height) 59 | } else { 60 | canvas = document.createElement('canvas') 61 | canvas.width = width 62 | canvas.height = height 63 | } 64 | const ctx = canvas.getContext('2d') 65 | if (!ctx) throw new Error('Failed to create canvas Context') 66 | // flip Y 67 | if (flipY === true) { 68 | ctx.translate(0, height) 69 | ctx.scale(1, -1) 70 | } 71 | 72 | ctx.drawImage(img, 0, 0, width, height) 73 | 74 | const blob = await canvasToBlob(canvas, mimeType, quality || 0.9) 75 | 76 | const data = new Uint8Array(await blob.arrayBuffer()) 77 | 78 | return { 79 | data, 80 | mimeType, 81 | width, 82 | height 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/encode/encode-and-compress.ts: -------------------------------------------------------------------------------- 1 | import { compress } from './compress' 2 | import { encode } from './encode' 3 | import { CompressedImage, EncodingParametersWithCompression } from './types' 4 | 5 | /** 6 | * Encodes a Gainmap starting from an HDR file into compressed file formats (`image/jpeg`, `image/webp` or `image/png`). 7 | * 8 | * Uses {@link encode} internally, then pipes the results to {@link compress}. 9 | * 10 | * @remarks 11 | * if a `renderer` parameter is not provided 12 | * This function will automatically dispose its "disposable" 13 | * renderer, no need to dispose it manually later 14 | * 15 | * @category Encoding Functions 16 | * @group Encoding Functions 17 | * @example 18 | * import { encodeAndCompress, findTextureMinMax } from '@monogrid/gainmap-js' 19 | * import { encodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' 20 | * import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 21 | * 22 | * // load an HDR file 23 | * const loader = new EXRLoader() 24 | * const image = await loader.loadAsync('image.exr') 25 | * 26 | * // find RAW RGB Max value of a texture 27 | * const textureMax = await findTextureMinMax(image) 28 | * 29 | * // Encode the gainmap 30 | * const encodingResult = await encodeAndCompress({ 31 | * image, 32 | * maxContentBoost: Math.max.apply(this, textureMax), 33 | * mimeType: 'image/jpeg' 34 | * }) 35 | * 36 | * // embed the compressed images + metadata into a single 37 | * // JPEG file 38 | * const jpeg = await encodeJPEGMetadata({ 39 | * ...encodingResult, 40 | * sdr: encodingResult.sdr, 41 | * gainMap: encodingResult.gainMap 42 | * }) 43 | * 44 | * // `jpeg` will be an `Uint8Array` which can be saved somewhere 45 | * 46 | * 47 | * @param params Encoding Parameters 48 | * @throws {Error} if the browser does not support [createImageBitmap](https://caniuse.com/createimagebitmap) 49 | */ 50 | export const encodeAndCompress = async (params: EncodingParametersWithCompression) => { 51 | const encodingResult = encode(params) 52 | 53 | const { mimeType, quality, flipY, withWorker } = params 54 | 55 | let compressResult: [CompressedImage, CompressedImage] 56 | 57 | let rawSDR: Uint8ClampedArray 58 | let rawGainMap: Uint8ClampedArray 59 | 60 | const sdrImageData = new ImageData(encodingResult.sdr.toArray(), encodingResult.sdr.width, encodingResult.sdr.height) 61 | const gainMapImageData = new ImageData(encodingResult.gainMap.toArray(), encodingResult.gainMap.width, encodingResult.gainMap.height) 62 | 63 | if (withWorker) { 64 | const workerResult = await Promise.all([ 65 | withWorker.compress({ 66 | source: sdrImageData, 67 | mimeType, 68 | quality, 69 | flipY 70 | }), 71 | withWorker.compress({ 72 | source: gainMapImageData, 73 | mimeType, 74 | quality, 75 | flipY 76 | }) 77 | ]) 78 | compressResult = workerResult 79 | rawSDR = workerResult[0].source 80 | rawGainMap = workerResult[1].source 81 | } else { 82 | compressResult = await Promise.all([ 83 | compress({ 84 | source: sdrImageData, 85 | mimeType, 86 | quality, 87 | flipY 88 | }), 89 | compress({ 90 | source: gainMapImageData, 91 | mimeType, 92 | quality, 93 | flipY 94 | }) 95 | ]) 96 | rawSDR = sdrImageData.data 97 | rawGainMap = gainMapImageData.data 98 | } 99 | 100 | encodingResult.sdr.dispose() 101 | encodingResult.gainMap.dispose() 102 | 103 | return { 104 | ...encodingResult, 105 | ...encodingResult.getMetadata(), 106 | sdr: compressResult[0], 107 | gainMap: compressResult[1], 108 | rawSDR, 109 | rawGainMap 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/encode/encode.ts: -------------------------------------------------------------------------------- 1 | import { getDataTexture } from '../core/get-data-texture' 2 | import { GainMapMetadata } from '../core/types' 3 | import { getGainMap } from './get-gainmap' 4 | import { getSDRRendition } from './get-sdr-rendition' 5 | import { EncodingParametersBase } from './types' 6 | 7 | /** 8 | * Encodes a Gainmap starting from an HDR file. 9 | * 10 | * @remarks 11 | * if you do not pass a `renderer` parameter 12 | * you must manually dispose the result 13 | * ```js 14 | * const encodingResult = await encode({ ... }) 15 | * // do something with the buffers 16 | * const sdr = encodingResult.sdr.getArray() 17 | * const gainMap = encodingResult.gainMap.getArray() 18 | * // after that 19 | * encodingResult.sdr.dispose() 20 | * encodingResult.gainMap.dispose() 21 | * ``` 22 | * 23 | * @category Encoding Functions 24 | * @group Encoding Functions 25 | * 26 | * @example 27 | * import { encode, findTextureMinMax } from '@monogrid/gainmap-js' 28 | * import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 29 | * 30 | * // load an HDR file 31 | * const loader = new EXRLoader() 32 | * const image = await loader.loadAsync('image.exr') 33 | * 34 | * // find RAW RGB Max value of a texture 35 | * const textureMax = await findTextureMinMax(image) 36 | * 37 | * // Encode the gainmap 38 | * const encodingResult = encode({ 39 | * image, 40 | * // this will encode the full HDR range 41 | * maxContentBoost: Math.max.apply(this, textureMax) 42 | * }) 43 | * // can be re-encoded after changing parameters 44 | * encodingResult.sdr.material.exposure = 0.9 45 | * encodingResult.sdr.render() 46 | * // or 47 | * encodingResult.gainMap.material.gamma = [1.1, 1.1, 1.1] 48 | * encodingResult.gainMap.render() 49 | * 50 | * // do something with encodingResult.gainMap.toArray() 51 | * // and encodingResult.sdr.toArray() 52 | * 53 | * // renderers must be manually disposed 54 | * encodingResult.sdr.dispose() 55 | * encodingResult.gainMap.dispose() 56 | * 57 | * @param params Encoding Parameters 58 | * @returns 59 | */ 60 | export const encode = (params: EncodingParametersBase) => { 61 | const { image, renderer } = params 62 | 63 | const dataTexture = getDataTexture(image) 64 | 65 | const sdr = getSDRRendition(dataTexture, renderer, params.toneMapping, params.renderTargetOptions) 66 | 67 | const gainMapRenderer = getGainMap({ 68 | ...params, 69 | image: dataTexture, 70 | sdr, 71 | renderer: sdr.renderer // reuse the same (maybe disposable?) renderer 72 | }) 73 | 74 | return { 75 | sdr, 76 | gainMap: gainMapRenderer, 77 | hdr: dataTexture, 78 | getMetadata: (): GainMapMetadata => { 79 | return { 80 | gainMapMax: gainMapRenderer.material.gainMapMax, 81 | gainMapMin: gainMapRenderer.material.gainMapMin, 82 | gamma: gainMapRenderer.material.gamma, 83 | hdrCapacityMax: gainMapRenderer.material.hdrCapacityMax, 84 | hdrCapacityMin: gainMapRenderer.material.hdrCapacityMin, 85 | offsetHdr: gainMapRenderer.material.offsetHdr, 86 | offsetSdr: gainMapRenderer.material.offsetSdr 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/encode/find-texture-min-max.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClampToEdgeWrapping, 3 | ColorSpace, 4 | DataTexture, 5 | DataUtils, 6 | FloatType, 7 | NearestFilter, 8 | ShaderMaterial, 9 | Vector2, 10 | WebGLRenderer, 11 | WebGLRenderTarget 12 | } from 'three' 13 | import { EXR } from 'three/examples/jsm/loaders/EXRLoader' 14 | import { RGBE } from 'three/examples/jsm/loaders/RGBELoader' 15 | 16 | import { getDataTexture } from '../core/get-data-texture' 17 | import { QuadRenderer } from '../core/QuadRenderer' 18 | const vertexShader = /* glsl */` 19 | varying vec2 vUv; 20 | void main() { 21 | vUv = uv; 22 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 23 | } 24 | ` 25 | 26 | const fragmentShader = /* glsl */` 27 | precision mediump float; 28 | 29 | #ifndef CELL_SIZE 30 | #define CELL_SIZE 2 31 | #endif 32 | 33 | #ifndef COMPARE_FUNCTION 34 | #define COMPARE_FUNCTION max 35 | #endif 36 | 37 | #ifndef INITIAL_VALUE 38 | #define INITIAL_VALUE 0 39 | #endif 40 | 41 | uniform sampler2D map; 42 | uniform vec2 u_srcResolution; 43 | 44 | varying vec2 vUv; 45 | 46 | void main() { 47 | // compute the first pixel the source cell 48 | vec2 srcPixel = floor(gl_FragCoord.xy) * float(CELL_SIZE); 49 | 50 | // one pixel in source 51 | vec2 onePixel = vec2(1) / u_srcResolution; 52 | 53 | // uv for first pixel in cell. +0.5 for center of pixel 54 | vec2 uv = (srcPixel + 0.5) * onePixel; 55 | 56 | vec4 resultColor = vec4(INITIAL_VALUE); 57 | 58 | for (int y = 0; y < CELL_SIZE; ++y) { 59 | for (int x = 0; x < CELL_SIZE; ++x) { 60 | resultColor = COMPARE_FUNCTION(resultColor, texture2D(map, uv + vec2(x, y) * onePixel)); 61 | } 62 | } 63 | 64 | gl_FragColor = resultColor; 65 | } 66 | ` 67 | /** 68 | * 69 | * @category Utility 70 | * @group Utility 71 | * 72 | * @param image 73 | * @param mode 74 | * @param renderer 75 | * @returns 76 | */ 77 | export const findTextureMinMax = (image: EXR | RGBE | DataTexture, mode: 'min' | 'max' = 'max', renderer?: WebGLRenderer) => { 78 | const srcTex = getDataTexture(image) 79 | const cellSize = 2 80 | 81 | const mat = new ShaderMaterial({ 82 | vertexShader, 83 | fragmentShader, 84 | uniforms: { 85 | u_srcResolution: { value: new Vector2(srcTex.image.width, srcTex.image.height) }, 86 | map: { value: srcTex } 87 | }, 88 | defines: { 89 | CELL_SIZE: cellSize, 90 | COMPARE_FUNCTION: mode, 91 | INITIAL_VALUE: mode === 'max' ? 0 : 65504 // max half float value 92 | } 93 | }) 94 | srcTex.needsUpdate = true 95 | mat.needsUpdate = true 96 | 97 | let w = srcTex.image.width 98 | let h = srcTex.image.height 99 | 100 | const quadRenderer = new QuadRenderer({ 101 | width: w, 102 | height: h, 103 | type: srcTex.type, 104 | colorSpace: srcTex.colorSpace as ColorSpace, 105 | material: mat, 106 | renderer 107 | }) 108 | 109 | const frameBuffers: WebGLRenderTarget[] = [] 110 | 111 | while (w > 1 || h > 1) { 112 | w = Math.max(1, (w + cellSize - 1) / cellSize | 0) 113 | h = Math.max(1, (h + cellSize - 1) / cellSize | 0) 114 | const fb = new WebGLRenderTarget(w, h, { 115 | type: quadRenderer.type, 116 | format: srcTex.format, 117 | colorSpace: quadRenderer.colorSpace, 118 | minFilter: NearestFilter, 119 | magFilter: NearestFilter, 120 | wrapS: ClampToEdgeWrapping, 121 | wrapT: ClampToEdgeWrapping, 122 | generateMipmaps: false, 123 | depthBuffer: false, 124 | stencilBuffer: false 125 | }) 126 | frameBuffers.push(fb) 127 | } 128 | 129 | w = srcTex.image.width 130 | h = srcTex.image.height 131 | frameBuffers.forEach((fbi) => { 132 | w = Math.max(1, (w + cellSize - 1) / cellSize | 0) 133 | h = Math.max(1, (h + cellSize - 1) / cellSize | 0) 134 | 135 | quadRenderer.renderTarget = fbi 136 | quadRenderer.render() 137 | 138 | mat.uniforms.map.value = fbi.texture 139 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 140 | mat.uniforms.u_srcResolution.value.x = w 141 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 142 | mat.uniforms.u_srcResolution.value.y = h 143 | }) 144 | 145 | const out = quadRenderer.toArray() 146 | 147 | quadRenderer.dispose() 148 | frameBuffers.forEach(fb => fb.dispose()) 149 | 150 | return [ 151 | quadRenderer.type === FloatType ? out[0] : DataUtils.fromHalfFloat(out[0]), 152 | quadRenderer.type === FloatType ? out[1] : DataUtils.fromHalfFloat(out[1]), 153 | quadRenderer.type === FloatType ? out[2] : DataUtils.fromHalfFloat(out[2]) 154 | ] 155 | } 156 | -------------------------------------------------------------------------------- /src/encode/get-gainmap.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LinearSRGBColorSpace, 3 | UnsignedByteType 4 | } from 'three' 5 | 6 | import { getDataTexture } from '../core/get-data-texture' 7 | import { QuadRenderer } from '../core/QuadRenderer' 8 | import { GainMapEncoderMaterial } from './materials/GainMapEncoderMaterial' 9 | import { EncodingParametersBase } from './types' 10 | /** 11 | * 12 | * @param params 13 | * @returns 14 | * @category Encoding Functions 15 | * @group Encoding Functions 16 | */ 17 | export const getGainMap = (params: { sdr: InstanceType } & EncodingParametersBase) => { 18 | const { image, sdr, renderer } = params 19 | 20 | const dataTexture = getDataTexture(image) 21 | 22 | const material = new GainMapEncoderMaterial({ 23 | ...params, 24 | sdr: sdr.renderTarget.texture, 25 | hdr: dataTexture 26 | }) 27 | 28 | const quadRenderer = new QuadRenderer({ 29 | width: dataTexture.image.width, 30 | height: dataTexture.image.height, 31 | type: UnsignedByteType, 32 | colorSpace: LinearSRGBColorSpace, 33 | material, 34 | renderer, 35 | renderTargetOptions: params.renderTargetOptions 36 | }) 37 | try { 38 | quadRenderer.render() 39 | } catch (e) { 40 | quadRenderer.disposeOnDemandRenderer() 41 | throw e 42 | } 43 | return quadRenderer 44 | } 45 | -------------------------------------------------------------------------------- /src/encode/get-sdr-rendition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataTexture, 3 | SRGBColorSpace, 4 | ToneMapping, 5 | UnsignedByteType, 6 | WebGLRenderer 7 | } from 'three' 8 | 9 | import { QuadRenderer } from '../core/QuadRenderer' 10 | import { QuadRendererTextureOptions } from '../decode' 11 | import { SDRMaterial } from './materials/SDRMaterial' 12 | 13 | /** 14 | * Renders an SDR Representation of an HDR Image 15 | * 16 | * @category Encoding Functions 17 | * @group Encoding Functions 18 | * 19 | * @param hdrTexture The HDR image to be rendered 20 | * @param renderer (optional) WebGLRenderer to use during the rendering, a disposable renderer will be create and destroyed if this is not provided. 21 | * @param toneMapping (optional) Tone mapping to be applied to the SDR Rendition 22 | * @param renderTargetOptions (optional) Options to use when creating the output renderTarget 23 | * @throws {Error} if the WebGLRenderer fails to render the SDR image 24 | */ 25 | export const getSDRRendition = (hdrTexture: DataTexture, renderer?: WebGLRenderer, toneMapping?: ToneMapping, renderTargetOptions?: QuadRendererTextureOptions): InstanceType>> => { 26 | hdrTexture.needsUpdate = true 27 | const quadRenderer = new QuadRenderer({ 28 | width: hdrTexture.image.width, 29 | height: hdrTexture.image.height, 30 | type: UnsignedByteType, 31 | colorSpace: SRGBColorSpace, 32 | material: new SDRMaterial({ map: hdrTexture, toneMapping }), 33 | renderer, 34 | renderTargetOptions 35 | }) 36 | try { 37 | quadRenderer.render() 38 | } catch (e) { 39 | quadRenderer.disposeOnDemandRenderer() 40 | throw e 41 | } 42 | return quadRenderer 43 | } 44 | -------------------------------------------------------------------------------- /src/encode/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../core/types' 2 | export * from './compress' 3 | export * from './encode' 4 | export * from './encode-and-compress' 5 | export * from './find-texture-min-max' 6 | export * from './get-gainmap' 7 | export * from './get-sdr-rendition' 8 | export * from './materials/GainMapEncoderMaterial' 9 | export * from './materials/SDRMaterial' 10 | export * from './types' 11 | -------------------------------------------------------------------------------- /src/encode/materials/GainMapEncoderMaterial.ts: -------------------------------------------------------------------------------- 1 | import { NoBlending, ShaderMaterial, Texture, Vector3 } from 'three' 2 | 3 | // eslint-disable-next-line unused-imports/no-unused-imports 4 | import { GainMapMetadata } from '../../core/types' // needed for docs 5 | import { GainmapEncodingParameters } from '../types' 6 | 7 | const vertexShader = /* glsl */` 8 | varying vec2 vUv; 9 | 10 | void main() { 11 | vUv = uv; 12 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 13 | } 14 | ` 15 | 16 | const fragmentShader = /* glsl */` 17 | #ifndef saturate 18 | #define saturate( a ) clamp( a, 0.0, 1.0 ) 19 | #endif 20 | uniform sampler2D sdr; 21 | uniform sampler2D hdr; 22 | uniform vec3 gamma; 23 | uniform vec3 offsetSdr; 24 | uniform vec3 offsetHdr; 25 | uniform float minLog2; 26 | uniform float maxLog2; 27 | 28 | varying vec2 vUv; 29 | 30 | void main() { 31 | vec3 sdrColor = texture2D(sdr, vUv).rgb; 32 | vec3 hdrColor = texture2D(hdr, vUv).rgb; 33 | 34 | vec3 pixelGain = (hdrColor + offsetHdr) / (sdrColor + offsetSdr); 35 | vec3 logRecovery = (log2(pixelGain) - minLog2) / (maxLog2 - minLog2); 36 | vec3 clampedRecovery = saturate(logRecovery); 37 | gl_FragColor = vec4(pow(clampedRecovery, gamma), 1.0); 38 | } 39 | ` 40 | /** 41 | * A Material which is able to encode a gainmap 42 | * 43 | * @category Materials 44 | * @group Materials 45 | */ 46 | export class GainMapEncoderMaterial extends ShaderMaterial { 47 | private _minContentBoost: number 48 | private _maxContentBoost: number 49 | private _offsetSdr: [number, number, number] 50 | private _offsetHdr: [number, number, number] 51 | private _gamma: [number, number, number] 52 | /** 53 | * 54 | * @param params 55 | */ 56 | constructor ({ sdr, hdr, offsetSdr, offsetHdr, maxContentBoost, minContentBoost, gamma }: { sdr: Texture, hdr: Texture } & GainmapEncodingParameters) { 57 | if (!maxContentBoost) throw new Error('maxContentBoost is required') 58 | if (!sdr) throw new Error('sdr is required') 59 | if (!hdr) throw new Error('hdr is required') 60 | 61 | const _gamma = gamma || [1, 1, 1] 62 | const _offsetSdr = offsetSdr || [1 / 64, 1 / 64, 1 / 64] 63 | const _offsetHdr = offsetHdr || [1 / 64, 1 / 64, 1 / 64] 64 | const _minContentBoost = minContentBoost || 1 65 | const _maxContentBoost = Math.max(maxContentBoost, 1.0001) 66 | 67 | super({ 68 | name: 'GainMapEncoderMaterial', 69 | vertexShader, 70 | fragmentShader, 71 | uniforms: { 72 | sdr: { value: sdr }, 73 | hdr: { value: hdr }, 74 | gamma: { value: new Vector3().fromArray(_gamma) }, 75 | offsetSdr: { value: new Vector3().fromArray(_offsetSdr) }, 76 | offsetHdr: { value: new Vector3().fromArray(_offsetHdr) }, 77 | minLog2: { value: Math.log2(_minContentBoost) }, 78 | maxLog2: { value: Math.log2(_maxContentBoost) } 79 | }, 80 | blending: NoBlending, 81 | depthTest: false, 82 | depthWrite: false 83 | }) 84 | 85 | this._minContentBoost = _minContentBoost 86 | this._maxContentBoost = _maxContentBoost 87 | this._offsetSdr = _offsetSdr 88 | this._offsetHdr = _offsetHdr 89 | this._gamma = _gamma 90 | 91 | this.needsUpdate = true 92 | this.uniformsNeedUpdate = true 93 | } 94 | 95 | /** 96 | * @see {@link GainmapEncodingParameters.gamma} 97 | */ 98 | get gamma () { return this._gamma } 99 | set gamma (value: [number, number, number]) { 100 | this._gamma = value 101 | this.uniforms.gamma.value = new Vector3().fromArray(value) 102 | } 103 | 104 | /** 105 | * @see {@link GainmapEncodingParameters.offsetHdr} 106 | */ 107 | get offsetHdr () { return this._offsetHdr } 108 | set offsetHdr (value: [number, number, number]) { 109 | this._offsetHdr = value 110 | this.uniforms.offsetHdr.value = new Vector3().fromArray(value) 111 | } 112 | 113 | /** 114 | * @see {@link GainmapEncodingParameters.offsetSdr} 115 | */ 116 | get offsetSdr () { return this._offsetSdr } 117 | set offsetSdr (value: [number, number, number]) { 118 | this._offsetSdr = value 119 | this.uniforms.offsetSdr.value = new Vector3().fromArray(value) 120 | } 121 | 122 | /** 123 | * @see {@link GainmapEncodingParameters.minContentBoost} 124 | * @remarks Non logarithmic space 125 | */ 126 | get minContentBoost () { return this._minContentBoost } 127 | set minContentBoost (value: number) { 128 | this._minContentBoost = value 129 | this.uniforms.minLog2.value = Math.log2(value) 130 | } 131 | 132 | /** 133 | * @see {@link GainmapEncodingParameters.maxContentBoost} 134 | * @remarks Non logarithmic space 135 | */ 136 | get maxContentBoost () { return this._maxContentBoost } 137 | set maxContentBoost (value: number) { 138 | this._maxContentBoost = value 139 | this.uniforms.maxLog2.value = Math.log2(value) 140 | } 141 | 142 | /** 143 | * @see {@link GainMapMetadata.gainMapMin} 144 | * @remarks Logarithmic space 145 | */ 146 | get gainMapMin (): [number, number, number] { return [Math.log2(this._minContentBoost), Math.log2(this._minContentBoost), Math.log2(this._minContentBoost)] } 147 | /** 148 | * @see {@link GainMapMetadata.gainMapMax} 149 | * @remarks Logarithmic space 150 | */ 151 | get gainMapMax (): [number, number, number] { return [Math.log2(this._maxContentBoost), Math.log2(this._maxContentBoost), Math.log2(this._maxContentBoost)] } 152 | 153 | /** 154 | * @see {@link GainMapMetadata.hdrCapacityMin} 155 | * @remarks Logarithmic space 156 | */ 157 | get hdrCapacityMin (): number { return Math.min(Math.max(0, this.gainMapMin[0]), Math.max(0, this.gainMapMin[1]), Math.max(0, this.gainMapMin[2])) } 158 | /** 159 | * @see {@link GainMapMetadata.hdrCapacityMax} 160 | * @remarks Logarithmic space 161 | */ 162 | get hdrCapacityMax (): number { return Math.max(Math.max(0, this.gainMapMax[0]), Math.max(0, this.gainMapMax[1]), Math.max(0, this.gainMapMax[2])) } 163 | } 164 | -------------------------------------------------------------------------------- /src/libultrahdr.ts: -------------------------------------------------------------------------------- 1 | export * from '../libultrahdr-wasm/build/libultrahdr' 2 | export * from './libultrahdr/decode-jpeg-metadata' 3 | export * from './libultrahdr/encode-jpeg-metadata' 4 | export * from './libultrahdr/library' 5 | -------------------------------------------------------------------------------- /src/libultrahdr/decode-jpeg-metadata.ts: -------------------------------------------------------------------------------- 1 | import { GainMapMetadata } from '../core/types' 2 | import { getLibrary } from './library' 3 | 4 | /** 5 | * Decodes a JPEG file with an embedded Gainmap and XMP Metadata (aka JPEG-R) 6 | * 7 | * @category Decoding 8 | * @group Decoding 9 | * @deprecated 10 | * @example 11 | * import { decodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' 12 | * 13 | * // fetch a JPEG image containing a gainmap as ArrayBuffer 14 | * const gainmap = new Uint8Array(await (await fetch('gainmap.jpeg')).arrayBuffer()) 15 | * 16 | * // extract data from the JPEG 17 | * const { gainMap, sdr, parsedMetadata } = await decodeJPEGMetadata(gainmap) 18 | * 19 | * @param file A Jpeg file Uint8Array. 20 | * @returns The decoded data 21 | * @throws {Error} if the provided file cannot be parsed or does not contain a valid Gainmap 22 | */ 23 | /* istanbul ignore next */ 24 | export const decodeJPEGMetadata = async (file: Uint8Array) => { 25 | const lib = await getLibrary() 26 | const result = lib.extractJpegR(file, file.length) 27 | if (!result.success) throw new Error(`${result.errorMessage}`) 28 | 29 | const getXMLValue = (xml: string, tag: string, defaultValue?: string): string | [string, string, string] => { 30 | // Check for attribute format first: tag="value" 31 | const attributeMatch = new RegExp(`${tag}="([^"]*)"`, 'i').exec(xml) 32 | if (attributeMatch) return attributeMatch[1] 33 | 34 | // Check for tag format: value or value... 35 | const tagMatch = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i').exec(xml) 36 | if (tagMatch) { 37 | // Check if it contains rdf:li elements 38 | const liValues = tagMatch[1].match(/([^<]*)<\/rdf:li>/g) 39 | if (liValues && liValues.length === 3) { 40 | return liValues.map(v => v.replace(/<\/?rdf:li>/g, '')) as [string, string, string] 41 | } 42 | return tagMatch[1].trim() 43 | } 44 | 45 | if (defaultValue !== undefined) return defaultValue 46 | throw new Error(`Can't find ${tag} in gainmap metadata`) 47 | } 48 | 49 | const metadata = result.metadata as string 50 | 51 | const gainMapMin = getXMLValue(metadata, 'hdrgm:GainMapMin', '0') 52 | const gainMapMax = getXMLValue(metadata, 'hdrgm:GainMapMax') 53 | const gamma = getXMLValue(metadata, 'hdrgm:Gamma', '1') 54 | const offsetSDR = getXMLValue(metadata, 'hdrgm:OffsetSDR', '0.015625') 55 | const offsetHDR = getXMLValue(metadata, 'hdrgm:OffsetHDR', '0.015625') 56 | 57 | // These are always attributes, so we can use a simpler regex 58 | const hdrCapacityMinMatch = /hdrgm:HDRCapacityMin="([^"]*)"/.exec(metadata) 59 | const hdrCapacityMin = hdrCapacityMinMatch ? hdrCapacityMinMatch[1] : '0' 60 | 61 | const hdrCapacityMaxMatch = /hdrgm:HDRCapacityMax="([^"]*)"/.exec(metadata) 62 | if (!hdrCapacityMaxMatch) throw new Error('Incomplete gainmap metadata') 63 | const hdrCapacityMax = hdrCapacityMaxMatch[1] 64 | 65 | const parsedMetadata: GainMapMetadata = { 66 | gainMapMin: Array.isArray(gainMapMin) ? gainMapMin.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gainMapMin), parseFloat(gainMapMin), parseFloat(gainMapMin)], 67 | gainMapMax: Array.isArray(gainMapMax) ? gainMapMax.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gainMapMax), parseFloat(gainMapMax), parseFloat(gainMapMax)], 68 | gamma: Array.isArray(gamma) ? gamma.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(gamma), parseFloat(gamma), parseFloat(gamma)], 69 | offsetSdr: Array.isArray(offsetSDR) ? offsetSDR.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(offsetSDR), parseFloat(offsetSDR), parseFloat(offsetSDR)], 70 | offsetHdr: Array.isArray(offsetHDR) ? offsetHDR.map(v => parseFloat(v)) as [number, number, number] : [parseFloat(offsetHDR), parseFloat(offsetHDR), parseFloat(offsetHDR)], 71 | hdrCapacityMin: parseFloat(hdrCapacityMin), 72 | hdrCapacityMax: parseFloat(hdrCapacityMax) 73 | } 74 | 75 | return { 76 | ...result, 77 | parsedMetadata 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/libultrahdr/encode-jpeg-metadata.ts: -------------------------------------------------------------------------------- 1 | import { type GainMapMetadata } from '../core/types' 2 | import { type CompressedImage } from '../encode/types' 3 | import { getLibrary } from './library' 4 | 5 | /** 6 | * Encapsulates a Gainmap into a single JPEG file (aka: JPEG-R) with the base map 7 | * as the sdr visualization and the gainMap encoded into a MPF (Multi-Picture Format) tag. 8 | * 9 | * @category Encoding 10 | * @group Encoding 11 | * 12 | * @example 13 | * import { compress, encode, findTextureMinMax } from '@monogrid/gainmap-js' 14 | * import { encodeJPEGMetadata } from '@monogrid/gainmap-js/libultrahdr' 15 | * import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 16 | * 17 | * // load an HDR file 18 | * const loader = new EXRLoader() 19 | * const image = await loader.loadAsync('image.exr') 20 | * 21 | * // find RAW RGB Max value of a texture 22 | * const textureMax = await findTextureMinMax(image) 23 | * 24 | * // Encode the gainmap 25 | * const encodingResult = encode({ 26 | * image, 27 | * maxContentBoost: Math.max.apply(this, textureMax) 28 | * }) 29 | * 30 | * // obtain the RAW RGBA SDR buffer and create an ImageData 31 | * const sdrImageData = new ImageData( 32 | * encodingResult.sdr.toArray(), 33 | * encodingResult.sdr.width, 34 | * encodingResult.sdr.height 35 | * ) 36 | * // obtain the RAW RGBA Gain map buffer and create an ImageData 37 | * const gainMapImageData = new ImageData( 38 | * encodingResult.gainMap.toArray(), 39 | * encodingResult.gainMap.width, 40 | * encodingResult.gainMap.height 41 | * ) 42 | * 43 | * // parallel compress the RAW buffers into the specified mimeType 44 | * const mimeType = 'image/jpeg' 45 | * const quality = 0.9 46 | * 47 | * const [sdr, gainMap] = await Promise.all([ 48 | * compress({ 49 | * source: sdrImageData, 50 | * mimeType, 51 | * quality, 52 | * flipY: true // output needs to be flipped 53 | * }), 54 | * compress({ 55 | * source: gainMapImageData, 56 | * mimeType, 57 | * quality, 58 | * flipY: true // output needs to be flipped 59 | * }) 60 | * ]) 61 | * 62 | * // obtain the metadata which will be embedded into 63 | * // and XMP tag inside the final JPEG file 64 | * const metadata = encodingResult.getMetadata() 65 | * 66 | * // embed the compressed images + metadata into a single 67 | * // JPEG file 68 | * const jpeg = await encodeJPEGMetadata({ 69 | * ...encodingResult, 70 | * ...metadata, 71 | * sdr, 72 | * gainMap 73 | * }) 74 | * 75 | * // `jpeg` will be an `Uint8Array` which can be saved somewhere 76 | * 77 | * 78 | * @param encodingResult 79 | * @returns an Uint8Array representing a JPEG-R file 80 | * @throws {Error} If `encodingResult.sdr.mimeType !== 'image/jpeg'` 81 | * @throws {Error} If `encodingResult.gainMap.mimeType !== 'image/jpeg'` 82 | */ 83 | export const encodeJPEGMetadata = async (encodingResult: GainMapMetadata & { sdr: CompressedImage, gainMap: CompressedImage }) => { 84 | const lib = await getLibrary() 85 | 86 | if (encodingResult.sdr.mimeType !== 'image/jpeg') throw new Error('This function expects an SDR image compressed in jpeg') 87 | if (encodingResult.gainMap.mimeType !== 'image/jpeg') throw new Error('This function expects a GainMap image compressed in jpeg') 88 | 89 | return lib.appendGainMap( 90 | encodingResult.sdr.width, encodingResult.sdr.height, 91 | encodingResult.sdr.data, encodingResult.sdr.data.length, 92 | encodingResult.gainMap.data, encodingResult.gainMap.data.length, 93 | encodingResult.gainMapMax.reduce((p, n) => p + n, 0) / encodingResult.gainMapMax.length, 94 | encodingResult.gainMapMin.reduce((p, n) => p + n, 0) / encodingResult.gainMapMin.length, 95 | encodingResult.gamma.reduce((p, n) => p + n, 0) / encodingResult.gamma.length, 96 | encodingResult.offsetSdr.reduce((p, n) => p + n, 0) / encodingResult.offsetSdr.length, 97 | encodingResult.offsetHdr.reduce((p, n) => p + n, 0) / encodingResult.offsetHdr.length, 98 | encodingResult.hdrCapacityMin, 99 | encodingResult.hdrCapacityMax 100 | ) as Uint8Array 101 | } 102 | -------------------------------------------------------------------------------- /src/libultrahdr/library.ts: -------------------------------------------------------------------------------- 1 | import { MainModule } from '../../libultrahdr-wasm/build/libultrahdr' 2 | // @ts-expect-error untyped 3 | import libultrahdr from '../../libultrahdr-wasm/build/libultrahdr-esm' 4 | 5 | let library: MainModule | undefined 6 | 7 | /** 8 | * Instances the WASM module and returns it, only one module will be created upon multiple calls. 9 | * @category WASM 10 | * @group WASM 11 | * 12 | * @returns 13 | */ 14 | export const getLibrary = async () => { 15 | if (!library) { 16 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 17 | library = await libultrahdr() as MainModule 18 | } 19 | return library 20 | } 21 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "strictNullChecks": true, 5 | "strictFunctionTypes": true, 6 | "noErrorTruncation": true, 7 | "noImplicitAny": true, 8 | "noImplicitThis": true, 9 | "skipLibCheck": true, 10 | 11 | "target": "ES2018", 12 | "lib": ["ES2018", "DOM"], 13 | "noEmit": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/worker-interface.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error untyped lib 2 | import PromiseWorker from 'promise-worker-transferable' 3 | 4 | import { type PromiseWorkerType, type WorkerInterface, type WorkerInterfaceImplementation } from './worker-types' 5 | 6 | export * from './worker-types' 7 | /** 8 | * Wraps a Regular worker into a `PromiseWorker` 9 | * 10 | * @param worker 11 | * @returns 12 | */ 13 | export const getPromiseWorker = (worker: Worker) => { 14 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 15 | return new PromiseWorker(worker) as PromiseWorkerType 16 | } 17 | /** 18 | * Returns an interface where methods of the worker can be called by the host site 19 | * 20 | * @example 21 | * // this assumes a vite-like bundler understands the `?worker` import 22 | * import GainMapWorker from '@monogrid/gainmap-js/worker?worker' 23 | * import { getPromiseWorker, getWorkerInterface } from '@monogrid/gainmap-js/worker-interface' 24 | * 25 | * // turn our Worker into a PromiseWorker 26 | * const promiseWorker = getPromiseWorker(new GainMapWorker()) 27 | * // get the interface 28 | * const workerInterface = getWorkerInterface(promiseWorker) 29 | * 30 | * @param worker 31 | * @returns 32 | */ 33 | export const getWorkerInterface = (worker: PromiseWorkerType): WorkerInterfaceImplementation => { 34 | return { 35 | compress: (payload: WorkerInterface['compress']['request']['payload']) => worker.postMessage({ type: 'compress', payload } as WorkerInterface['compress']['request']) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/worker-types.ts: -------------------------------------------------------------------------------- 1 | import { CompressedImage, type CompressParameters } from './encode/types' 2 | 3 | export type WorkerInterface = { 4 | compress: { 5 | request: { 6 | type: 'compress', 7 | payload: CompressParameters 8 | } 9 | result: Awaited & { source: Uint8ClampedArray } 10 | } 11 | } 12 | 13 | /** 14 | * Transferable 15 | */ 16 | export type Transferable = ArrayBufferLike | ImageBitmap 17 | 18 | export type WorkerInterfaceImplementation = { 19 | [k in keyof WorkerInterface]: (payload: WorkerInterface[k]['request']['payload']) => Promise 20 | } 21 | 22 | export type WorkerRequest = WorkerInterface[keyof WorkerInterface]['request'] 23 | 24 | export type WithTransferListFunction = (payload: T, transferList: Transferable[]) => T 25 | 26 | export type PromiseWorkerType = { 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | postMessage: (message: any, transferables?: Transferable[]) => Promise 29 | } 30 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error untyped lib 2 | import registerPromiseWorker from 'promise-worker-transferable/register' 3 | 4 | import { compress } from './encode/compress' 5 | import { type WithTransferListFunction, type WorkerInterface, type WorkerRequest } from './worker-types' 6 | 7 | const _compress = async (message: WorkerInterface['compress']['request'], withTransferList: WithTransferListFunction): Promise => { 8 | const result = await compress(message.payload) 9 | return withTransferList({ 10 | ...result, 11 | source: message.payload.source instanceof ImageData ? message.payload.source.data : new Uint8ClampedArray(message.payload.source) 12 | }, [result.data.buffer, message.payload.source instanceof ImageData ? message.payload.source.data.buffer : message.payload.source.buffer]) 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 16 | registerPromiseWorker(async (message: WorkerRequest, withTransferList: WithTransferListFunction) => { 17 | switch (message.type) { 18 | // case 'encode-gainmap-buffers': 19 | // return encodeGainmapBuffers(message, withTransferList) 20 | case 'compress': 21 | return _compress(message, withTransferList) 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /tests/__snapshots__/decode/decode.test.ts/chromium-material-values.json: -------------------------------------------------------------------------------- 1 | {"sdr":{"metadata":{"version":4.6,"type":"Texture","generator":"Texture.toJSON"},"uuid":"55327fab-b82f-4596-92f7-3477261b561a","name":"","image":"28892867-3d10-4beb-899d-055d9994648f","mapping":300,"channel":0,"repeat":[1,1],"offset":[0,0],"center":[0,0],"rotation":0,"wrap":[1001,1001],"format":1023,"internalFormat":null,"type":1009,"colorSpace":"srgb","minFilter":1008,"magFilter":1006,"anisotropy":1,"flipY":true,"generateMipmaps":true,"premultiplyAlpha":false,"unpackAlignment":4},"gainMap":{"metadata":{"version":4.6,"type":"Texture","generator":"Texture.toJSON"},"uuid":"faa11cf8-81f1-436b-92e6-62526490046a","name":"","image":"369e2641-90ff-4df5-966d-86e5eda0610f","mapping":300,"channel":0,"repeat":[1,1],"offset":[0,0],"center":[0,0],"rotation":0,"wrap":[1001,1001],"format":1023,"internalFormat":null,"type":1009,"colorSpace":"srgb-linear","minFilter":1008,"magFilter":1006,"anisotropy":1,"flipY":true,"generateMipmaps":true,"premultiplyAlpha":false,"unpackAlignment":4},"offsetHdr":[0.015625,0.015625,0.015625],"offsetSdr":[0.015625,0.015625,0.015625],"gainMapMin":[0,0,0],"gainMapMax":[15.9993,15.9993,15.9993],"gamma":[1,1,1],"hdrCapacityMin":0,"hdrCapacityMax":15.9993,"maxDisplayBoost":65504.20944752219} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/decode.test.ts/chromium-render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/decode/decode.test.ts/chromium-render.png -------------------------------------------------------------------------------- /tests/__snapshots__/decode/loaders/GainMapLoader.test.ts/chromium-render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/decode/loaders/GainMapLoader.test.ts/chromium-render.png -------------------------------------------------------------------------------- /tests/__snapshots__/decode/loaders/HDRJPGLoader.test.ts/chromium-render-no-create-image-bitmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/decode/loaders/HDRJPGLoader.test.ts/chromium-render-no-create-image-bitmap.png -------------------------------------------------------------------------------- /tests/__snapshots__/decode/loaders/HDRJPGLoader.test.ts/chromium-render-plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/decode/loaders/HDRJPGLoader.test.ts/chromium-render-plain.png -------------------------------------------------------------------------------- /tests/__snapshots__/decode/loaders/HDRJPGLoader.test.ts/chromium-render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/decode/loaders/HDRJPGLoader.test.ts/chromium-render.png -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/MPFExtractor.test.ts/chromium-01-jpg-gainmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/decode/utils/MPFExtractor.test.ts/chromium-01-jpg-gainmap.png -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/MPFExtractor.test.ts/chromium-01-jpg-sdr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/decode/utils/MPFExtractor.test.ts/chromium-01-jpg-sdr.png -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-01-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[-0.141127,-0.107269,-0.309696],"gainMapMax":[2.474807,2.453772,2.44652],"gamma":[0.250409,0.25,0.325208],"offsetSdr":[0.183221,0.183221,0.183221],"offsetHdr":[0.183221,0.183221,0.183221],"hdrCapacityMin":0,"hdrCapacityMax":2.8} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-02-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[0.000312,0.034719,0.053824],"gainMapMax":[3.522114,3.060263,2.706481],"gamma":[0.336811,0.357914,0.386615],"offsetSdr":[0.225551,0.225551,0.225551],"offsetHdr":[0.225551,0.225551,0.225551],"hdrCapacityMin":0,"hdrCapacityMax":4} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-03-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[0.000017,-0.000003,0.000012],"gainMapMax":[0.849514,0.748561,0.753096],"gamma":[0.25,0.25,0.25],"offsetSdr":[10.308957,10.308957,10.308957],"offsetHdr":[10.308957,10.308957,10.308957],"hdrCapacityMin":0,"hdrCapacityMax":4} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-04-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[0.023869,0.075191,0.142298],"gainMapMax":[3.527605,2.830234,1.537243],"gamma":[0.506828,0.590032,1.517708],"offsetSdr":[0.046983,0.046983,0.046983],"offsetHdr":[0.046983,0.046983,0.046983],"hdrCapacityMin":0,"hdrCapacityMax":3.9} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-05-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[-0.014377,-0.005596,-0.004277],"gainMapMax":[1.560281,1.560309,1.560328],"gamma":[0.25,0.25,0.25],"offsetSdr":[6.695587,6.695587,6.695587],"offsetHdr":[6.695587,6.695587,6.695587],"hdrCapacityMin":0,"hdrCapacityMax":4} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-06-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[-0.144892,-0.140972,-0.065712],"gainMapMax":[3.482396,3.126128,2.75434],"gamma":[0.252136,0.263644,0.25],"offsetSdr":[0.37841,0.37841,0.37841],"offsetHdr":[0.37841,0.37841,0.37841],"hdrCapacityMin":0,"hdrCapacityMax":4} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-07-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[-0.950004,-1.06685,-1.203979],"gainMapMax":[3.961703,3.737166,3.259504],"gamma":[0.622845,0.726034,0.869492],"offsetSdr":[0.028746,0.028746,0.028746],"offsetHdr":[0.028746,0.028746,0.028746],"hdrCapacityMin":0,"hdrCapacityMax":4} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-08-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[-0.012107,-0.028537,-0.071069],"gainMapMax":[2.668896,2.580987,2.457281],"gamma":[0.260823,0.354576,0.47235],"offsetSdr":[0.09302,0.09302,0.09302],"offsetHdr":[0.09302,0.09302,0.09302],"hdrCapacityMin":0,"hdrCapacityMax":4} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-09-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[-0.551779,-0.542884,-0.529494],"gainMapMax":[3.270427,3.225892,3.196919],"gamma":[0.42047,0.420672,0.421504],"offsetSdr":[0.017543,0.017543,0.017543],"offsetHdr":[0.017543,0.017543,0.017543],"hdrCapacityMin":0,"hdrCapacityMax":3.9} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-10-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[-0.135098,-0.133564,-0.128712],"gainMapMax":[2.941015,2.410044,1.595006],"gamma":[0.36099,0.406118,0.538082],"offsetSdr":[0.02503,0.02503,0.02503],"offsetHdr":[0.02503,0.02503,0.02503],"hdrCapacityMin":0,"hdrCapacityMax":4} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-abandoned-bakery-16k-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[0,0,0],"gainMapMax":[12.6294,12.6294,12.6294],"gamma":[1,1,1],"offsetSdr":[0.015625,0.015625,0.015625],"offsetHdr":[0.015625,0.015625,0.015625],"hdrCapacityMin":0,"hdrCapacityMax":12.6294} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-pisa-4k-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[0,0,0],"gainMapMax":[3.13443,3.13443,3.13443],"gamma":[1,1,1],"offsetSdr":[0.015625,0.015625,0.015625],"offsetHdr":[0.015625,0.015625,0.015625],"hdrCapacityMin":0,"hdrCapacityMax":3.13443} -------------------------------------------------------------------------------- /tests/__snapshots__/decode/utils/extractXMP.test.ts/chromium-extracts-xmp-from-spruit-sunrise-4k-jpg-1.txt: -------------------------------------------------------------------------------- 1 | {"gainMapMin":[0,0,0],"gainMapMax":[15.9993,15.9993,15.9993],"gamma":[1,1,1],"offsetSdr":[0.015625,0.015625,0.015625],"offsetHdr":[0.015625,0.015625,0.015625],"hdrCapacityMin":0,"hdrCapacityMax":15.9993} -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode-and-compress.test.ts/chromium-memorial-exr-encode-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode-and-compress.test.ts/chromium-memorial-exr-encode-result.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode-and-compress.test.ts/chromium-odd-sized-exr-encode-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode-and-compress.test.ts/chromium-odd-sized-exr-encode-result.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-custom-params-gainmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-custom-params-gainmap.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-custom-sdr-params.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-custom-sdr-params.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-gainmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-gainmap.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-ACESFilmicToneMapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-ACESFilmicToneMapping.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-CineonToneMapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-CineonToneMapping.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-INVALID.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-INVALID.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-LinearToneMapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-LinearToneMapping.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-ReinhardToneMapping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result-with-tone-mapping-ReinhardToneMapping.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/encode/encode.test.ts/chromium-memorial-exr-encode-result.png -------------------------------------------------------------------------------- /tests/__snapshots__/encode/find-texture-min-max.test.ts/chromium-finds-max-values-in-exr-1.txt: -------------------------------------------------------------------------------- 1 | [274,218,126] -------------------------------------------------------------------------------- /tests/__snapshots__/encode/find-texture-min-max.test.ts/chromium-finds-min-values-in-exr-1.txt: -------------------------------------------------------------------------------- 1 | [0.212158203125,0.212158203125,0.212158203125] -------------------------------------------------------------------------------- /tests/__snapshots__/examples/integrated-example.test.ts/chromium-initial.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/examples/integrated-example.test.ts/chromium-initial.png -------------------------------------------------------------------------------- /tests/__snapshots__/examples/integrated-example.test.ts/chromium-zoomed-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/examples/integrated-example.test.ts/chromium-zoomed-in.png -------------------------------------------------------------------------------- /tests/__snapshots__/examples/integrated-example.test.ts/chromium-zoomed-out-from-above.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/__snapshots__/examples/integrated-example.test.ts/chromium-zoomed-out-from-above.png -------------------------------------------------------------------------------- /tests/coverage-comment-template.md: -------------------------------------------------------------------------------- 1 | ## Coverage Report 2 | 3 | Commit: [{{short_commit_sha}}]({{commit_link}}) 4 | Base: [{{base_ref}}@{{base_short_commit_sha}}]({{base_commit_link}}) 5 | 6 | | Type | Base | This PR | 7 | |---------------------------|--------------------------------------------|------------------------------------------------------------------------------------| 8 | | Total Statements Coverage | {{base_total_statements_coverage_percent}} | {{total_statements_coverage_percent}} ({{total_statements_coverage_percent_diff}}) | 9 | | Total Branches Coverage | {{base_total_branches_coverage_percent}} | {{total_branches_coverage_percent}} ({{total_branches_coverage_percent_diff}}) | 10 | | Total Functions Coverage | {{base_total_functions_coverage_percent}} | {{total_functions_coverage_percent}} ({{total_functions_coverage_percent_diff}}) | 11 | | Total Lines Coverage | {{base_total_lines_coverage_percent}} | {{total_lines_coverage_percent}} ({{total_lines_coverage_percent_diff}}) | 12 | 13 |
14 | Details (changed files) 15 | {{changed_files_coverage_table}} 16 |
17 |
18 | Details (all files) 19 | {{files_coverage_table}} 20 |
21 | -------------------------------------------------------------------------------- /tests/decode/decode.test.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleMessage, expect } from '@playwright/test' 2 | 3 | import { test } from '../testWithCoverage' 4 | import { decodeInBrowser } from './decode' 5 | 6 | // const matrix = [ 7 | // '01.jpg', 8 | // '02.jpg', 9 | // '03.jpg', 10 | // '04.jpg', 11 | // '05.jpg', 12 | // '06.jpg', 13 | // '07.jpg', 14 | // '08.jpg', 15 | // '09.jpg', 16 | // '10.jpg', 17 | // 'pisa-4k.jpg', 18 | // 'spruit_sunrise_4k.jpg', 19 | // 'abandoned_bakery_16k.jpg' 20 | // ] 21 | 22 | // for (const testFile of matrix) { 23 | 24 | // } 25 | 26 | test('decodes from jpeg', async ({ page }) => { 27 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 28 | 29 | const logs: string[] = [] 30 | page.on('console', (m: ConsoleMessage) => { 31 | logs.push(m.text()) 32 | }) 33 | 34 | const script = page.getByTestId('script') 35 | await expect(script).toBeAttached() 36 | 37 | const result = await page.evaluate(decodeInBrowser, { file: 'files/spruit_sunrise_4k.jpg' }) 38 | 39 | expect(JSON.stringify(result.materialValues)).toMatchSnapshot({ name: 'material-values.json' }) 40 | 41 | // test conversion to appropriate colorspace happens 42 | expect(logs.find(m => m.match(/Gainmap Colorspace needs to be/gi))).toBeTruthy() 43 | expect(logs.find(m => m.match(/SDR Colorspace needs to be/gi))).toBeTruthy() 44 | 45 | await expect(page).toHaveScreenshot('render.png') 46 | }) 47 | -------------------------------------------------------------------------------- /tests/decode/decode.ts: -------------------------------------------------------------------------------- 1 | import * as decode from '@monogrid/gainmap-js' 2 | import * as THREE from 'three' 3 | /** 4 | * test evaluated inside browser 5 | * 6 | * @param args 7 | * @returns 8 | */ 9 | export const decodeInBrowser = async (args: { file: string }) => { 10 | const renderer = new THREE.WebGLRenderer() 11 | document.body.append(renderer.domElement) 12 | renderer.setSize(window.innerWidth, window.innerHeight) 13 | 14 | // fetch a JPEG image containing a gainmap as ArrayBuffer 15 | const file = await fetch(args.file) 16 | const fileBuffer = await file.arrayBuffer() 17 | const jpeg = new Uint8Array(fileBuffer) 18 | 19 | // extract data from the JPEG 20 | const { gainMap: gainMapBuffer, sdr: sdrBuffer, metadata } = await decode.extractGainmapFromJPEG(jpeg) 21 | 22 | // create data blobs 23 | const gainMapBlob = new Blob([gainMapBuffer], { type: 'image/jpeg' }) 24 | const sdrBlob = new Blob([sdrBuffer], { type: 'image/jpeg' }) 25 | 26 | // create ImageBitmap data 27 | const [gainMapImageBitmap, sdrImageBitmap] = await Promise.all([ 28 | createImageBitmap(gainMapBlob, { imageOrientation: 'flipY' }), 29 | createImageBitmap(sdrBlob, { imageOrientation: 'flipY' }) 30 | ]) 31 | 32 | const gainMap = new THREE.Texture(gainMapImageBitmap) 33 | gainMap.needsUpdate = true 34 | 35 | const sdr = new THREE.Texture(sdrImageBitmap) 36 | sdr.needsUpdate = true 37 | 38 | // restore the HDR texture 39 | const result = decode.decode({ 40 | sdr, 41 | gainMap, 42 | renderer, 43 | maxDisplayBoost: Math.pow(2, metadata.hdrCapacityMax), 44 | ...metadata 45 | }) 46 | 47 | const scene = new THREE.Scene() 48 | 49 | const plane = new THREE.Mesh( 50 | new THREE.PlaneGeometry(), 51 | new THREE.MeshBasicMaterial({ map: result.renderTarget.texture }) 52 | ) 53 | const ratio = result.width / result.height 54 | plane.scale.y = Math.min(1, 1 / ratio) 55 | plane.scale.x = Math.min(1, ratio) 56 | 57 | const camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5) 58 | camera.position.z = 10 59 | 60 | scene.add(plane) 61 | renderer.render(scene, camera) 62 | 63 | result.dispose() 64 | 65 | return { 66 | jpeg: Array.from(jpeg), 67 | materialValues: { 68 | sdr: result.material.sdr.toJSON(), 69 | gainMap: result.material.gainMap.toJSON(), 70 | offsetHdr: result.material.offsetHdr, 71 | offsetSdr: result.material.offsetSdr, 72 | gainMapMin: result.material.gainMapMin, 73 | gainMapMax: result.material.gainMapMax, 74 | gamma: result.material.gamma, 75 | hdrCapacityMin: result.material.hdrCapacityMin, 76 | hdrCapacityMax: result.material.hdrCapacityMax, 77 | maxDisplayBoost: result.material.maxDisplayBoost 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/decode/extract.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | 3 | import { test } from '../testWithCoverage' 4 | import { extractInBrowser } from './extract' 5 | 6 | test('extracts from a valid jpeg', async ({ page }) => { 7 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 8 | 9 | const script = page.getByTestId('script') 10 | await expect(script).toBeAttached() 11 | 12 | const result = await page.evaluate(extractInBrowser, { file: 'files/spruit_sunrise_4k.jpg' }) 13 | 14 | expect(result).not.toBeUndefined() 15 | }) 16 | 17 | test('throws from an invalid jpeg', async ({ page }) => { 18 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 19 | 20 | const script = page.getByTestId('script') 21 | await expect(script).toBeAttached() 22 | 23 | const shouldThrow = async () => { 24 | await page.evaluate(extractInBrowser, { file: 'files/plain-jpeg.jpg' }) 25 | } 26 | await expect(shouldThrow).rejects.toThrowError(/XMP metadata not found/) 27 | }) 28 | -------------------------------------------------------------------------------- /tests/decode/extract.ts: -------------------------------------------------------------------------------- 1 | import * as decode from '@monogrid/gainmap-js' 2 | /** 3 | * test evaluated inside browser 4 | * @param args 5 | * @returns 6 | */ 7 | export const extractInBrowser = async (args: { file: string }) => { 8 | // fetch a JPEG image containing a gainmap as ArrayBuffer 9 | const file = await fetch(args.file) 10 | const fileBuffer = await file.arrayBuffer() 11 | const jpeg = new Uint8Array(fileBuffer) 12 | return decode.extractGainmapFromJPEG(jpeg) 13 | } 14 | -------------------------------------------------------------------------------- /tests/decode/loaders/GainMapLoader.test.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleMessage, expect } from '@playwright/test' 2 | 3 | import { test } from '../../testWithCoverage' 4 | import { testGainMapLoaderInBrowser } from './gainmap-loader' 5 | 6 | // const matrix = [ 7 | // '01.jpg', 8 | // '02.jpg', 9 | // '03.jpg', 10 | // '04.jpg', 11 | // '05.jpg', 12 | // '06.jpg', 13 | // '07.jpg', 14 | // '08.jpg', 15 | // '09.jpg', 16 | // '10.jpg', 17 | // 'pisa-4k.jpg', 18 | // 'spruit_sunrise_4k.jpg', 19 | // 'abandoned_bakery_16k.jpg' 20 | // ] 21 | 22 | // for (const testFile of matrix) { 23 | 24 | // } 25 | 26 | test('loads from webp', async ({ page }) => { 27 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 28 | 29 | const script = page.getByTestId('script') 30 | await expect(script).toBeAttached() 31 | 32 | await page.evaluate(testGainMapLoaderInBrowser, { 33 | sdr: 'files/spruit_sunrise_4k.webp', 34 | gainmap: 'files/spruit_sunrise_4k-gainmap.webp', 35 | metadata: 'files/spruit_sunrise_4k.json' 36 | }) 37 | 38 | await expect(page).toHaveScreenshot('render.png') 39 | }) 40 | 41 | test('loads from webp sync', async ({ page }) => { 42 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 43 | 44 | const logs: string[] = [] 45 | page.on('console', (m: ConsoleMessage) => { 46 | logs.push(m.text()) 47 | }) 48 | 49 | const script = page.getByTestId('script') 50 | await expect(script).toBeAttached() 51 | 52 | await page.evaluate(testGainMapLoaderInBrowser, { 53 | sync: true, 54 | sdr: 'files/spruit_sunrise_4k.webp', 55 | gainmap: 'files/spruit_sunrise_4k-gainmap.webp', 56 | metadata: 'files/spruit_sunrise_4k.json' 57 | }) 58 | 59 | // test loading progress happens 60 | expect(logs.find(mess => mess.match(/loading/gi))).toBeTruthy() 61 | 62 | await expect(page).toHaveScreenshot('render.png') 63 | }) 64 | 65 | test('throws with an invalid sdr', async ({ page }) => { 66 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 67 | 68 | const script = page.getByTestId('script') 69 | await expect(script).toBeAttached() 70 | 71 | const shouldThrow = async () => { 72 | await page.evaluate(testGainMapLoaderInBrowser, { 73 | sdr: 'files/invalid_image.png', 74 | gainmap: 'files/spruit_sunrise_4k-gainmap.webp', 75 | metadata: 'files/spruit_sunrise_4k.json' 76 | }) 77 | } 78 | 79 | await expect(shouldThrow).rejects.toThrow(/The source image could not be decoded/) 80 | }) 81 | 82 | test('throws with an invalid gainmap', async ({ page }) => { 83 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 84 | 85 | const script = page.getByTestId('script') 86 | await expect(script).toBeAttached() 87 | 88 | const shouldThrow = async () => { 89 | await page.evaluate(testGainMapLoaderInBrowser, { 90 | sdr: 'files/spruit_sunrise_4k.webp', 91 | gainmap: 'files/invalid_image.png', 92 | metadata: 'files/spruit_sunrise_4k.json' 93 | }) 94 | } 95 | 96 | await expect(shouldThrow).rejects.toThrow(/The source image could not be decoded/) 97 | }) 98 | 99 | test('throws with it doesn\'t find the sdr', async ({ page }) => { 100 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 101 | 102 | const script = page.getByTestId('script') 103 | await expect(script).toBeAttached() 104 | 105 | const shouldThrow = async () => { 106 | await page.evaluate(testGainMapLoaderInBrowser, { 107 | sdr: 'nope', 108 | gainmap: 'files/spruit_sunrise_4k-gainmap.webp', 109 | metadata: 'files/spruit_sunrise_4k.json' 110 | }) 111 | } 112 | 113 | await expect(shouldThrow).rejects.toThrow(/404/) 114 | }) 115 | 116 | test('throws with it doesn\'t find the gainmap', async ({ page }) => { 117 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 118 | 119 | const script = page.getByTestId('script') 120 | await expect(script).toBeAttached() 121 | 122 | const shouldThrow = async () => { 123 | await page.evaluate(testGainMapLoaderInBrowser, { 124 | sdr: 'files/spruit_sunrise_4k.webp', 125 | gainmap: 'nope', 126 | metadata: 'files/spruit_sunrise_4k.json' 127 | }) 128 | } 129 | 130 | await expect(shouldThrow).rejects.toThrow(/404/) 131 | }) 132 | 133 | test('throws with it doesn\'t find the metadata', async ({ page }) => { 134 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 135 | 136 | const script = page.getByTestId('script') 137 | await expect(script).toBeAttached() 138 | 139 | const shouldThrow = async () => { 140 | await page.evaluate(testGainMapLoaderInBrowser, { 141 | sdr: 'files/spruit_sunrise_4k.webp', 142 | gainmap: 'files/spruit_sunrise_4k-gainmap.webp', 143 | metadata: 'nope' 144 | }) 145 | } 146 | 147 | await expect(shouldThrow).rejects.toThrow(/404/) 148 | }) 149 | -------------------------------------------------------------------------------- /tests/decode/loaders/HDRJPGLoader.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | 3 | import { disableCreateImageBitmap } from '../../disableBrowserFeatures' 4 | import { test } from '../../testWithCoverage' 5 | import { testHDRJpegLoaderInBrowser } from './hdr-jpg-loader' 6 | 7 | // const matrix = [ 8 | // '01.jpg', 9 | // '02.jpg', 10 | // '03.jpg', 11 | // '04.jpg', 12 | // '05.jpg', 13 | // '06.jpg', 14 | // '07.jpg', 15 | // '08.jpg', 16 | // '09.jpg', 17 | // '10.jpg', 18 | // 'pisa-4k.jpg', 19 | // 'spruit_sunrise_4k.jpg', 20 | // 'abandoned_bakery_16k.jpg' 21 | // ] 22 | 23 | // for (const testFile of matrix) { 24 | 25 | // } 26 | 27 | test('loads from jpeg', async ({ page }) => { 28 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 29 | 30 | const script = page.getByTestId('script') 31 | await expect(script).toBeAttached() 32 | 33 | await page.evaluate(testHDRJpegLoaderInBrowser, { file: 'files/spruit_sunrise_4k.jpg' }) 34 | 35 | await expect(page).toHaveScreenshot('render.png') 36 | }) 37 | 38 | test('loads from jpeg in browsers where createImageBitmap is not available', async ({ page }) => { 39 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 40 | 41 | const script = page.getByTestId('script') 42 | await expect(script).toBeAttached() 43 | 44 | await page.evaluate(disableCreateImageBitmap) 45 | await page.evaluate(testHDRJpegLoaderInBrowser, { file: 'files/spruit_sunrise_4k.jpg' }) 46 | 47 | await expect(page).toHaveScreenshot('render-no-create-image-bitmap.png') 48 | }) 49 | 50 | test('loads a plain jpeg anyway', async ({ page }) => { 51 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 52 | 53 | const script = page.getByTestId('script') 54 | await expect(script).toBeAttached() 55 | 56 | await page.evaluate(testHDRJpegLoaderInBrowser, { file: 'files/plain-jpeg.jpg' }) 57 | 58 | await expect(page).toHaveScreenshot('render-plain.png') 59 | }) 60 | 61 | test('throws with an invalid image', async ({ page }) => { 62 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 63 | 64 | const script = page.getByTestId('script') 65 | await expect(script).toBeAttached() 66 | 67 | const shouldThrow = async () => { 68 | await page.evaluate(testHDRJpegLoaderInBrowser, { file: 'files/invalid_image.png' }) 69 | } 70 | 71 | await expect(shouldThrow).rejects.toThrow(/The source image could not be decoded/) 72 | }) 73 | 74 | test('throws with a not found image', async ({ page }) => { 75 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 76 | 77 | const script = page.getByTestId('script') 78 | await expect(script).toBeAttached() 79 | 80 | const shouldThrow = async () => { 81 | await page.evaluate(testHDRJpegLoaderInBrowser, { file: 'nope' }) 82 | } 83 | 84 | await expect(shouldThrow).rejects.toThrow(/404/) 85 | }) 86 | -------------------------------------------------------------------------------- /tests/decode/loaders/gainmap-loader.ts: -------------------------------------------------------------------------------- 1 | import * as decode from '@monogrid/gainmap-js' 2 | import * as THREE from 'three' 3 | /** 4 | * 5 | * @param args 6 | */ 7 | export const testGainMapLoaderInBrowser = (args: { sdr: string, gainmap: string, metadata: string, exposure?: number, sync?: boolean } & Partial) => { 8 | // eslint-disable-next-line no-async-promise-executor 9 | return new Promise(async (resolve, reject) => { 10 | const renderer = new THREE.WebGLRenderer() 11 | renderer.toneMapping = THREE.LinearToneMapping 12 | renderer.toneMappingExposure = args.exposure || 1 13 | 14 | document.body.append(renderer.domElement) 15 | renderer.setSize(window.innerWidth, window.innerHeight) 16 | const loader = new decode.GainMapLoader(renderer) 17 | 18 | const onLoadingDone = (result: decode.QuadRenderer<1016, decode.GainMapDecoderMaterial>) => { 19 | if (args.maxDisplayBoost) { 20 | result.material.maxDisplayBoost = args.maxDisplayBoost 21 | result.render() 22 | } 23 | 24 | const scene = new THREE.Scene() 25 | const plane = new THREE.Mesh( 26 | new THREE.PlaneGeometry(), 27 | new THREE.MeshBasicMaterial({ map: result.renderTarget.texture }) 28 | ) 29 | const ratio = result.width / result.height 30 | plane.scale.y = Math.min(1, 1 / ratio) 31 | plane.scale.x = Math.min(1, ratio) 32 | scene.add(plane) 33 | 34 | scene.background = result.toDataTexture({ 35 | mapping: THREE.EquirectangularReflectionMapping, 36 | minFilter: THREE.LinearFilter, 37 | generateMipmaps: false 38 | }) 39 | scene.background.needsUpdate = true 40 | 41 | // result must be manually disposed 42 | // when you are done using it 43 | result.dispose() 44 | 45 | const camera = new THREE.PerspectiveCamera() 46 | camera.position.z = 3 47 | renderer.render(scene, camera) 48 | 49 | resolve() 50 | } 51 | 52 | if (!args.sync) { 53 | let result: decode.QuadRenderer<1016, decode.GainMapDecoderMaterial> 54 | try { 55 | result = await loader.loadAsync([ 56 | args.sdr, 57 | args.gainmap, 58 | args.metadata 59 | ]) 60 | } catch (e) { 61 | // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors 62 | reject(e) 63 | return 64 | } 65 | onLoadingDone(result) 66 | } else { 67 | loader.load( 68 | [ 69 | args.sdr, 70 | args.gainmap, 71 | args.metadata 72 | ], 73 | result => onLoadingDone(result), 74 | (evt) => { 75 | console.log('loading', evt.loaded, 'of', evt.total) 76 | } 77 | ) 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /tests/decode/loaders/hdr-jpg-loader.ts: -------------------------------------------------------------------------------- 1 | import * as decode from '@monogrid/gainmap-js' 2 | import * as THREE from 'three' 3 | 4 | export const testHDRJpegLoaderInBrowser = async (args: { file: string, exposure?: number } & Partial) => { 5 | const renderer = new THREE.WebGLRenderer() 6 | renderer.toneMapping = THREE.LinearToneMapping 7 | renderer.toneMappingExposure = args.exposure || 1 8 | document.body.append(renderer.domElement) 9 | renderer.setSize(window.innerWidth, window.innerHeight) 10 | const loader = new decode.HDRJPGLoader(renderer) 11 | 12 | const result = await loader.loadAsync(args.file) 13 | 14 | if (args.maxDisplayBoost) { 15 | result.material.maxDisplayBoost = args.maxDisplayBoost 16 | result.render() 17 | } 18 | 19 | const scene = new THREE.Scene() 20 | const plane = new THREE.Mesh( 21 | new THREE.PlaneGeometry(), 22 | new THREE.MeshBasicMaterial({ map: result.renderTarget.texture }) 23 | ) 24 | const ratio = result.width / result.height 25 | plane.scale.y = Math.min(1, 1 / ratio) 26 | plane.scale.x = Math.min(1, ratio) 27 | scene.add(plane) 28 | 29 | scene.background = result.toDataTexture({ 30 | mapping: THREE.EquirectangularReflectionMapping, 31 | minFilter: THREE.LinearFilter, 32 | generateMipmaps: false 33 | }) 34 | scene.background.needsUpdate = true 35 | 36 | // result must be manually disposed 37 | // when you are done using it 38 | result.dispose() 39 | 40 | const camera = new THREE.PerspectiveCamera() 41 | camera.position.z = 3 42 | renderer.render(scene, camera) 43 | } 44 | -------------------------------------------------------------------------------- /tests/decode/utils/MPFExtractor.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | import sharp from 'sharp' 3 | 4 | import { test } from '../../testWithCoverage' 5 | import { testMPFExtractorInBrowser } from './MPFExtractor' 6 | 7 | const matrix = [ 8 | '01.jpg' 9 | // '02.jpg', 10 | // '03.jpg', 11 | // '04.jpg', 12 | // '05.jpg', 13 | // '06.jpg', 14 | // '07.jpg', 15 | // '08.jpg', 16 | // '09.jpg', 17 | // '10.jpg', 18 | // 'pisa-4k.jpg', 19 | // 'spruit_sunrise_4k.jpg' 20 | // 'abandoned_bakery_16k.jpg' // too bit to test? snapshot testing fails 21 | ] 22 | 23 | for (const testFile of matrix) { 24 | test(`extracts gainmap from ${testFile}`, async ({ page }) => { 25 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 26 | 27 | const script = page.getByTestId('script') 28 | await expect(script).toBeAttached() 29 | 30 | const result = await page.evaluate(testMPFExtractorInBrowser, `files/${testFile}`) 31 | 32 | expect(result).not.toBeNull() 33 | expect(result.length).toBe(2) 34 | 35 | const sdr = await sharp(Buffer.from(result[0])) 36 | .resize({ width: 500, height: 500, fit: 'inside' }) 37 | .png({ compressionLevel: 9, effort: 10 }) 38 | .toBuffer() 39 | 40 | expect(sdr).not.toBeNull() // temporary 41 | expect(sdr).toMatchSnapshot(`${testFile}-sdr.png`) 42 | 43 | const gainMap = await sharp(Buffer.from(result[1])) 44 | .resize({ width: 500, height: 500, fit: 'inside' }) 45 | .png({ compressionLevel: 9, effort: 10 }) 46 | .toBuffer() 47 | 48 | expect(gainMap).not.toBeNull() // temporary 49 | expect(gainMap).toMatchSnapshot(`${testFile}-gainmap.png`) 50 | }) 51 | } 52 | 53 | test('throw when given a plain jpeg', async ({ page }) => { 54 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 55 | 56 | const script = page.getByTestId('script') 57 | await expect(script).toBeAttached() 58 | 59 | const shouldThrow = async () => { 60 | await page.evaluate(testMPFExtractorInBrowser, 'files/plain-jpeg.jpg') 61 | } 62 | 63 | await expect(shouldThrow).rejects.toThrow(/Not a valid marker at offset/) 64 | }) 65 | 66 | test('throw when given an invalid jpeg', async ({ page }) => { 67 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 68 | 69 | const script = page.getByTestId('script') 70 | await expect(script).toBeAttached() 71 | 72 | const shouldThrow = async () => { 73 | await page.evaluate(testMPFExtractorInBrowser, 'files/invalid_image.png') 74 | } 75 | 76 | await expect(shouldThrow).rejects.toThrow(/Not a valid jpeg/) 77 | }) 78 | 79 | // test('extracts an unrelated mpf image', async ({ page }) => { 80 | // await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 81 | 82 | // const script = page.getByTestId('script') 83 | // await expect(script).toBeAttached() 84 | 85 | // const result = await page.evaluate(testMPFExtractorInBrowser, 'files/340_AppleiPhoneXSMax_IMG_E7156.jpg') 86 | 87 | // expect(result).not.toBeNull() 88 | // expect(result.length).toBe(2) 89 | // }) 90 | -------------------------------------------------------------------------------- /tests/decode/utils/MPFExtractor.ts: -------------------------------------------------------------------------------- 1 | import * as decode from '@monogrid/gainmap-js' 2 | 3 | /** 4 | * 5 | * @param testFile 6 | * @returns 7 | */ 8 | export const testMPFExtractorInBrowser = async (testFile: string | number[]) => { 9 | let jpeg 10 | if (typeof testFile === 'string') { 11 | const file = await fetch(testFile) 12 | const fileBuffer = await file.arrayBuffer() 13 | jpeg = new Uint8Array(fileBuffer) 14 | } else { 15 | jpeg = Uint8Array.from(testFile) 16 | } 17 | 18 | const extractor = new decode.MPFExtractor({ extractFII: true, extractNonFII: true }) 19 | const result = await extractor.extract(jpeg) 20 | const buffers = await Promise.all(result.map(blob => blob.arrayBuffer())) 21 | console.log(buffers) 22 | return buffers.map(buff => Array.from(new Uint8Array(buff))) 23 | } 24 | -------------------------------------------------------------------------------- /tests/decode/utils/extractXMP.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | 3 | import { test } from '../../testWithCoverage' 4 | import { extractXMPInBrowser } from './extractXMP' 5 | 6 | const matrix = [ 7 | '01.jpg', 8 | '02.jpg', 9 | '03.jpg', 10 | '04.jpg', 11 | '05.jpg', 12 | '06.jpg', 13 | '07.jpg', 14 | '08.jpg', 15 | '09.jpg', 16 | '10.jpg', 17 | 'pisa-4k.jpg', 18 | 'spruit_sunrise_4k.jpg', 19 | 'abandoned_bakery_16k.jpg' 20 | ] 21 | 22 | for (const testFile of matrix) { 23 | test(`extracts xmp from ${testFile}`, async ({ page }) => { 24 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 25 | 26 | const script = page.getByTestId('script') 27 | await expect(script).toBeAttached() 28 | 29 | const result = await page.evaluate(extractXMPInBrowser, `files/${testFile}`) 30 | 31 | expect(result).not.toBeUndefined() 32 | expect(JSON.stringify(result)).toMatchSnapshot() 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /tests/decode/utils/extractXMP.ts: -------------------------------------------------------------------------------- 1 | import * as decode from '@monogrid/gainmap-js' 2 | 3 | export const extractXMPInBrowser = async (testFile: string | number[]) => { 4 | if (typeof testFile === 'string') { 5 | const file = await fetch(testFile) 6 | const fileBuffer = await file.arrayBuffer() 7 | const jpeg = new Uint8Array(fileBuffer) 8 | return decode.extractXMP(jpeg) 9 | } 10 | return decode.extractXMP(Uint8Array.from(testFile)) 11 | } 12 | -------------------------------------------------------------------------------- /tests/disableBrowserFeatures.ts: -------------------------------------------------------------------------------- 1 | export const disableCreateImageBitmap = () => { 2 | // @ts-expect-error this is intentional 3 | window.createImageBitmap = undefined 4 | } 5 | export const disableOffscreenCanvas = () => { 6 | // @ts-expect-error this is intentional 7 | window.OffscreenCanvas = undefined 8 | } 9 | export const throwOnCanvasToBlob = () => { 10 | window.HTMLCanvasElement.prototype.toBlob = function () { throw new Error('error') } 11 | } 12 | export const returnNullOnCanvasToBlob = () => { 13 | window.HTMLCanvasElement.prototype.toBlob = function (cb) { cb(null) } 14 | } 15 | export const throwOnCanvasGetContext = () => { 16 | window.HTMLCanvasElement.prototype.getContext = function () { throw new Error('error') } 17 | } 18 | export const returnNullOnCanvasGetContext = () => { 19 | window.HTMLCanvasElement.prototype.getContext = function () { return null } 20 | } 21 | -------------------------------------------------------------------------------- /tests/encode/encode-and-compress.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | import sharp from 'sharp' 3 | 4 | import { disableCreateImageBitmap, disableOffscreenCanvas, returnNullOnCanvasToBlob } from '../disableBrowserFeatures' 5 | import { test } from '../testWithCoverage' 6 | import { encodeAndCompressInBrowser } from './encode-and-compress' 7 | // const matrix = [ 8 | // '01.jpg', 9 | // '02.jpg', 10 | // '03.jpg', 11 | // '04.jpg', 12 | // '05.jpg', 13 | // '06.jpg', 14 | // '07.jpg', 15 | // '08.jpg', 16 | // '09.jpg', 17 | // '10.jpg', 18 | // 'pisa-4k.jpg', 19 | // 'spruit_sunrise_4k.jpg', 20 | // 'abandoned_bakery_16k.jpg' 21 | // ] 22 | 23 | // for (const testFile of matrix) { 24 | 25 | // } 26 | 27 | test('encodes and compresses from exr', async ({ page }) => { 28 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 29 | 30 | const script = page.getByTestId('script') 31 | await expect(script).toBeAttached() 32 | 33 | const result = await page.evaluate(encodeAndCompressInBrowser, { file: 'files/memorial.exr' }) 34 | 35 | expect(result.length).toBeGreaterThan(0) 36 | 37 | const resized = await sharp(Buffer.from(result)) 38 | .resize({ width: 500, height: 500, fit: 'inside' }) 39 | .png({ compressionLevel: 9, effort: 10 }) 40 | .toBuffer() 41 | 42 | expect(resized).toMatchSnapshot('memorial.exr-encode-result.png') 43 | }) 44 | 45 | test('encodes and compresses odd sized images', async ({ page }) => { 46 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 47 | 48 | const script = page.getByTestId('script') 49 | await expect(script).toBeAttached() 50 | 51 | const result = await page.evaluate(encodeAndCompressInBrowser, { file: 'files/odd-sized.exr' }) 52 | 53 | expect(result.length).toBeGreaterThan(0) 54 | 55 | const resized = await sharp(Buffer.from(result)) 56 | .resize({ width: 500, height: 500, fit: 'inside' }) 57 | .png({ compressionLevel: 9, effort: 10 }) 58 | .toBuffer() 59 | 60 | expect(resized).toMatchSnapshot('odd-sized.exr-encode-result.png') 61 | }) 62 | 63 | test('encodes and compresses from exr using worker', async ({ page }) => { 64 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 65 | 66 | const script = page.getByTestId('script') 67 | await expect(script).toBeAttached() 68 | 69 | const result = await page.evaluate(encodeAndCompressInBrowser, { file: 'files/memorial.exr', withWorker: true }) 70 | 71 | expect(result.length).toBeGreaterThan(0) 72 | 73 | const resized = await sharp(Buffer.from(result)) 74 | .resize({ width: 500, height: 500, fit: 'inside' }) 75 | .png({ compressionLevel: 9, effort: 10 }) 76 | .toBuffer() 77 | 78 | expect(resized).toMatchSnapshot('memorial.exr-encode-result.png') 79 | }) 80 | 81 | test('encodes and compresses from exr with no OffscreenCanvas', async ({ page }) => { 82 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 83 | 84 | const script = page.getByTestId('script') 85 | await expect(script).toBeAttached() 86 | 87 | await page.evaluate(disableOffscreenCanvas) 88 | 89 | const result = await page.evaluate(encodeAndCompressInBrowser, { file: 'files/memorial.exr' }) 90 | 91 | expect(result.length).toBeGreaterThan(0) 92 | 93 | const resized = await sharp(Buffer.from(result)) 94 | .resize({ width: 500, height: 500, fit: 'inside' }) 95 | .png({ compressionLevel: 9, effort: 10 }) 96 | .toBuffer() 97 | 98 | expect(resized).toMatchSnapshot('memorial.exr-encode-result.png') 99 | }) 100 | 101 | test('fails when canvas.toBlob return null (as in TS types possibilities)', async ({ page }) => { 102 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 103 | 104 | const script = page.getByTestId('script') 105 | await expect(script).toBeAttached() 106 | 107 | await page.evaluate(disableOffscreenCanvas) 108 | await page.evaluate(returnNullOnCanvasToBlob) 109 | 110 | const shouldThrow = async () => { 111 | await page.evaluate(encodeAndCompressInBrowser, { file: 'files/memorial.exr' }) 112 | } 113 | 114 | await expect(shouldThrow).rejects.toThrow(/Failed to convert canvas to blob/) 115 | }) 116 | 117 | test('fails when createImageBitmap is not supported', async ({ page }) => { 118 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 119 | 120 | const script = page.getByTestId('script') 121 | await expect(script).toBeAttached() 122 | 123 | await page.evaluate(disableCreateImageBitmap) 124 | 125 | const shouldThrow = async () => { 126 | await page.evaluate(encodeAndCompressInBrowser, { file: 'files/memorial.exr' }) 127 | } 128 | 129 | await expect(shouldThrow).rejects.toThrow(/createImageBitmap.*not supported/gi) 130 | }) 131 | -------------------------------------------------------------------------------- /tests/encode/encode-and-compress.ts: -------------------------------------------------------------------------------- 1 | import * as encode from '@monogrid/gainmap-js/encode' 2 | import * as libultrahdr from '@monogrid/gainmap-js/libultrahdr' 3 | import * as workerInterface from '@monogrid/gainmap-js/worker-interface' 4 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 5 | 6 | /** 7 | * evaluated inside browser 8 | * 9 | * @param args 10 | * @returns 11 | */ 12 | export const encodeAndCompressInBrowser = async (args: Omit & { file: string, maxContentBoost?: number, mimeType?: encode.CompressionMimeType, quality?: number, withWorker?: boolean }) => { 13 | let withWorker 14 | if (args.withWorker) { 15 | withWorker = workerInterface.getWorkerInterface( 16 | workerInterface.getPromiseWorker( 17 | new Worker('../dist/worker.umd.cjs') 18 | ) 19 | ) 20 | } 21 | 22 | // load an HDR file 23 | const image = await new EXRLoader().loadAsync(args.file) 24 | 25 | // Encode the gainmap 26 | const encodingResult = await encode.encodeAndCompress({ 27 | image, 28 | toneMapping: args.toneMapping, 29 | gamma: args.gamma, 30 | minContentBoost: args.minContentBoost, 31 | offsetHdr: args.offsetHdr, 32 | offsetSdr: args.offsetSdr, 33 | renderTargetOptions: args.renderTargetOptions, 34 | maxContentBoost: args.maxContentBoost || Math.max.apply(this, encode.findTextureMinMax(image)), 35 | mimeType: args.mimeType || 'image/jpeg', 36 | quality: args.quality || 0.9, 37 | flipY: args.flipY !== undefined ? args.flipY : true, 38 | withWorker 39 | }) 40 | 41 | // embed the compressed images + metadata into a single 42 | // JPEG file 43 | const jpeg = await libultrahdr.encodeJPEGMetadata({ 44 | ...encodingResult, 45 | sdr: encodingResult.sdr, 46 | gainMap: encodingResult.gainMap 47 | }) 48 | 49 | return Array.from(jpeg) 50 | } 51 | -------------------------------------------------------------------------------- /tests/encode/encode.ts: -------------------------------------------------------------------------------- 1 | import * as encode from '@monogrid/gainmap-js/encode' 2 | import * as libultrahdr from '@monogrid/gainmap-js/libultrahdr' 3 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 4 | 5 | /** 6 | * test evaluated inside browser 7 | * 8 | * @param args 9 | * @returns 10 | */ 11 | export const encodeInBrowser = async (args: Omit & { file: string, mimeType?: encode.CompressionMimeType, maxContentBoost?: number, quality?: number, dynamicGainMapParameters?: Partial, dynamicSDRParameters?: { brightness?: number, contrast?: number, saturation?: number, exposure?: number } }) => { 12 | // load an HDR file 13 | const loader = new EXRLoader() 14 | const image = await loader.loadAsync(args.file) 15 | 16 | // Encode the gainmap 17 | const encodingResult = encode.encode({ 18 | image, 19 | toneMapping: args.toneMapping, 20 | gamma: args.gamma, 21 | minContentBoost: args.minContentBoost, 22 | offsetHdr: args.offsetHdr, 23 | offsetSdr: args.offsetSdr, 24 | renderTargetOptions: args.renderTargetOptions, 25 | // this will encode the full HDR range 26 | maxContentBoost: args.maxContentBoost || Math.max.apply(this, encode.findTextureMinMax(image)) 27 | }) 28 | 29 | // test re-render 30 | if (args.dynamicGainMapParameters !== undefined) { 31 | const { maxContentBoost, minContentBoost, gamma, offsetHdr, offsetSdr } = args.dynamicGainMapParameters 32 | if (maxContentBoost !== undefined) encodingResult.gainMap.material.maxContentBoost = maxContentBoost 33 | if (minContentBoost !== undefined) encodingResult.gainMap.material.minContentBoost = minContentBoost 34 | if (gamma !== undefined) encodingResult.gainMap.material.gamma = gamma 35 | if (offsetHdr !== undefined) encodingResult.gainMap.material.offsetHdr = offsetHdr 36 | if (offsetSdr !== undefined) encodingResult.gainMap.material.offsetSdr = offsetSdr 37 | encodingResult.gainMap.render() 38 | } 39 | 40 | // test re-render 41 | if (args.dynamicSDRParameters !== undefined) { 42 | const { brightness, contrast, exposure, saturation } = args.dynamicSDRParameters 43 | if (brightness !== undefined) encodingResult.sdr.material.brightness = brightness 44 | if (exposure !== undefined) encodingResult.sdr.material.exposure = exposure 45 | if (saturation !== undefined) encodingResult.sdr.material.saturation = saturation 46 | if (contrast !== undefined) encodingResult.sdr.material.contrast = contrast 47 | encodingResult.sdr.render() 48 | } 49 | 50 | // obtain the RAW RGBA SDR buffer and create an ImageData 51 | const sdrImageData = new ImageData(encodingResult.sdr.toArray(), encodingResult.sdr.width, encodingResult.sdr.height) 52 | // obtain the RAW RGBA Gain map buffer and create an ImageData 53 | const gainMapImageData = new ImageData(encodingResult.gainMap.toArray(), encodingResult.gainMap.width, encodingResult.gainMap.height) 54 | 55 | // parallel compress the RAW buffers into the specified mimeType 56 | const mimeType = args.mimeType || 'image/jpeg' 57 | const quality = args.quality || 0.9 58 | 59 | const [sdr, gainMap] = await Promise.all([ 60 | encode.compress({ 61 | source: sdrImageData, 62 | mimeType, 63 | quality, 64 | flipY: args.flipY !== undefined ? args.flipY : true // output needs to be flipped with EXR 65 | }), 66 | encode.compress({ 67 | source: gainMapImageData, 68 | mimeType, 69 | quality, 70 | flipY: args.flipY !== undefined ? args.flipY : true // output needs to be flipped with EXR 71 | }) 72 | ]) 73 | 74 | // obtain the metadata which will be embedded into 75 | // and XMP tag inside the final JPEG file 76 | const metadata = encodingResult.getMetadata() 77 | 78 | // embed the compressed images + metadata into a single 79 | // JPEG file 80 | const jpeg = await libultrahdr.encodeJPEGMetadata({ 81 | ...encodingResult, 82 | ...metadata, 83 | sdr, 84 | gainMap 85 | }) 86 | 87 | encodingResult.gainMap.dispose(true) 88 | encodingResult.sdr.dispose(true) 89 | 90 | return { 91 | jpeg: Array.from(jpeg), 92 | sdrMaterialValues: { 93 | toneMapping: encodingResult.sdr.material.toneMapping, 94 | brightness: encodingResult.sdr.material.brightness, 95 | contrast: encodingResult.sdr.material.contrast, 96 | saturation: encodingResult.sdr.material.saturation 97 | }, 98 | gainMapMaterialValues: { 99 | maxContentBoost: encodingResult.gainMap.material.maxContentBoost, 100 | minContentBoost: encodingResult.gainMap.material.minContentBoost, 101 | gainMapMax: encodingResult.gainMap.material.gainMapMax, 102 | gainMapMin: encodingResult.gainMap.material.gainMapMin, 103 | hdrCapacityMin: encodingResult.gainMap.material.hdrCapacityMin, 104 | hdrCapacityMax: encodingResult.gainMap.material.hdrCapacityMax, 105 | offsetHdr: encodingResult.gainMap.material.offsetHdr, 106 | offsetSdr: encodingResult.gainMap.material.offsetSdr, 107 | gamma: encodingResult.gainMap.material.gamma 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/encode/find-texture-min-max.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | 3 | import { test } from '../testWithCoverage' 4 | import { findTextureMinMaxInBrowser } from './find-texture-min-max' 5 | 6 | test('finds max values in exr', async ({ page }) => { 7 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 8 | 9 | const script = page.getByTestId('script') 10 | await expect(script).toBeAttached() 11 | 12 | const result = await page.evaluate(findTextureMinMaxInBrowser, { file: 'files/memorial.exr' }) 13 | 14 | expect(JSON.stringify(result)).toMatchSnapshot() 15 | }) 16 | 17 | test('finds min values in exr', async ({ page }) => { 18 | await page.goto('/tests/testbed.html', { waitUntil: 'networkidle' }) 19 | 20 | const script = page.getByTestId('script') 21 | await expect(script).toBeAttached() 22 | 23 | const result = await page.evaluate(findTextureMinMaxInBrowser, { file: 'files/gray.exr', mode: 'min' as const }) 24 | 25 | expect(JSON.stringify(result)).toMatchSnapshot() 26 | }) 27 | -------------------------------------------------------------------------------- /tests/encode/find-texture-min-max.ts: -------------------------------------------------------------------------------- 1 | import * as encode from '@monogrid/gainmap-js/encode' 2 | import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js' 3 | /** 4 | * 5 | * @param file 6 | * @returns 7 | */ 8 | export const findTextureMinMaxInBrowser = async (args: { file: string, mode?: 'min' | 'max' }) => { 9 | // load an HDR file 10 | const image = await new EXRLoader().loadAsync(args.file) 11 | 12 | // find RAW RGB Max value of a texture 13 | return encode.findTextureMinMax(image, args.mode) 14 | } 15 | -------------------------------------------------------------------------------- /tests/examples/integrated-example.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from '@playwright/test' 2 | import { readFile } from 'fs/promises' 3 | import { join } from 'path' 4 | 5 | import { test } from '../testWithCoverage' 6 | 7 | // @ts-expect-error tsc throws "Named capturing groups are only available when targeting 'ES2018' or later." here 8 | const threeMatch = /https:\/\/unpkg\.com\/three(?@[0-9.]+)?\/(?.*)/ 9 | // @ts-expect-error tsc throws "Named capturing groups are only available when targeting 'ES2018' or later." here 10 | const gainmapJSMatch = /https:\/\/unpkg\.com\/@monogrid\/gainmap-js(?@[0-9.]+)?\/(?.*)/ 11 | 12 | test('renders the example correctly', async ({ page, context }) => { 13 | await context.route(threeMatch, async (route, request) => { 14 | const match = threeMatch.exec(request.url()) 15 | const path = match?.groups?.path 16 | if (path) { 17 | const body = await readFile(join('./node_modules/three/', path)) 18 | return route.fulfill({ body, status: 200, contentType: 'application/javascript' }) 19 | } 20 | return route.continue() 21 | }) 22 | await context.route(gainmapJSMatch, async (route, request) => { 23 | const match = gainmapJSMatch.exec(request.url()) 24 | const path = match?.groups?.path 25 | if (path) { 26 | const body = await readFile(join('.', path)) 27 | return route.fulfill({ body, status: 200, contentType: 'application/javascript' }) 28 | } 29 | return route.continue() 30 | }) 31 | 32 | await page.goto('/examples/integrated/', { waitUntil: 'networkidle' }) 33 | 34 | await expect(page.locator('canvas').first()).toBeAttached() 35 | 36 | await expect(page).toHaveScreenshot('initial.png') 37 | 38 | await page.mouse.wheel(0, -9000) 39 | 40 | await expect(page).toHaveScreenshot('zoomed-in.png') 41 | 42 | await page.mouse.wheel(0, 9000) 43 | 44 | await page.mouse.move(250, 250, { steps: 20 }) 45 | await page.mouse.down({ button: 'left' }) 46 | await page.mouse.move(250, 500, { steps: 20 }) 47 | await page.mouse.up({ button: 'left' }) 48 | 49 | await expect(page).toHaveScreenshot('zoomed-out-from-above.png') 50 | }) 51 | -------------------------------------------------------------------------------- /tests/files/01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/01.jpg -------------------------------------------------------------------------------- /tests/files/02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/02.jpg -------------------------------------------------------------------------------- /tests/files/03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/03.jpg -------------------------------------------------------------------------------- /tests/files/04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/04.jpg -------------------------------------------------------------------------------- /tests/files/05.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/05.jpg -------------------------------------------------------------------------------- /tests/files/06.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/06.jpg -------------------------------------------------------------------------------- /tests/files/07.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/07.jpg -------------------------------------------------------------------------------- /tests/files/08.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/08.jpg -------------------------------------------------------------------------------- /tests/files/09.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/09.jpg -------------------------------------------------------------------------------- /tests/files/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/10.jpg -------------------------------------------------------------------------------- /tests/files/abandoned_bakery_16k.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/abandoned_bakery_16k.jpg -------------------------------------------------------------------------------- /tests/files/chcaus2-bloom.exr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/chcaus2-bloom.exr -------------------------------------------------------------------------------- /tests/files/chcaus2-bloom.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/chcaus2-bloom.hdr -------------------------------------------------------------------------------- /tests/files/gray.exr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/gray.exr -------------------------------------------------------------------------------- /tests/files/grey.exr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/grey.exr -------------------------------------------------------------------------------- /tests/files/invalid_image.png: -------------------------------------------------------------------------------- 1 | invalid 2 | -------------------------------------------------------------------------------- /tests/files/memorial.exr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/memorial.exr -------------------------------------------------------------------------------- /tests/files/memorial.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/memorial.hdr -------------------------------------------------------------------------------- /tests/files/memorial.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/memorial.jpg -------------------------------------------------------------------------------- /tests/files/odd-sized.exr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/odd-sized.exr -------------------------------------------------------------------------------- /tests/files/pisa-4k.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/pisa-4k.jpg -------------------------------------------------------------------------------- /tests/files/plain-jpeg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/plain-jpeg.jpg -------------------------------------------------------------------------------- /tests/files/spruit_sunrise_1k.hdr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/spruit_sunrise_1k.hdr -------------------------------------------------------------------------------- /tests/files/spruit_sunrise_4k-gainmap.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/spruit_sunrise_4k-gainmap.webp -------------------------------------------------------------------------------- /tests/files/spruit_sunrise_4k.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/spruit_sunrise_4k.jpg -------------------------------------------------------------------------------- /tests/files/spruit_sunrise_4k.json: -------------------------------------------------------------------------------- 1 | { 2 | "gainMapMax": [ 3 | 15.99929538702341, 4 | 15.99929538702341, 5 | 15.99929538702341 6 | ], 7 | "gainMapMin": [ 8 | 0, 9 | 0, 10 | 0 11 | ], 12 | "gamma": [ 13 | 1, 14 | 1, 15 | 1 16 | ], 17 | "hdrCapacityMax": 15.99929538702341, 18 | "hdrCapacityMin": 0, 19 | "offsetHdr": [ 20 | 0.015625, 21 | 0.015625, 22 | 0.015625 23 | ], 24 | "offsetSdr": [ 25 | 0.015625, 26 | 0.015625, 27 | 0.015625 28 | ] 29 | } -------------------------------------------------------------------------------- /tests/files/spruit_sunrise_4k.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MONOGRID/gainmap-js/fe719b9781d867b5f9487c0a13ba8eea6fac0335/tests/files/spruit_sunrise_4k.webp -------------------------------------------------------------------------------- /tests/global.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | // eslint-disable-next-line unused-imports/no-unused-vars 3 | interface Window { 4 | collectIstanbulCoverage: (coverageJSON: string) => void 5 | __coverage__: Record 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/testWithCoverage.ts: -------------------------------------------------------------------------------- 1 | import { test as baseTest } from '@playwright/test' 2 | import { randomBytes } from 'crypto' 3 | import { writeFileSync } from 'fs' 4 | import { mkdir } from 'fs/promises' 5 | import { join } from 'path' 6 | 7 | const istanbulCLIOutput = join(process.cwd(), '.nyc_output') 8 | 9 | export function generateUUID (): string { 10 | return randomBytes(16).toString('hex') 11 | } 12 | 13 | export const test = baseTest.extend({ 14 | context: async ({ context }, use) => { 15 | await context.addInitScript(() => { 16 | /* Deterministic math random */ 17 | let seed = Math.PI / 4 18 | window.Math.random = function () { 19 | // const x = window.location.href.split('').map(c => c.charCodeAt(0)).reduce((v,i) => v + i, 0) 20 | const x = Math.sin(seed++) * 10000 21 | return x - Math.floor(x) 22 | } 23 | 24 | /* Collect coverage */ 25 | window.addEventListener('beforeunload', () => 26 | window.collectIstanbulCoverage(JSON.stringify(window.__coverage__)) 27 | ) 28 | 29 | // @ts-expect-error injecting variables 30 | window.TESTING = true 31 | 32 | /* Deterministic Font Rendering across platforms */ 33 | let styleInjected = false 34 | document.addEventListener('readystatechange', (e) => { 35 | if (!styleInjected) { 36 | styleInjected = true 37 | const style = document.createElement('style') 38 | style.innerHTML = ` 39 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans&display=swap'); 40 | 41 | #info, .lbl, .lil-gui, canvas[width="80"] { 42 | display: none !important; 43 | } 44 | 45 | body, html { 46 | margin: 0; 47 | padding: 0; 48 | } 49 | 50 | *, *:before, *:after { 51 | font-family: 'Noto Sans' !important; 52 | font-size: 16px !important; 53 | font-weight: 400 !important; 54 | font-kerning: none !important; 55 | font-style: normal !important; 56 | -webkit-font-smoothing: antialiased !important; 57 | text-rendering: geometricprecision !important; 58 | }` 59 | document.head.append(style) 60 | } 61 | }) 62 | }) 63 | await mkdir(istanbulCLIOutput, { recursive: true }) 64 | await context.exposeFunction('collectIstanbulCoverage', (coverageJSON: string) => { 65 | if (coverageJSON) { writeFileSync(join(istanbulCLIOutput, `playwright_coverage_${generateUUID()}.json`), coverageJSON) } 66 | }) 67 | await use(context) 68 | for (const page of context.pages()) { 69 | await page.evaluate(() => window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))) 70 | } 71 | } 72 | }) 73 | 74 | export const expect = test.expect 75 | -------------------------------------------------------------------------------- /tests/testbed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 20 | 21 | 22 | 23 | 40 | 43 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "strictNullChecks": true, 5 | "strictFunctionTypes": true, 6 | "noErrorTruncation": true, 7 | "noImplicitAny": true, 8 | "noImplicitThis": true, 9 | "allowSyntheticDefaultImports": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "noEmit": true, 13 | "lib": ["DOM", "ES2019"], 14 | "paths": { 15 | "three": [ "../node_modules/three/build/three.module" ], 16 | "@monogrid/gainmap-js": [ "../dist/decode" ], 17 | "@monogrid/gainmap-js/encode": [ "../dist/encode" ], 18 | "@monogrid/gainmap-js/libultrahdr": [ "../dist/libultrahdr" ], 19 | "@monogrid/gainmap-js/worker": [ "../dist/worker" ], 20 | "@monogrid/gainmap-js/worker-interface": [ "../dist/worker-interface" ] 21 | }, 22 | "types": ["@playwright/test", "@types/node"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "node16", 5 | "strict": true, 6 | "strictNullChecks": true, 7 | "strictFunctionTypes": true, 8 | "noErrorTruncation": true, 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "allowJs": true, 12 | "checkJs": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "noEmit": true, 16 | "skipLibCheck": true 17 | }, 18 | "exclude": ["dist", "src", "libultrahdr-wasm", "tests", "examples", "coverage", "playwright-report", ".nyc_output"] 19 | } 20 | -------------------------------------------------------------------------------- /typedoc.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('typedoc').TypeDocOptions} */ 2 | module.exports = { 3 | entryPoints: ['./src/core/index.ts', './src/decode.ts', './src/encode.ts', './src/libultrahdr.ts', './src/worker-interface.ts'], 4 | tsconfig: './src/tsconfig.json', 5 | // entryPointStrategy: 'Merge', 6 | out: 'wiki', 7 | plugin: ['typedoc-plugin-markdown', 'typedoc-github-wiki-theme'], 8 | excludeExternals: true, 9 | excludePrivate: true, 10 | excludeInternal: true, 11 | validation: { 12 | notExported: true, 13 | invalidLink: true, 14 | notDocumented: false 15 | }, 16 | treatWarningsAsErrors: true 17 | } 18 | --------------------------------------------------------------------------------