diff --git a/packages/core/src/utils/image-bitmap.ts b/packages/core/src/utils/image-bitmap.ts index 60f9fc85..c7055e78 100644 --- a/packages/core/src/utils/image-bitmap.ts +++ b/packages/core/src/utils/image-bitmap.ts @@ -147,7 +147,7 @@ export async function attemptExifRotate( EXIFParser.create(buffer).parse(); exifRotate(image); // EXIF data - } catch (error) { - console.error(error); + } catch { + // do nothing } } diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs index eb153e67..d1808e98 100644 --- a/packages/docs/astro.config.mjs +++ b/packages/docs/astro.config.mjs @@ -3,6 +3,7 @@ import starlight from "@astrojs/starlight"; import starlightTypeDoc, { typeDocSidebarGroup } from "starlight-typedoc"; import react from "@astrojs/react"; import path from "path"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; export default defineConfig({ site: "https://jimp-dev.github.io", @@ -24,6 +25,7 @@ export default defineConfig({ { label: "Writing Plugins", link: "/guides/writing-plugins/" }, { label: "Custom Jimp", link: "/guides/custom-jimp/" }, { label: "Migrate to v1", link: "/guides/migrate-to-v1/" }, + { label: "WEBP/WASM", link: "/guides/webp/" }, ], }, typeDocSidebarGroup, @@ -54,4 +56,7 @@ export default defineConfig({ ], }), ], + vite: { + plugins: [nodePolyfills({ include: ["buffer"] })], + }, }); diff --git a/packages/docs/package.json b/packages/docs/package.json index 272b7da3..85e0c73c 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -18,6 +18,7 @@ "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@jimp/core": "workspace:*", "@jimp/plugin-print": "workspace:*", + "@jimp/wasm-webp": "workspace:*", "@types/react": "^18.3.5", "astro": "^4.15.1", "eslint": "^9.9.1", @@ -29,6 +30,7 @@ "typedoc": "^0.26.6", "typedoc-plugin-markdown": "4.2.6", "typedoc-plugin-zod": "^1.2.1", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vite-plugin-node-polyfills": "^0.22.0" } } diff --git a/packages/docs/public/tree.webp b/packages/docs/public/tree.webp new file mode 100644 index 00000000..8bbe329f Binary files /dev/null and b/packages/docs/public/tree.webp differ diff --git a/packages/docs/src/components/webp-example.tsx b/packages/docs/src/components/webp-example.tsx new file mode 100644 index 00000000..bbb5e23f --- /dev/null +++ b/packages/docs/src/components/webp-example.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from "react"; + +import { defaultFormats, defaultPlugins } from "jimp"; +import webp from "@jimp/wasm-webp"; +import { createJimp } from "@jimp/core"; + +const Jimp = createJimp({ + formats: [...defaultFormats, webp], + plugins: defaultPlugins, +}); + +export function WebpExample() { + const [selectedFile, setSelectedFile] = useState(""); + const [output, setOutput] = React.useState(""); + + function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + + if (!file) { + return; + } + + const reader = new FileReader(); + + reader.onload = async (e) => { + const data = e.target?.result; + + if (!data || !(data instanceof ArrayBuffer)) { + return; + } + + // Manipulate images uploaded directly from the website. + const image = await Jimp.fromBuffer(data); + image.quantize({ colors: 16 }).blur(8).pixelate(8); + setSelectedFile(URL.createObjectURL(file)); + setOutput(await image.getBase64("image/webp")); + }; + + reader.readAsArrayBuffer(file); + } + + useEffect(() => { + // Or load images hosted on the same domain. + Jimp.read("/jimp/tree.webp").then(async (image) => { + setSelectedFile(await image.getBase64("image/png")); + image.quantize({ colors: 16 }).blur(8).pixelate(8); + setOutput(await image.getBase64("image/png")); + }); + }, []); + + return ( +
+ {/* A file input that takes a png/jpeg */} + + +
+ {selectedFile && ( + Input + )} + {output && ( + Output + )} +
+
+ ); +} diff --git a/packages/docs/src/content/docs/guides/webp.mdx b/packages/docs/src/content/docs/guides/webp.mdx new file mode 100644 index 00000000..2fc7dce8 --- /dev/null +++ b/packages/docs/src/content/docs/guides/webp.mdx @@ -0,0 +1,58 @@ +--- +title: Using WEBP (And other WASM plugins) +description: How to use Jimp WebP and other WASM plugins. +--- + +import { WebpExample } from "../../../components/webp-example"; +import WebpExampleCode from "../../../components/webp-example?raw"; +import { Code } from "@astrojs/starlight/components"; + +The default build of Jimp only includes image formats written in javascript. +To utilize webp (and anything else we don't have a JS implementation for) we need to use format plugins and create a custom jimp. + +```ts +import { createJimp } from "@jimp/core"; +import { defaultFormats, defaultPlugins } from "jimp"; +import webp from "@jimp/wasm-webp"; + +// A custom jimp that supports webp +const Jimp = createJimp({ + formats: [...defaultFormats, webp], + plugins: defaultPlugins, +}); +``` + +
+ + +
+ Full code for example + + + +
+ +## Browser Usage + +Since you're no longer using a pre-bundled version of jimp you need configure your bundler to handle the node code. + +For example in vite/astro you can use `vite-plugin-node-polyfills`. + +```js + +import { nodePolyfills } from "vite-plugin-node-polyfills"; + +export default defineConfig({ + plugins: [ + // You only need to polyfill buffer if you're using a browser + plugins: [nodePolyfills({ include: ["buffer"] })], + ], +}); +``` + +## All WASM Plugins + +- [@jimp/wasm-avif](https://github.com/jimp-dev/jimp/tree/main/plugins/wasm-avif) +- [@jimp/wasm-jpeg](https://github.com/jimp-dev/jimp/tree/main/plugins/wasm-jpeg) +- [@jimp/wasm-png](https://github.com/jimp-dev/jimp/tree/main/plugins/wasm-png) +- [@jimp/wasm-webp](https://github.com/jimp-dev/jimp/tree/main/plugins/wasm-webp) \ No newline at end of file diff --git a/packages/jimp/src/index.ts b/packages/jimp/src/index.ts index 6dc2b904..6511163f 100644 --- a/packages/jimp/src/index.ts +++ b/packages/jimp/src/index.ts @@ -31,6 +31,29 @@ import * as quantize from "@jimp/plugin-quantize"; import { createJimp } from "@jimp/core"; +export const defaultPlugins = [ + blit.methods, + blur.methods, + circle.methods, + color.methods, + contain.methods, + cover.methods, + crop.methods, + displace.methods, + dither.methods, + fisheye.methods, + flip.methods, + hash.methods, + mask.methods, + print.methods, + resize.methods, + rotate.methods, + threshold.methods, + quantize.methods, +]; + +export const defaultFormats = [bmp, msBmp, gif, jpeg, png, tiff]; + // TODO: This doesn't document the constructor of the class /** * @class @@ -101,27 +124,8 @@ import { createJimp } from "@jimp/core"; * ``` */ export const Jimp = createJimp({ - formats: [bmp, msBmp, gif, jpeg, png, tiff], - plugins: [ - blit.methods, - blur.methods, - circle.methods, - color.methods, - contain.methods, - cover.methods, - crop.methods, - displace.methods, - dither.methods, - fisheye.methods, - flip.methods, - hash.methods, - mask.methods, - print.methods, - resize.methods, - rotate.methods, - threshold.methods, - quantize.methods, - ], + formats: defaultFormats, + plugins: defaultPlugins, }); export type { diff --git a/plugins/plugin-print/src/load-bitmap-font.ts b/plugins/plugin-print/src/load-bitmap-font.ts index 35950c12..90043711 100644 --- a/plugins/plugin-print/src/load-bitmap-font.ts +++ b/plugins/plugin-print/src/load-bitmap-font.ts @@ -5,7 +5,9 @@ import { BmCharacter, BmKerning, BmFont, BmCommonProps } from "./types.js"; import png from "@jimp/js-png"; import { createJimp } from "@jimp/core"; import path from "path"; -import { convertXML } from "simple-xml-to-json"; +import xmlPackage from "simple-xml-to-json"; + +const { convertXML } = xmlPackage; export const isWebWorker = typeof self !== "undefined" && self.document === undefined; diff --git a/plugins/wasm-avif/README.md b/plugins/wasm-avif/README.md new file mode 100644 index 00000000..f4c02a1e --- /dev/null +++ b/plugins/wasm-avif/README.md @@ -0,0 +1,19 @@ +# `@jimp/wasm-avif` + +A format plugin for Jimp that adds support for AVIF images using the [libavif](https://github.com/AOMediaCodec/libavif). + +> NOTE: Only works in esm environments. + +## Usage + +```ts +import { createJimp } from "@jimp/core"; +import { defaultPlugins } from "jimp"; +import avif from "@jimp/wasm-avif"; + +// A custom jimp that supports webp +const Jimp = createJimp({ + formats: [avif], + plugins: defaultPlugins, +}); +``` diff --git a/plugins/wasm-avif/eslint.config.mjs b/plugins/wasm-avif/eslint.config.mjs new file mode 100644 index 00000000..939816d0 --- /dev/null +++ b/plugins/wasm-avif/eslint.config.mjs @@ -0,0 +1,2 @@ +import shared from "@jimp/config-eslint/base.js"; +export default [...shared]; \ No newline at end of file diff --git a/plugins/wasm-avif/package.json b/plugins/wasm-avif/package.json new file mode 100644 index 00000000..9f33aef6 --- /dev/null +++ b/plugins/wasm-avif/package.json @@ -0,0 +1,60 @@ +{ + "name": "@jimp/wasm-avif", + "version": "1.0.1", + "repository": "jimp-dev/jimp", + "engines": { + "node": ">=18" + }, + "scripts": { + "lint": "eslint .", + "build": "tshy", + "dev": "tshy --watch", + "clean": "rm -rf node_modules .tshy .tshy-build dist .turbo" + }, + "author": "Andrew Lisowski ", + "license": "MIT", + "devDependencies": { + "@jimp/config-eslint": "workspace:*", + "@jimp/config-typescript": "workspace:*", + "@jimp/core": "workspace:*", + "@jimp/plugin-color": "workspace:*", + "@jimp/test-utils": "workspace:*", + "@jimp/types": "workspace:*", + "@types/node": "^18.19.48", + "eslint": "^9.9.1", + "tshy": "^3.0.2", + "typescript": "^5.5.4", + "vitest": "^2.0.5" + }, + "tshy": { + "exclude": [ + "**/*.test.ts" + ], + "dialects": [ + "esm" + ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + } + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + } + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "dependencies": { + "@jsquash/avif": "^1.3.0", + "zod": "^3.23.8" + }, + "module": "./dist/esm/index.js" +} diff --git a/plugins/wasm-avif/src/index.ts b/plugins/wasm-avif/src/index.ts new file mode 100644 index 00000000..7ba16b03 --- /dev/null +++ b/plugins/wasm-avif/src/index.ts @@ -0,0 +1,77 @@ +import decode, { init as initDecoder } from "@jsquash/avif/decode.js"; +import encode, { init as initEncoder } from "@jsquash/avif/encode.js"; +import { Format } from "@jimp/types"; +import z from "zod"; + +const AvifOptionsSchema = z.object({ + colorSpace: z.union([z.literal("display-p3"), z.literal("srgb")]).optional(), + chromaDeltaQ: z.boolean().optional().default(false), + cqAlphaLevel: z.number().min(0).max(100).optional().default(0), + cqLevel: z.number().min(0).max(100).optional().default(0), + denoiseLevel: z.number().min(0).max(100).optional().default(0), + sharpness: z.number().min(0).max(100).optional().default(0), + speed: z.number().min(0).max(100).optional().default(6), + subsample: z.number().min(0).max(4).optional().default(1), + tileColsLog2: z.number().min(0).max(10).optional().default(0), + tileRowsLog2: z.number().min(0).max(10).optional().default(0), + tune: z + .union([z.literal("psnr"), z.literal("ssim"), z.literal("auto")]) + .optional() + .default("auto"), +}); + +type AvifOptions = z.infer; + +export default function avif() { + return { + mime: "image/avif", + hasAlpha: true, + encode: async (bitmap, options: Partial = {}) => { + const { + colorSpace = "srgb", + chromaDeltaQ, + cqAlphaLevel, + cqLevel, + denoiseLevel, + sharpness, + speed, + subsample, + tileColsLog2, + tileRowsLog2, + tune, + } = AvifOptionsSchema.parse(options); + await initEncoder(); + const arrayBuffer = await encode( + { + ...bitmap, + data: new Uint8ClampedArray(bitmap.data), + colorSpace, + }, + { + chromaDeltaQ, + cqAlphaLevel, + cqLevel, + denoiseLevel, + sharpness, + speed, + subsample, + tileColsLog2, + tileRowsLog2, + tune: tune === "auto" ? 0 : tune === "psnr" ? 1 : 2, + } + ); + + return Buffer.from(arrayBuffer); + }, + decode: async (data) => { + await initDecoder(); + const result = await decode(data); + + return { + data: Buffer.from(result.data), + width: result.width, + height: result.height, + }; + }, + } satisfies Format<"image/avif">; +} diff --git a/plugins/wasm-avif/tsconfig.json b/plugins/wasm-avif/tsconfig.json new file mode 100644 index 00000000..ad7f216c --- /dev/null +++ b/plugins/wasm-avif/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@jimp/config-typescript/base.json", + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/plugins/wasm-jpeg/README.md b/plugins/wasm-jpeg/README.md new file mode 100644 index 00000000..79bd8f6b --- /dev/null +++ b/plugins/wasm-jpeg/README.md @@ -0,0 +1,19 @@ +# `@jimp/wasm-jpeg` + +A format plugin for Jimp that adds support for JPEG images using [mozjpeg](https://github.com/mozilla/mozjpeg). + +> NOTE: Only works in esm environments. + +## Usage + +```ts +import { createJimp } from "@jimp/core"; +import { defaultPlugins } from "jimp"; +import jpeg from "@jimp/wasm-jpeg"; + +// A custom jimp that supports webp +const Jimp = createJimp({ + formats: [jpeg], + plugins: defaultPlugins, +}); +``` diff --git a/plugins/wasm-jpeg/eslint.config.mjs b/plugins/wasm-jpeg/eslint.config.mjs new file mode 100644 index 00000000..939816d0 --- /dev/null +++ b/plugins/wasm-jpeg/eslint.config.mjs @@ -0,0 +1,2 @@ +import shared from "@jimp/config-eslint/base.js"; +export default [...shared]; \ No newline at end of file diff --git a/plugins/wasm-jpeg/package.json b/plugins/wasm-jpeg/package.json new file mode 100644 index 00000000..b27cda87 --- /dev/null +++ b/plugins/wasm-jpeg/package.json @@ -0,0 +1,60 @@ +{ + "name": "@jimp/wasm-jpeg", + "version": "1.0.1", + "repository": "jimp-dev/jimp", + "engines": { + "node": ">=18" + }, + "scripts": { + "lint": "eslint .", + "build": "tshy", + "dev": "tshy --watch", + "clean": "rm -rf node_modules .tshy .tshy-build dist .turbo" + }, + "author": "Andrew Lisowski ", + "license": "MIT", + "devDependencies": { + "@jimp/config-eslint": "workspace:*", + "@jimp/config-typescript": "workspace:*", + "@jimp/core": "workspace:*", + "@jimp/plugin-color": "workspace:*", + "@jimp/test-utils": "workspace:*", + "@jimp/types": "workspace:*", + "@types/node": "^18.19.48", + "eslint": "^9.9.1", + "tshy": "^3.0.2", + "typescript": "^5.5.4", + "vitest": "^2.0.5" + }, + "tshy": { + "exclude": [ + "**/*.test.ts" + ], + "dialects": [ + "esm" + ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + } + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + } + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "dependencies": { + "@jsquash/jpeg": "^1.4.0", + "zod": "^3.23.8" + }, + "module": "./dist/esm/index.js" +} diff --git a/plugins/wasm-jpeg/src/index.ts b/plugins/wasm-jpeg/src/index.ts new file mode 100644 index 00000000..eec56dc8 --- /dev/null +++ b/plugins/wasm-jpeg/src/index.ts @@ -0,0 +1,109 @@ +import decode, { init as initDecoder } from "@jsquash/jpeg/decode.js"; +import encode, { init as initEncoder } from "@jsquash/jpeg/encode.js"; +import { Format } from "@jimp/types"; +import z from "zod"; + +const JpegOptionsSchema = z.object({ + colorSpace: z.union([z.literal("display-p3"), z.literal("srgb")]).optional(), + jpegColorSpace: z.union([ + z.literal("rgb"), + z.literal("grayscale"), + z.literal("ycbcr"), + ]), + /** + * Image quality, between 0 and 100. + * For lossy, 0 gives the smallest size and 100 the largest. + * For lossless, this parameter is the amount of effort put + * into the compression: 0 is the fastest but gives larger + * files compared to the slowest, but best, 100. + * @default 100 + */ + quality: z.number().min(0).max(100).optional().default(75), + arithmetic: z.boolean().optional().default(false), + autoSubsample: z.boolean().optional().default(true), + baseline: z.boolean().optional().default(false), + chromaQuality: z.number().min(0).max(100).optional().default(75), + chromaSubsample: z.number().min(0).max(4).optional().default(2), + optimizeCoding: z.boolean().optional().default(true), + progressive: z.boolean().optional().default(true), + quantTable: z.number().min(0).optional().default(3), + separateChromaQuality: z.boolean().optional().default(false), + smoothing: z.number().min(0).max(100).optional().default(0), + trellisLoops: z.number().min(0).max(100).optional().default(1), + trellisMultipass: z.boolean().optional().default(false), + trellisOptTable: z.boolean().optional().default(false), + trellisOptZero: z.boolean().optional().default(false), +}); + +type JpegOptions = z.infer; + +export default function jpeg() { + return { + mime: "image/jpeg", + hasAlpha: true, + encode: async (bitmap, options: Partial = {}) => { + const { + quality, + colorSpace = "srgb", + arithmetic, + autoSubsample, + baseline, + chromaQuality, + chromaSubsample, + optimizeCoding, + progressive, + quantTable, + separateChromaQuality, + smoothing, + trellisLoops, + trellisMultipass, + trellisOptTable, + trellisOptZero, + jpegColorSpace, + } = JpegOptionsSchema.parse(options); + await initEncoder(); + const arrayBuffer = await encode( + { + ...bitmap, + data: new Uint8ClampedArray(bitmap.data), + colorSpace, + }, + { + quality, + arithmetic, + auto_subsample: autoSubsample, + baseline, + chroma_quality: chromaQuality, + chroma_subsample: chromaSubsample, + color_space: + jpegColorSpace === "rgb" + ? 2 + : jpegColorSpace === "grayscale" + ? 1 + : 3, + optimize_coding: optimizeCoding, + progressive, + quant_table: quantTable, + separate_chroma_quality: separateChromaQuality, + smoothing, + trellis_loops: trellisLoops, + trellis_multipass: trellisMultipass, + trellis_opt_table: trellisOptTable, + trellis_opt_zero: trellisOptZero, + } + ); + + return Buffer.from(arrayBuffer); + }, + decode: async (data) => { + await initDecoder(); + const result = await decode(data); + + return { + data: Buffer.from(result.data), + width: result.width, + height: result.height, + }; + }, + } satisfies Format<"image/jpeg">; +} diff --git a/plugins/wasm-jpeg/tsconfig.json b/plugins/wasm-jpeg/tsconfig.json new file mode 100644 index 00000000..ad7f216c --- /dev/null +++ b/plugins/wasm-jpeg/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@jimp/config-typescript/base.json", + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/plugins/wasm-png/README.md b/plugins/wasm-png/README.md new file mode 100644 index 00000000..7aa41a80 --- /dev/null +++ b/plugins/wasm-png/README.md @@ -0,0 +1,20 @@ +# `@jimp/wasm-png` + +A format plugin for Jimp that adds support for PNG images using the [rust crate](https://docs.rs/png/0.11.0/png/). +It also support optimizing the image using [oxipng](https://github.com/shssoichiro/oxipng). + +> NOTE: Only works in esm environments. + +## Usage + +```ts +import { createJimp } from "@jimp/core"; +import { defaultPlugins } from "jimp"; +import png from "@jimp/wasm-png"; + +// A custom jimp that supports webp +const Jimp = createJimp({ + formats: [png], + plugins: defaultPlugins, +}); +``` diff --git a/plugins/wasm-png/eslint.config.mjs b/plugins/wasm-png/eslint.config.mjs new file mode 100644 index 00000000..939816d0 --- /dev/null +++ b/plugins/wasm-png/eslint.config.mjs @@ -0,0 +1,2 @@ +import shared from "@jimp/config-eslint/base.js"; +export default [...shared]; \ No newline at end of file diff --git a/plugins/wasm-png/package.json b/plugins/wasm-png/package.json new file mode 100644 index 00000000..66899dbc --- /dev/null +++ b/plugins/wasm-png/package.json @@ -0,0 +1,61 @@ +{ + "name": "@jimp/wasm-png", + "version": "1.0.1", + "repository": "jimp-dev/jimp", + "engines": { + "node": ">=18" + }, + "scripts": { + "lint": "eslint .", + "build": "tshy", + "dev": "tshy --watch", + "clean": "rm -rf node_modules .tshy .tshy-build dist .turbo" + }, + "author": "Andrew Lisowski ", + "license": "MIT", + "devDependencies": { + "@jimp/config-eslint": "workspace:*", + "@jimp/config-typescript": "workspace:*", + "@jimp/core": "workspace:*", + "@jimp/plugin-color": "workspace:*", + "@jimp/test-utils": "workspace:*", + "@jimp/types": "workspace:*", + "@types/node": "^18.19.48", + "eslint": "^9.9.1", + "tshy": "^3.0.2", + "typescript": "^5.5.4", + "vitest": "^2.0.5" + }, + "tshy": { + "exclude": [ + "**/*.test.ts" + ], + "dialects": [ + "esm" + ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + } + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + } + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "dependencies": { + "@jsquash/oxipng": "^2.3.0", + "@jsquash/png": "^3.0.1", + "zod": "^3.23.8" + }, + "module": "./dist/esm/index.js" +} diff --git a/plugins/wasm-png/src/index.ts b/plugins/wasm-png/src/index.ts new file mode 100644 index 00000000..176c3632 --- /dev/null +++ b/plugins/wasm-png/src/index.ts @@ -0,0 +1,53 @@ +import decode, { init as initDecoder } from "@jsquash/png/decode.js"; +import encode, { init as initEncoder } from "@jsquash/png/encode.js"; +import { Format } from "@jimp/types"; +import { optimise } from "@jsquash/oxipng"; +import z from "zod"; + +const PngOptionsSchema = z.object({ + colorSpace: z.union([z.literal("display-p3"), z.literal("srgb")]).optional(), + optimize: z + .object({ + /** whether to use PNG interlacing or not. Interlacing will increase the size of an optimised image. */ + interlace: z.boolean().optional().default(false), + /** is the optimisation level between 1 to 6. The higher the level, the higher the compression. Any level above 4 is not recommended. */ + level: z.number().min(0).max(6).optional().default(2), + /** whether to allow transparent pixels to be altered to improve compression. */ + optimiseAlpha: z.boolean().optional().default(false), + }) + .optional(), +}); + +type PngOptions = z.infer; + +export default function png() { + return { + mime: "image/png", + hasAlpha: true, + encode: async (bitmap, options: Partial = {}) => { + const { colorSpace = "srgb", optimize } = PngOptionsSchema.parse(options); + await initEncoder(); + let arrayBuffer = await encode({ + ...bitmap, + data: new Uint8ClampedArray(bitmap.data), + colorSpace, + }); + + if (optimize) { + arrayBuffer = await optimise(arrayBuffer, optimize); + } + + return Buffer.from(arrayBuffer); + }, + decode: async (data) => { + await initDecoder(); + const result = await decode(data); + + return { + data: Buffer.from(result.data), + width: result.width, + height: result.height, + }; + }, + } satisfies Format<"image/png">; +} diff --git a/plugins/wasm-png/tsconfig.json b/plugins/wasm-png/tsconfig.json new file mode 100644 index 00000000..ad7f216c --- /dev/null +++ b/plugins/wasm-png/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@jimp/config-typescript/base.json", + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/plugins/wasm-webp/README.md b/plugins/wasm-webp/README.md new file mode 100644 index 00000000..628a4016 --- /dev/null +++ b/plugins/wasm-webp/README.md @@ -0,0 +1,19 @@ +# `@jimp/wasm-webp` + +A format plugin for Jimp that adds support for WebP images. + +> NOTE: Only works in esm environments. + +## Usage + +```ts +import { createJimp } from "@jimp/core"; +import { defaultFormats, defaultPlugins } from "jimp"; +import webp from "@jimp/wasm-webp"; + +// A custom jimp that supports webp +const Jimp = createJimp({ + formats: [...defaultFormats, webp], + plugins: defaultPlugins, +}); +``` diff --git a/plugins/wasm-webp/eslint.config.mjs b/plugins/wasm-webp/eslint.config.mjs new file mode 100644 index 00000000..939816d0 --- /dev/null +++ b/plugins/wasm-webp/eslint.config.mjs @@ -0,0 +1,2 @@ +import shared from "@jimp/config-eslint/base.js"; +export default [...shared]; \ No newline at end of file diff --git a/plugins/wasm-webp/package.json b/plugins/wasm-webp/package.json new file mode 100644 index 00000000..b5e698f7 --- /dev/null +++ b/plugins/wasm-webp/package.json @@ -0,0 +1,60 @@ +{ + "name": "@jimp/wasm-webp", + "version": "1.0.1", + "repository": "jimp-dev/jimp", + "engines": { + "node": ">=18" + }, + "scripts": { + "lint": "eslint .", + "build": "tshy", + "dev": "tshy --watch", + "clean": "rm -rf node_modules .tshy .tshy-build dist .turbo" + }, + "author": "Andrew Lisowski ", + "license": "MIT", + "devDependencies": { + "@jimp/config-eslint": "workspace:*", + "@jimp/config-typescript": "workspace:*", + "@jimp/core": "workspace:*", + "@jimp/plugin-color": "workspace:*", + "@jimp/test-utils": "workspace:*", + "@jimp/types": "workspace:*", + "@types/node": "^18.19.48", + "eslint": "^9.9.1", + "tshy": "^3.0.2", + "typescript": "^5.5.4", + "vitest": "^2.0.5" + }, + "tshy": { + "exclude": [ + "**/*.test.ts" + ], + "dialects": [ + "esm" + ], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + } + }, + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + } + } + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "dependencies": { + "@jsquash/webp": "^1.4.0", + "zod": "^3.23.8" + }, + "module": "./dist/esm/index.js" +} diff --git a/plugins/wasm-webp/src/images/test.webp b/plugins/wasm-webp/src/images/test.webp new file mode 100644 index 00000000..8bbe329f Binary files /dev/null and b/plugins/wasm-webp/src/images/test.webp differ diff --git a/plugins/wasm-webp/src/index.ts b/plugins/wasm-webp/src/index.ts new file mode 100644 index 00000000..22e5c27d --- /dev/null +++ b/plugins/wasm-webp/src/index.ts @@ -0,0 +1,188 @@ +import decode, { init as initDecoder } from "@jsquash/webp/decode.js"; +import encode, { init as initEncoder } from "@jsquash/webp/encode.js"; +import { Format } from "@jimp/types"; +import z from "zod"; + +const WebpOptionsSchema = z.object({ + colorSpace: z.union([z.literal("display-p3"), z.literal("srgb")]).optional(), + /** + * Image quality, between 0 and 100. + * For lossy, 0 gives the smallest size and 100 the largest. + * For lossless, this parameter is the amount of effort put + * into the compression: 0 is the fastest but gives larger + * files compared to the slowest, but best, 100. + * @default 100 + */ + quality: z.number().min(0).max(100).optional().default(100), + /** If non-zero, set the desired target size in bytes. */ + targetSize: z.number().min(0).optional().default(0), + /** If non-zero, specifies the minimal distortion to try to achieve. Takes precedence over target_size. */ + targetPSNR: z.number().min(0).optional().default(0), + /** Quality/speed trade-off (0 = fast, 6 = slower-better). */ + method: z.number().min(0).max(6).optional().default(4), + /** Spatial Noise Shaping. 0 = off, 100 = maximum. */ + snsStrength: z.number().min(0).max(100).optional().default(50), + /** Range: 0 = off, 100 = strongest. */ + filterStrength: z.number().min(0).max(100).optional().default(60), + /** Range: 0 = off, 7 = least sharp. */ + filterSharpness: z.number().min(0).max(7).optional().default(0), + /** Filtering type: 0 = simple, 1 = strong (only used if filter_strength > 0 or autofilter > 0). */ + filterType: z.number().min(0).max(1).optional().default(1), + /** log2(number of token partitions) in 0..3. Default is set to 0 for easier progressive decoding. */ + partitions: z.number().min(0).max(3).optional().default(0), + /** Maximum number of segments to use, in 1..4. */ + segments: z.number().min(1).max(4).optional().default(4), + /** Number of entropy-analysis passes (in 1..10). */ + pass: z.number().min(1).max(10).optional().default(1), + /** If true, export the compressed picture back. In-loop filtering is not applied. */ + showCompressed: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** Preprocessing filter (0 = none, 1 = segment-smooth). */ + preprocessing: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** Auto adjust filter's strength (0 = off, 1 = on). */ + autoFilter: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** Quality degradation allowed to fit the 512k limit on prediction modes coding (0 = no degradation, 100 = maximum possible degradation). */ + partitionLimit: z.number().min(0).max(100).optional().default(0), + /** Algorithm for encoding the alpha plane (0 = none, 1 = compressed with WebP lossless). */ + alphaCompression: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(1), + /** Predictive filtering method for alpha plane (0 = none, 1 = fast, 2 = best). */ + alphaFiltering: z + .union([z.literal(0), z.literal(1), z.literal(2)]) + .optional() + .default(1), + /** Between 0 (smallest size) and 100 (lossless). */ + alphaQuality: z.number().min(0).max(100).optional().default(100), + /** Set to 1 for lossless encoding (default is lossy). */ + lossless: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** By default, RGB values in transparent areas will be modified to improve compression. Set exact to 1 to prevent this. */ + exact: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** If true, compression parameters will be remapped to better match the expected output size from JPEG compression. Generally, the output size will be similar but the degradation will be lower. */ + emulateJpegSize: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** If non-zero, try and use multi-threaded encoding. */ + threadLevel: z.number().min(0).optional().default(0), + /** Reduce memory usage (slower encoding). */ + lowMemory: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** Near lossless encoding (0 = max loss, 100 = off). */ + nearLossless: z.number().min(0).max(100).optional().default(100), + /** Reserved for future lossless feature. */ + useDeltaPalette: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), + /** If needed, use sharp (and slow) RGB->YUV conversion. */ + useSharpYuv: z + .union([z.literal(1), z.literal(0)]) + .optional() + .default(0), +}); + +type WebpOptions = z.infer; + +export default function png() { + return { + mime: "image/webp", + hasAlpha: true, + encode: async (bitmap, options: Partial = {}) => { + const { + quality, + alphaCompression, + alphaFiltering, + alphaQuality, + autoFilter, + emulateJpegSize, + exact, + filterSharpness, + filterStrength, + filterType, + lossless, + method, + lowMemory, + nearLossless, + useDeltaPalette, + useSharpYuv, + threadLevel, + partitionLimit, + partitions, + pass, + preprocessing, + showCompressed, + segments, + snsStrength, + targetPSNR, + targetSize, + colorSpace = "srgb", + } = WebpOptionsSchema.parse(options); + await initEncoder(); + const arrayBuffer = await encode( + { + ...bitmap, + data: new Uint8ClampedArray(bitmap.data), + colorSpace, + }, + { + quality, + alpha_compression: alphaCompression, + alpha_filtering: alphaFiltering, + alpha_quality: alphaQuality, + autofilter: autoFilter, + emulate_jpeg_size: emulateJpegSize, + exact: exact, + filter_sharpness: filterSharpness, + filter_strength: filterStrength, + filter_type: filterType, + lossless: lossless, + method, + low_memory: lowMemory, + near_lossless: nearLossless, + use_delta_palette: useDeltaPalette, + use_sharp_yuv: useSharpYuv, + thread_level: threadLevel, + partition_limit: partitionLimit, + partitions, + pass, + preprocessing, + show_compressed: showCompressed, + segments, + sns_strength: snsStrength, + target_PSNR: targetPSNR, + target_size: targetSize, + } + ); + + return Buffer.from(arrayBuffer); + }, + decode: async (data) => { + await initDecoder(); + const result = await decode(data); + + return { + data: Buffer.from(result.data), + width: result.width, + height: result.height, + }; + }, + } satisfies Format<"image/webp">; +} diff --git a/plugins/wasm-webp/tsconfig.json b/plugins/wasm-webp/tsconfig.json new file mode 100644 index 00000000..ad7f216c --- /dev/null +++ b/plugins/wasm-webp/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@jimp/config-typescript/base.json", + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 386065c6..4de90ac8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,6 +209,9 @@ importers: '@jimp/plugin-print': specifier: workspace:* version: link:../../plugins/plugin-print + '@jimp/wasm-webp': + specifier: workspace:* + version: link:../../plugins/wasm-webp '@types/react': specifier: ^18.3.5 version: 18.3.5 @@ -245,6 +248,9 @@ importers: typescript: specifier: ^5.5.4 version: 5.5.4 + vite-plugin-node-polyfills: + specifier: ^0.22.0 + version: 0.22.0(rollup@4.21.2)(vite@5.4.2(@types/node@22.5.2)(terser@5.30.3)) packages/file-ops: devDependencies: @@ -1678,6 +1684,181 @@ importers: specifier: ^2.0.5 version: 2.0.5(@types/node@22.5.2)(@vitest/browser@2.0.5)(terser@5.30.3) + plugins/wasm-avif: + dependencies: + '@jsquash/avif': + specifier: ^1.3.0 + version: 1.3.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@jimp/config-eslint': + specifier: workspace:* + version: link:../../packages/config-eslint + '@jimp/config-typescript': + specifier: workspace:* + version: link:../../packages/config-typescript + '@jimp/core': + specifier: workspace:* + version: link:../../packages/core + '@jimp/plugin-color': + specifier: workspace:* + version: link:../plugin-color + '@jimp/test-utils': + specifier: workspace:* + version: link:../../packages/test-utils + '@jimp/types': + specifier: workspace:* + version: link:../../packages/types + '@types/node': + specifier: ^18.19.48 + version: 18.19.48 + eslint: + specifier: ^9.9.1 + version: 9.9.1 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@18.19.48)(@vitest/browser@2.0.5)(terser@5.30.3) + + plugins/wasm-jpeg: + dependencies: + '@jsquash/jpeg': + specifier: ^1.4.0 + version: 1.4.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@jimp/config-eslint': + specifier: workspace:* + version: link:../../packages/config-eslint + '@jimp/config-typescript': + specifier: workspace:* + version: link:../../packages/config-typescript + '@jimp/core': + specifier: workspace:* + version: link:../../packages/core + '@jimp/plugin-color': + specifier: workspace:* + version: link:../plugin-color + '@jimp/test-utils': + specifier: workspace:* + version: link:../../packages/test-utils + '@jimp/types': + specifier: workspace:* + version: link:../../packages/types + '@types/node': + specifier: ^18.19.48 + version: 18.19.48 + eslint: + specifier: ^9.9.1 + version: 9.9.1 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@18.19.48)(@vitest/browser@2.0.5)(terser@5.30.3) + + plugins/wasm-png: + dependencies: + '@jsquash/oxipng': + specifier: ^2.3.0 + version: 2.3.0 + '@jsquash/png': + specifier: ^3.0.1 + version: 3.0.1 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@jimp/config-eslint': + specifier: workspace:* + version: link:../../packages/config-eslint + '@jimp/config-typescript': + specifier: workspace:* + version: link:../../packages/config-typescript + '@jimp/core': + specifier: workspace:* + version: link:../../packages/core + '@jimp/plugin-color': + specifier: workspace:* + version: link:../plugin-color + '@jimp/test-utils': + specifier: workspace:* + version: link:../../packages/test-utils + '@jimp/types': + specifier: workspace:* + version: link:../../packages/types + '@types/node': + specifier: ^18.19.48 + version: 18.19.48 + eslint: + specifier: ^9.9.1 + version: 9.9.1 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@18.19.48)(@vitest/browser@2.0.5)(terser@5.30.3) + + plugins/wasm-webp: + dependencies: + '@jsquash/webp': + specifier: ^1.4.0 + version: 1.4.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@jimp/config-eslint': + specifier: workspace:* + version: link:../../packages/config-eslint + '@jimp/config-typescript': + specifier: workspace:* + version: link:../../packages/config-typescript + '@jimp/core': + specifier: workspace:* + version: link:../../packages/core + '@jimp/plugin-color': + specifier: workspace:* + version: link:../plugin-color + '@jimp/test-utils': + specifier: workspace:* + version: link:../../packages/test-utils + '@jimp/types': + specifier: workspace:* + version: link:../../packages/types + '@types/node': + specifier: ^18.19.48 + version: 18.19.48 + eslint: + specifier: ^9.9.1 + version: 9.9.1 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + vitest: + specifier: ^2.0.5 + version: 2.0.5(@types/node@18.19.48)(@vitest/browser@2.0.5)(terser@5.30.3) + packages: '@aashutoshrathi/word-wrap@1.2.6': @@ -2309,6 +2490,21 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsquash/avif@1.3.0': + resolution: {integrity: sha512-N6zH27O/AioCPNGxaf33PYnUEQZmAjUz0JwwAf9eMHRdYItn+CxwxlsHSSOkFmZKW+v9uVX6c7ZPQ4RTXArL7A==} + + '@jsquash/jpeg@1.4.0': + resolution: {integrity: sha512-I/uGQ5Gk3qOEQNufUcR9boZ0qH+eoXW7cp0mW9eNInlgjRXwEhepvfbnutQ+RM1dNeuIHoOM5YVCEK1y/ATipQ==} + + '@jsquash/oxipng@2.3.0': + resolution: {integrity: sha512-aQ8wiEp6ztlTMXc+RMt/CG8crU3mEHDU+h+JYkIi6ctMhlh8+Ltj5XwQFfBuyzKYrp8NxaFW80Dp824bqjr+zA==} + + '@jsquash/png@3.0.1': + resolution: {integrity: sha512-Bnvv93Y5LL92cuk2r2gpV+9JKuDo2/w7bOODw1iPxk8VARknky0sS1tSDgMosUdhNb4CdMlcCm3TMzTaqa3zZw==} + + '@jsquash/webp@1.4.0': + resolution: {integrity: sha512-yKJb6Hilq+qV/4C4qTDEalBobNwJO09LeHHtilmWg5mYHFUDwMunfeAap/r3cL5KsHkGOoI0IjY2nKbTaHq9Bw==} + '@mdx-js/mdx@3.0.1': resolution: {integrity: sha512-eIQ4QTrOWyL3LWEe/bu6Taqzq2HQvHcyTMaOrI95P2/LmJE7AsfPfgJGuFLPVqBUE1BC1rik3VIhU+s9u72arA==} @@ -4714,10 +4910,6 @@ packages: magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} - magic-string@0.30.8: - resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} - engines: {node: '>=12'} - magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -6633,6 +6825,9 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + wasm-feature-detect@1.6.2: + resolution: {integrity: sha512-4dnaZ+Fq/q+BbMlTIfaNS851i+0zmHzui++NUZdskESRu3xwB6g6x2FnGvBdWtpijqO5yuj1l+EUTJGc4S4DKg==} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -7634,6 +7829,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + '@jsquash/avif@1.3.0': + dependencies: + wasm-feature-detect: 1.6.2 + + '@jsquash/jpeg@1.4.0': {} + + '@jsquash/oxipng@2.3.0': + dependencies: + wasm-feature-detect: 1.6.2 + + '@jsquash/png@3.0.1': {} + + '@jsquash/webp@1.4.0': + dependencies: + wasm-feature-detect: 1.6.2 + '@mdx-js/mdx@3.0.1': dependencies: '@types/estree': 1.0.5 @@ -7830,7 +8041,7 @@ snapshots: dependencies: '@rollup/pluginutils': 5.1.0(rollup@4.21.2) estree-walker: 2.0.2 - magic-string: 0.30.8 + magic-string: 0.30.11 optionalDependencies: rollup: 4.21.2 @@ -10591,10 +10802,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - magic-string@0.30.8: - dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 - magicast@0.3.5: dependencies: '@babel/parser': 7.25.6 @@ -13071,6 +13278,8 @@ snapshots: walk-up-path@4.0.0: {} + wasm-feature-detect@1.6.2: {} + wcwidth@1.0.1: dependencies: defaults: 1.0.4