From 4066adebf8a5de6268f7d54ef9725621eb6790c3 Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:04:29 +0200 Subject: [PATCH 1/9] Add CI workflow that builds the layer and tests it in the Lambda runtime image --- .github/workflows/test-layer.yml | 92 ++++++++++++++++++++++++++++++++ README.md | 3 ++ test/layer.test.js | 86 +++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 .github/workflows/test-layer.yml create mode 100644 test/layer.test.js diff --git a/.github/workflows/test-layer.yml b/.github/workflows/test-layer.yml new file mode 100644 index 0000000..bed73cb --- /dev/null +++ b/.github/workflows/test-layer.yml @@ -0,0 +1,92 @@ +name: Test layer + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + SAM_CLI_TELEMETRY: 0 + LAMBDA_IMAGE: public.ecr.aws/lambda/nodejs:24 + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run lint + + build-and-test: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v5 + + - uses: aws-actions/setup-sam@v2 + with: + use-installer: true + + - name: Read pinned versions + run: | + echo "EXPECTED_SHARP_VERSION=$(sed -n 's/^SHARP_VERSION=//p' layer/Makefile)" >> "$GITHUB_ENV" + echo "EXPECTED_VIPS_VERSION=$(sed -n 's/^VIPS_VERSION=//p' layer/Makefile)" >> "$GITHUB_ENV" + + # Same build image as the documented CodeBuild/SAM path. GitHub runners have 4 vCPUs / 16 GB, + # so the CodeBuild-oriented parallelism limits can be raised. + - name: Build layer + run: sam build --use-container --container-env-var BUILD_JOBS=4 --container-env-var LIBAOM_BUILD_JOBS=2 + + - name: Upload layer + uses: actions/upload-artifact@v4 + with: + name: sharp-heic-layer-x86_64 + path: .aws-sam/build/SharpHEICLayer + retention-days: 7 + + # The Lambda runtime image has no build toolchain or -devel packages, so any shared library + # missing from the layer's lib/ directory fails here. + - name: Test layer in Lambda runtime image + run: | + docker run --rm \ + --entrypoint /var/lang/bin/node \ + -v "$PWD/.aws-sam/build/SharpHEICLayer:/opt:ro" \ + -v "$PWD:/work:ro" \ + -e NODE_PATH=/opt/nodejs/node_modules \ + -e LD_LIBRARY_PATH=/var/lang/lib:/lib64:/usr/lib64:/var/runtime:/var/runtime/lib:/var/task:/var/task/lib:/opt/lib \ + -e EXPECTED_SHARP_VERSION -e EXPECTED_VIPS_VERSION \ + "$LAMBDA_IMAGE" --test /work/test/layer.test.js + + - name: Invoke example function via Lambda Runtime Interface Emulator + run: | + docker run -d --name lambda -p 9000:8080 \ + -v "$PWD/.aws-sam/build/SharpHEICLayer:/opt:ro" \ + -v "$PWD/examples/src:/var/task:ro" \ + "$LAMBDA_IMAGE" index.handler + curl --silent --show-error --fail --retry 10 --retry-connrefused --retry-delay 1 \ + -d '{}' http://localhost:9000/2015-03-31/functions/function/invocations > response.json + node -e ' + const res = JSON.parse(require("fs").readFileSync("response.json", "utf8")); + if (res.statusCode !== 200) throw new Error("unexpected response: " + JSON.stringify(res).slice(0, 500)); + if (res.headers["content-type"] !== "image/webp") throw new Error("unexpected content-type " + res.headers["content-type"]); + const body = Buffer.from(res.body, "base64"); + if (body.toString("ascii", 0, 4) !== "RIFF" || body.toString("ascii", 8, 12) !== "WEBP") throw new Error("body is not a WebP image"); + console.log("Lambda returned a WebP image of " + body.length + " bytes"); + ' + + - name: Lambda container logs + if: always() + run: docker logs lambda 2>&1 || true diff --git a/README.md b/README.md index 0b1a5ec..ce6619b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ The environment variables are used to create a `samconfig.toml` file that config ### Note regarding build process Previously, some custom docker images were needed to build this layer. AWS now publishes managed SAM build images for current Lambda runtimes, including `public.ecr.aws/sam/build-nodejs24.x`. +### Testing +The [Test layer](.github/workflows/test-layer.yml) workflow builds the layer with `sam build --use-container` and mounts the result at `/opt` in the `public.ecr.aws/lambda/nodejs:24` runtime image. There it runs [test/layer.test.js](test/layer.test.js), which checks shared library resolution, the libvips and sharp versions, HEIC decoding, WebP resizing, and HEIF encoding with both HEVC and AV1. It also invokes the [example function](examples/src/index.js) through the Lambda Runtime Interface Emulator. + ## Background This repo exists as it is rather painful to compile all libraries required to get sharp to work with HEIC/HEIF files in an AWS Lambda environment. The sharp repository has several [issues](https://github.com/lovell/sharp/issues) related to this. diff --git a/test/layer.test.js b/test/layer.test.js new file mode 100644 index 0000000..cf4d994 --- /dev/null +++ b/test/layer.test.js @@ -0,0 +1,86 @@ +// Smoke test for the built layer. Runs inside the public.ecr.aws/lambda/nodejs image +// with the layer mounted at /opt, see .github/workflows/test-layer.yml. +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const LAYER_NODE_MODULES = '/opt/nodejs/node_modules'; +const TEST_IMAGE = path.resolve(__dirname, '../examples/src/test-input.heic'); +const TEST_IMAGE_WIDTH = 4032; +const TEST_IMAGE_HEIGHT = 3024; + +const findSharpBinary = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = findSharpBinary(full); + if (found) return found; + } else if (/(^|\/)Release\/sharp[^/]*\.node$/.test(full)) { + return full; + } + } + return null; +}; + +test('compiled sharp addon resolves all shared libraries', () => { + const binary = findSharpBinary(path.join(LAYER_NODE_MODULES, 'sharp')); + assert.ok(binary, 'compiled sharp addon not found in layer'); + + // ldd is not guaranteed to exist in the Lambda image, so ask the dynamic loader directly. + const loader = ['/lib64/ld-linux-x86-64.so.2', '/lib/ld-linux-aarch64.so.1'].find((p) => fs.existsSync(p)); + assert.ok(loader, 'dynamic loader not found'); + const output = execFileSync(loader, ['--list', binary], { encoding: 'utf8' }); + console.log(output); + assert.doesNotMatch(output, /not found/); +}); + +test('sharp uses the libvips built for the layer', () => { + const sharp = require('sharp'); + assert.equal(require('sharp/package.json').version, process.env.EXPECTED_SHARP_VERSION); + assert.equal(sharp.versions.vips, process.env.EXPECTED_VIPS_VERSION); + assert.ok(!fs.existsSync(path.join(LAYER_NODE_MODULES, '@img')), 'prebuilt @img/sharp-* packages must not be bundled'); +}); + +test('sharp reports HEIF and WebP support', () => { + const { format } = require('sharp'); + for (const name of ['heif', 'webp', 'jpeg', 'png']) { + assert.ok(format[name].input.buffer, `${name} input`); + assert.ok(format[name].output.buffer, `${name} output`); + } +}); + +test('decodes HEIC input', async () => { + const sharp = require('sharp'); + const metadata = await sharp(TEST_IMAGE).metadata(); + assert.equal(metadata.format, 'heif'); + assert.equal(metadata.compression, 'hevc'); + assert.equal(metadata.width, TEST_IMAGE_WIDTH); + assert.equal(metadata.height, TEST_IMAGE_HEIGHT); +}); + +test('resizes HEIC to WebP', async () => { + const sharp = require('sharp'); + const { info } = await sharp(TEST_IMAGE).resize({ width: 200 }).webp().toBuffer({ resolveWithObject: true }); + assert.equal(info.format, 'webp'); + assert.equal(info.width, 200); + assert.equal(info.height, Math.round(200 * TEST_IMAGE_HEIGHT / TEST_IMAGE_WIDTH)); +}); + +for (const { compression, encoder } of [ + { compression: 'hevc', encoder: 'x265' }, + { compression: 'av1', encoder: 'libaom' }, +]) { + test(`encodes and decodes HEIF with ${compression} (${encoder})`, async () => { + const sharp = require('sharp'); + const encoded = await sharp(TEST_IMAGE).resize({ width: 320 }).heif({ compression, quality: 50 }).toBuffer(); + const metadata = await sharp(encoded).metadata(); + assert.equal(metadata.format, 'heif'); + assert.equal(metadata.compression, compression); + assert.equal(metadata.width, 320); + + const { info } = await sharp(encoded).png().toBuffer({ resolveWithObject: true }); + assert.equal(info.width, 320); + }); +} From f69b12be3dea746ccd74021c5c97cf8500fa3a3c Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:06:45 +0200 Subject: [PATCH 2/9] Bump GitHub Actions to latest major versions --- .github/workflows/test-layer.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-layer.yml b/.github/workflows/test-layer.yml index bed73cb..8b5f0d4 100644 --- a/.github/workflows/test-layer.yml +++ b/.github/workflows/test-layer.yml @@ -22,8 +22,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 24 cache: npm @@ -34,9 +34,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 120 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - - uses: aws-actions/setup-sam@v2 + - uses: aws-actions/setup-sam@v3 with: use-installer: true @@ -51,7 +51,7 @@ jobs: run: sam build --use-container --container-env-var BUILD_JOBS=4 --container-env-var LIBAOM_BUILD_JOBS=2 - name: Upload layer - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: sharp-heic-layer-x86_64 path: .aws-sam/build/SharpHEICLayer From 3790c93238ad070becc2cd94a83ad0ef30632e4e Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:11:41 +0200 Subject: [PATCH 3/9] Remove eslint in favor of node --check eslint was the only dependency of the root package and the source of all open Dependabot alerts (transitive deps in package-lock.json). node --check gives the same syntax check without installing anything. --- .github/workflows/test-layer.yml | 6 +- buildspec.yml | 4 +- eslint.config.js | 8 - package-lock.json | 1076 ------------------------------ package.json | 7 +- 5 files changed, 5 insertions(+), 1096 deletions(-) delete mode 100644 eslint.config.js diff --git a/.github/workflows/test-layer.yml b/.github/workflows/test-layer.yml index 8b5f0d4..a89d959 100644 --- a/.github/workflows/test-layer.yml +++ b/.github/workflows/test-layer.yml @@ -18,7 +18,7 @@ env: LAMBDA_IMAGE: public.ecr.aws/lambda/nodejs:24 jobs: - lint: + syntax-check: runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -26,9 +26,7 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 24 - cache: npm - - run: npm ci - - run: npm run lint + - run: npm test build-and-test: runs-on: ubuntu-latest diff --git a/buildspec.yml b/buildspec.yml index a0a3c96..0105cc1 100644 --- a/buildspec.yml +++ b/buildspec.yml @@ -27,8 +27,8 @@ phases: - NODE_ENV=development npm ci - 'echo "==> Pre-build phase: audit dependencies"' - npm audit --audit-level=moderate - - 'echo "==> Pre-build phase: lint"' - - npm run lint + - 'echo "==> Pre-build phase: syntax check"' + - npm test build: commands: - 'echo "==> Build phase: sam build"' diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index ef6d27a..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = [ - { - languageOptions: { - ecmaVersion: 2022, - sourceType: 'script', - }, - }, -]; diff --git a/package-lock.json b/package-lock.json index aaf103e..9ba2a60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,1085 +8,9 @@ "name": "sharp-heic-lambda-layer", "version": "4.2.0", "license": "MIT", - "devDependencies": { - "eslint": "^9.38.0" - }, "engines": { "node": ">=22.11" } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", - "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.16.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.16.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", - "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.1", - "@eslint/core": "^0.16.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.38.0", - "@eslint/plugin-kit": "^0.4.0", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", - "dev": true, - "license": "ISC" - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/package.json b/package.json index a66ba0b..2be2b32 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,7 @@ "description": "Lambda Layer providing sharp with HEIC support", "main": "index.js", "scripts": { - "test": "npm run lint", - "lint": "npx eslint .", - "lint:fix": "npx eslint --fix .", + "test": "node --check deployment/createSAMConfiguration.js && node --check examples/src/index.js && node --check test/layer.test.js", "build": "sam build", "prepackage": "node deployment/createSAMConfiguration.js > samconfig.toml", "package": "sam package", @@ -26,8 +24,5 @@ "homepage": "https://github.com/zoellner/sharp-heic-lambda-layer#readme", "engines": { "node": ">=22.11" - }, - "devDependencies": { - "eslint": "^9.38.0" } } From 779efe2544aad94f3e90a4db274b386c3aba55fe Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:20:57 +0200 Subject: [PATCH 4/9] Fix layer test: read sharp version from disk, correct test image dimensions, set x265-compatible tune --- test/layer.test.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/layer.test.js b/test/layer.test.js index cf4d994..28cf494 100644 --- a/test/layer.test.js +++ b/test/layer.test.js @@ -8,8 +8,8 @@ const { test } = require('node:test'); const LAYER_NODE_MODULES = '/opt/nodejs/node_modules'; const TEST_IMAGE = path.resolve(__dirname, '../examples/src/test-input.heic'); -const TEST_IMAGE_WIDTH = 4032; -const TEST_IMAGE_HEIGHT = 3024; +const TEST_IMAGE_WIDTH = 3024; +const TEST_IMAGE_HEIGHT = 4032; const findSharpBinary = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -38,7 +38,8 @@ test('compiled sharp addon resolves all shared libraries', () => { test('sharp uses the libvips built for the layer', () => { const sharp = require('sharp'); - assert.equal(require('sharp/package.json').version, process.env.EXPECTED_SHARP_VERSION); + const { version } = JSON.parse(fs.readFileSync(path.join(LAYER_NODE_MODULES, 'sharp/package.json'), 'utf8')); + assert.equal(version, process.env.EXPECTED_SHARP_VERSION); assert.equal(sharp.versions.vips, process.env.EXPECTED_VIPS_VERSION); assert.ok(!fs.existsSync(path.join(LAYER_NODE_MODULES, '@img')), 'prebuilt @img/sharp-* packages must not be bundled'); }); @@ -68,13 +69,14 @@ test('resizes HEIC to WebP', async () => { assert.equal(info.height, Math.round(200 * TEST_IMAGE_HEIGHT / TEST_IMAGE_WIDTH)); }); -for (const { compression, encoder } of [ - { compression: 'hevc', encoder: 'x265' }, - { compression: 'av1', encoder: 'libaom' }, +for (const { compression, encoder, options } of [ + // sharp defaults to tune 'auto', which libheif's x265 plugin rejects (only psnr, ssim, grain, fastdecode). + { compression: 'hevc', encoder: 'x265', options: { tune: 'ssim' } }, + { compression: 'av1', encoder: 'libaom', options: {} }, ]) { test(`encodes and decodes HEIF with ${compression} (${encoder})`, async () => { const sharp = require('sharp'); - const encoded = await sharp(TEST_IMAGE).resize({ width: 320 }).heif({ compression, quality: 50 }).toBuffer(); + const encoded = await sharp(TEST_IMAGE).resize({ width: 320 }).heif({ compression, quality: 50, ...options }).toBuffer(); const metadata = await sharp(encoded).metadata(); assert.equal(metadata.format, 'heif'); assert.equal(metadata.compression, compression); From 3ec3a766be7097ab36b76b98036d7d04f7f24bb5 Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:22:21 +0200 Subject: [PATCH 5/9] Do not upload the compiled layer as a workflow artifact --- .github/workflows/test-layer.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-layer.yml b/.github/workflows/test-layer.yml index a89d959..ba5ca77 100644 --- a/.github/workflows/test-layer.yml +++ b/.github/workflows/test-layer.yml @@ -28,6 +28,8 @@ jobs: node-version: 24 - run: npm test + # The compiled layer must not be published (codec licensing), so it is never uploaded as an + # artifact or cached; it only exists on the ephemeral runner for the duration of this job. build-and-test: runs-on: ubuntu-latest timeout-minutes: 120 @@ -48,13 +50,6 @@ jobs: - name: Build layer run: sam build --use-container --container-env-var BUILD_JOBS=4 --container-env-var LIBAOM_BUILD_JOBS=2 - - name: Upload layer - uses: actions/upload-artifact@v7 - with: - name: sharp-heic-layer-x86_64 - path: .aws-sam/build/SharpHEICLayer - retention-days: 7 - # The Lambda runtime image has no build toolchain or -devel packages, so any shared library # missing from the layer's lib/ directory fails here. - name: Test layer in Lambda runtime image From 29ce09452f4cc0eb6add5e4e9b7ab8f5f1c5fcdf Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:32:45 +0200 Subject: [PATCH 6/9] Only flag prebuilt @img/sharp-* packages in layer test --- test/layer.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/layer.test.js b/test/layer.test.js index 28cf494..9b50ec3 100644 --- a/test/layer.test.js +++ b/test/layer.test.js @@ -41,7 +41,10 @@ test('sharp uses the libvips built for the layer', () => { const { version } = JSON.parse(fs.readFileSync(path.join(LAYER_NODE_MODULES, 'sharp/package.json'), 'utf8')); assert.equal(version, process.env.EXPECTED_SHARP_VERSION); assert.equal(sharp.versions.vips, process.env.EXPECTED_VIPS_VERSION); - assert.ok(!fs.existsSync(path.join(LAYER_NODE_MODULES, '@img')), 'prebuilt @img/sharp-* packages must not be bundled'); + // @img/colour is a regular JS dependency; only the prebuilt @img/sharp-* binaries must be absent. + const scope = path.join(LAYER_NODE_MODULES, '@img'); + const prebuilt = fs.existsSync(scope) ? fs.readdirSync(scope).filter((name) => name.startsWith('sharp-')) : []; + assert.deepEqual(prebuilt, [], 'prebuilt @img/sharp-* packages must not be bundled'); }); test('sharp reports HEIF and WebP support', () => { From 51a475c8526c0c926861a90d873fdc9a1ca424b8 Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:47:13 +0200 Subject: [PATCH 7/9] Verify decoded pixel colours using a synthetic HEIC fixture --- README.md | 2 +- package.json | 2 +- test/fixtures/make-quadrants-heic.js | 47 +++++++++++++++++++++++++++ test/fixtures/quadrants.heic | Bin 0 -> 865 bytes test/fixtures/quadrants.js | 11 +++++++ test/layer.test.js | 46 +++++++++++++++++++++++--- 6 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/make-quadrants-heic.js create mode 100644 test/fixtures/quadrants.heic create mode 100644 test/fixtures/quadrants.js diff --git a/README.md b/README.md index ce6619b..51e0e86 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ The environment variables are used to create a `samconfig.toml` file that config Previously, some custom docker images were needed to build this layer. AWS now publishes managed SAM build images for current Lambda runtimes, including `public.ecr.aws/sam/build-nodejs24.x`. ### Testing -The [Test layer](.github/workflows/test-layer.yml) workflow builds the layer with `sam build --use-container` and mounts the result at `/opt` in the `public.ecr.aws/lambda/nodejs:24` runtime image. There it runs [test/layer.test.js](test/layer.test.js), which checks shared library resolution, the libvips and sharp versions, HEIC decoding, WebP resizing, and HEIF encoding with both HEVC and AV1. It also invokes the [example function](examples/src/index.js) through the Lambda Runtime Interface Emulator. +The [Test layer](.github/workflows/test-layer.yml) workflow builds the layer with `sam build --use-container` and mounts the result at `/opt` in the `public.ecr.aws/lambda/nodejs:24` runtime image. There it runs [test/layer.test.js](test/layer.test.js), which checks shared library resolution, the libvips and sharp versions, HEIC decoding, WebP resizing, and HEIF encoding with both HEVC and AV1. Pixel colours are verified against [test/fixtures/quadrants.heic](test/fixtures/quadrants.heic), a synthetic image encoded with macOS `sips` (regenerate with `node test/fixtures/make-quadrants-heic.js`). It also invokes the [example function](examples/src/index.js) through the Lambda Runtime Interface Emulator. ## Background This repo exists as it is rather painful to compile all libraries required to get sharp to work with HEIC/HEIF files in an AWS Lambda environment. The sharp repository has several [issues](https://github.com/lovell/sharp/issues) related to this. diff --git a/package.json b/package.json index 2be2b32..20f9ee8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Lambda Layer providing sharp with HEIC support", "main": "index.js", "scripts": { - "test": "node --check deployment/createSAMConfiguration.js && node --check examples/src/index.js && node --check test/layer.test.js", + "test": "node --check deployment/createSAMConfiguration.js && node --check examples/src/index.js && node --check test/layer.test.js && node --check test/fixtures/quadrants.js && node --check test/fixtures/make-quadrants-heic.js", "build": "sam build", "prepackage": "node deployment/createSAMConfiguration.js > samconfig.toml", "package": "sam package", diff --git a/test/fixtures/make-quadrants-heic.js b/test/fixtures/make-quadrants-heic.js new file mode 100644 index 0000000..28b811a --- /dev/null +++ b/test/fixtures/make-quadrants-heic.js @@ -0,0 +1,47 @@ +// Generates test/fixtures/quadrants.heic: a 480x320 image with one solid colour per quadrant. +// Encoded with Apple's HEIF encoder (macOS sips) so the fixture is independent of the libheif/x265 +// build under test. Usage (macOS only): node test/fixtures/make-quadrants-heic.js +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const zlib = require('node:zlib'); + +const { WIDTH, HEIGHT, QUADRANTS } = require('./quadrants.js'); + +const chunk = (type, data) => { + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(zlib.crc32(body)); + return Buffer.concat([length, body, crc]); +}; + +const header = Buffer.alloc(13); +header.writeUInt32BE(WIDTH, 0); +header.writeUInt32BE(HEIGHT, 4); +header.set([8, 2, 0, 0, 0], 8); // 8-bit RGB, no interlace + +const rows = []; +for (let y = 0; y < HEIGHT; y++) { + const row = Buffer.alloc(1 + WIDTH * 3); // leading 0 = no filter + for (let x = 0; x < WIDTH; x++) { + const { rgb } = QUADRANTS.find((q) => q.x === (x < WIDTH / 2 ? 0 : 1) && q.y === (y < HEIGHT / 2 ? 0 : 1)); + row.set(rgb, 1 + x * 3); + } + rows.push(row); +} + +const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', zlib.deflateSync(Buffer.concat(rows))), + chunk('IEND', Buffer.alloc(0)), +]); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'quadrants-')); +const pngPath = path.join(tmp, 'quadrants.png'); +fs.writeFileSync(pngPath, png); +execFileSync('sips', ['-s', 'format', 'heic', '-s', 'formatOptions', 'best', pngPath, '--out', path.join(__dirname, 'quadrants.heic')], { stdio: 'inherit' }); +fs.rmSync(tmp, { recursive: true }); diff --git a/test/fixtures/quadrants.heic b/test/fixtures/quadrants.heic new file mode 100644 index 0000000000000000000000000000000000000000..9577b2740121aa31bd47555d1b86c9ce4a8b57fd GIT binary patch literal 865 zcmZQzU{FabsVvAy%}izhg51nBL*GmfSKrKlqTI~HG>AB3OKxgOB1j$x6f;tCiXcn| zhJwuG5*QmstE6P+r2#oIDMhIu8Uz@Dn5VQTM*+&_E66O#1+pPB%9#)y5S}QIlgb2@ z%P31W1Tmjv78Df#>BE@?$@xH9I5|J3C@(puf`JJL*&2XCJjppZnGB~H9Dr<*%;JJn zAPoeJ4}jPK$l%N@$_G1Epdhm%6DYvI%)!9{r1F85I5V<`mc~Sv#K!&04l{Rn~$IC5|Qq5bnbH(CRc+{gDYztPG|9|2Sm9b`heSLlXv$M0!a&A^x%N>yU67nK{p3Js= z>sP<}b)Z3kgNdO*f&DLQ=8U-xyffxDFd8v%10#x=S%u+o128E{u!}G-C|PJI007am BAMpSH literal 0 HcmV?d00001 diff --git a/test/fixtures/quadrants.js b/test/fixtures/quadrants.js new file mode 100644 index 0000000..7ad47a5 --- /dev/null +++ b/test/fixtures/quadrants.js @@ -0,0 +1,11 @@ +// Layout of test/fixtures/quadrants.heic, shared by the generator and the layer test. +module.exports = { + WIDTH: 480, + HEIGHT: 320, + QUADRANTS: [ + { name: 'top-left', x: 0, y: 0, rgb: [220, 40, 40] }, + { name: 'top-right', x: 1, y: 0, rgb: [40, 200, 60] }, + { name: 'bottom-left', x: 0, y: 1, rgb: [40, 60, 220] }, + { name: 'bottom-right', x: 1, y: 1, rgb: [230, 210, 40] }, + ], +}; diff --git a/test/layer.test.js b/test/layer.test.js index 9b50ec3..b24b850 100644 --- a/test/layer.test.js +++ b/test/layer.test.js @@ -11,6 +11,24 @@ const TEST_IMAGE = path.resolve(__dirname, '../examples/src/test-input.heic'); const TEST_IMAGE_WIDTH = 3024; const TEST_IMAGE_HEIGHT = 4032; +// Synthetic HEIC with one solid colour per quadrant, see test/fixtures/make-quadrants-heic.js. +const QUADRANTS_IMAGE = path.resolve(__dirname, 'fixtures/quadrants.heic'); +const QUADRANTS_LAYOUT = require('./fixtures/quadrants.js'); + +// Samples the centre of each quadrant, which also catches flipped or rotated output. +const assertQuadrantColours = async (input, tolerance) => { + const sharp = require('sharp'); + const { data, info } = await sharp(input).removeAlpha().raw().toBuffer({ resolveWithObject: true }); + for (const { name, x, y, rgb } of QUADRANTS_LAYOUT.QUADRANTS) { + const cx = Math.floor((x + 0.5) * info.width / 2); + const cy = Math.floor((y + 0.5) * info.height / 2); + const i = (cy * info.width + cx) * info.channels; + const actual = [data[i], data[i + 1], data[i + 2]]; + const ok = actual.every((value, c) => Math.abs(value - rgb[c]) <= tolerance); + assert.ok(ok, `${name}: expected ${rgb} ±${tolerance}, got ${actual}`); + } +}; + const findSharpBinary = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); @@ -64,6 +82,25 @@ test('decodes HEIC input', async () => { assert.equal(metadata.height, TEST_IMAGE_HEIGHT); }); +test('decodes synthetic HEIC with correct dimensions and colours', async () => { + const sharp = require('sharp'); + const metadata = await sharp(QUADRANTS_IMAGE).metadata(); + assert.equal(metadata.format, 'heif'); + assert.equal(metadata.compression, 'hevc'); + assert.equal(metadata.width, QUADRANTS_LAYOUT.WIDTH); + assert.equal(metadata.height, QUADRANTS_LAYOUT.HEIGHT); + await assertQuadrantColours(QUADRANTS_IMAGE, 8); +}); + +test('resizes synthetic HEIC to WebP with correct colours', async () => { + const sharp = require('sharp'); + const { data, info } = await sharp(QUADRANTS_IMAGE).resize({ width: 240 }).webp().toBuffer({ resolveWithObject: true }); + assert.equal(info.format, 'webp'); + assert.equal(info.width, 240); + assert.equal(info.height, 160); + await assertQuadrantColours(data, 12); +}); + test('resizes HEIC to WebP', async () => { const sharp = require('sharp'); const { info } = await sharp(TEST_IMAGE).resize({ width: 200 }).webp().toBuffer({ resolveWithObject: true }); @@ -79,13 +116,12 @@ for (const { compression, encoder, options } of [ ]) { test(`encodes and decodes HEIF with ${compression} (${encoder})`, async () => { const sharp = require('sharp'); - const encoded = await sharp(TEST_IMAGE).resize({ width: 320 }).heif({ compression, quality: 50, ...options }).toBuffer(); + const encoded = await sharp(QUADRANTS_IMAGE).heif({ compression, quality: 80, ...options }).toBuffer(); const metadata = await sharp(encoded).metadata(); assert.equal(metadata.format, 'heif'); assert.equal(metadata.compression, compression); - assert.equal(metadata.width, 320); - - const { info } = await sharp(encoded).png().toBuffer({ resolveWithObject: true }); - assert.equal(info.width, 320); + assert.equal(metadata.width, QUADRANTS_LAYOUT.WIDTH); + assert.equal(metadata.height, QUADRANTS_LAYOUT.HEIGHT); + await assertQuadrantColours(encoded, 12); }); } From 7df49e4df8a9d45db96bff6071984bbe7e5fb9de Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Thu, 8 Oct 2026 23:55:57 +0200 Subject: [PATCH 8/9] Link sharp issue for the x265 tune workaround --- test/layer.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/layer.test.js b/test/layer.test.js index b24b850..c4f2ea5 100644 --- a/test/layer.test.js +++ b/test/layer.test.js @@ -111,6 +111,7 @@ test('resizes HEIC to WebP', async () => { for (const { compression, encoder, options } of [ // sharp defaults to tune 'auto', which libheif's x265 plugin rejects (only psnr, ssim, grain, fastdecode). + // https://github.com/lovell/sharp/issues/4621 { compression: 'hevc', encoder: 'x265', options: { tune: 'ssim' } }, { compression: 'av1', encoder: 'libaom', options: {} }, ]) { From cb51689591a602543843a96ff2a6de2ab118e061 Mon Sep 17 00:00:00 2001 From: Andreas Zoellner Date: Fri, 9 Oct 2026 09:56:24 +0200 Subject: [PATCH 9/9] Skip the layer build when no layer-relevant files changed; document HEVC tune workaround --- .github/workflows/test-layer.yml | 28 ++++++++++++++++++++++++++++ README.md | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-layer.yml b/.github/workflows/test-layer.yml index ba5ca77..6e517fc 100644 --- a/.github/workflows/test-layer.yml +++ b/.github/workflows/test-layer.yml @@ -28,9 +28,37 @@ jobs: node-version: 24 - run: npm test + # Decides whether the layer needs to be rebuilt. The workflow itself always runs because its jobs + # are required checks; a job skipped via `if` counts as passing, a workflow that never starts does not. + changes: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + layer: ${{ steps.filter.outputs.layer }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: filter + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + run: | + if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then + echo "layer=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + git diff --name-only "$BASE_SHA" "$GITHUB_SHA" | tee changed-files.txt + if grep -qE '^(layer/|test/|examples/src/|template\.yaml$|\.github/workflows/test-layer\.yml$)' changed-files.txt; then + echo "layer=true" >> "$GITHUB_OUTPUT" + else + echo "layer=false" >> "$GITHUB_OUTPUT" + fi + # The compiled layer must not be published (codec licensing), so it is never uploaded as an # artifact or cached; it only exists on the ephemeral runner for the duration of this job. build-and-test: + needs: changes + if: needs.changes.outputs.layer == 'true' runs-on: ubuntu-latest timeout-minutes: 120 steps: diff --git a/README.md b/README.md index 51e0e86..9a81ac6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ sam deploy --guided ### Lambda Layer - Add the lambda layer with ARN `arn:aws:lambda:us-east-1:${AWS:AccountId}:layer:sharp-heic:${LAYER_VERSION}` to any lambda function (replace `${LAYER_VERSION}` with the appropriate version and `${AWS:AccountId}` if you're not using a layer from the same account as the function). You can also import the layer ARN using `!ImportValue SharpHEICLayerArn`. - Remove sharp from the dependencies in the function code (it will otherwise conflict with the one provided through the layer) +- When encoding HEIC (`heif({ compression: 'hevc' })`), pass `tune: 'ssim'` or `tune: 'psnr'`. sharp's default `tune: 'auto'` is rejected by the x265 encoder with `heif: Invalid parameter value (5.2006)`, see [lovell/sharp#4621](https://github.com/lovell/sharp/issues/4621). AVIF (`compression: 'av1'`) is not affected. - See [example template](examples/sam-template.yaml) for a complete sample template. ### Environment Variables for build @@ -59,7 +60,7 @@ The environment variables are used to create a `samconfig.toml` file that config Previously, some custom docker images were needed to build this layer. AWS now publishes managed SAM build images for current Lambda runtimes, including `public.ecr.aws/sam/build-nodejs24.x`. ### Testing -The [Test layer](.github/workflows/test-layer.yml) workflow builds the layer with `sam build --use-container` and mounts the result at `/opt` in the `public.ecr.aws/lambda/nodejs:24` runtime image. There it runs [test/layer.test.js](test/layer.test.js), which checks shared library resolution, the libvips and sharp versions, HEIC decoding, WebP resizing, and HEIF encoding with both HEVC and AV1. Pixel colours are verified against [test/fixtures/quadrants.heic](test/fixtures/quadrants.heic), a synthetic image encoded with macOS `sips` (regenerate with `node test/fixtures/make-quadrants-heic.js`). It also invokes the [example function](examples/src/index.js) through the Lambda Runtime Interface Emulator. +The [Test layer](.github/workflows/test-layer.yml) workflow builds the layer with `sam build --use-container` and mounts the result at `/opt` in the `public.ecr.aws/lambda/nodejs:24` runtime image. There it runs [test/layer.test.js](test/layer.test.js), which checks shared library resolution, the libvips and sharp versions, HEIC decoding, WebP resizing, and HEIF encoding with both HEVC and AV1. Pixel colours are verified against [test/fixtures/quadrants.heic](test/fixtures/quadrants.heic), a synthetic image encoded with macOS `sips` (regenerate with `node test/fixtures/make-quadrants-heic.js`). The layer is only rebuilt when `layer/`, `test/`, `examples/src/`, `template.yaml` or the workflow itself change; for other changes (e.g. documentation) the build job is skipped. It also invokes the [example function](examples/src/index.js) through the Lambda Runtime Interface Emulator. ## Background This repo exists as it is rather painful to compile all libraries required to get sharp to work with HEIC/HEIF files in an AWS Lambda environment. The sharp repository has several [issues](https://github.com/lovell/sharp/issues) related to this.