Skip to content

Commit

Permalink
feat(textureCompress): Fall back to ndarray-pixels+ndarray-lanczos fo…
Browse files Browse the repository at this point in the history
…r image compression on Web. (#1075)
  • Loading branch information
donmccurdy authored Aug 30, 2023
1 parent eac440c commit cd78ffd
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 55 deletions.
131 changes: 96 additions & 35 deletions packages/functions/src/texture-compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { EXTTextureAVIF, EXTTextureWebP } from '@gltf-transform/extensions';
import { getTextureChannelMask } from './list-texture-channels.js';
import { listTextureSlots } from './list-texture-slots.js';
import type sharp from 'sharp';
import { createTransform, formatBytes } from './utils.js';
import { createTransform, fitWithin, formatBytes } from './utils.js';
import { TextureResizeFilter } from './texture-resize.js';
import { getPixels, savePixels } from 'ndarray-pixels';
import ndarray from 'ndarray';
import { lanczos2, lanczos3 } from 'ndarray-lanczos';

const NAME = 'textureCompress';

Expand All @@ -14,9 +17,11 @@ const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/av

export interface TextureCompressOptions {
/** Instance of the Sharp encoder, which must be installed from the
* 'sharp' package and provided by the caller.
* 'sharp' package and provided by the caller. When not provided, a
* platform-specific fallback implementation will be used, and most
* quality- and compression-related options are ignored.
*/
encoder: unknown;
encoder?: unknown;
/**
* Target image format. If specified, included textures in other formats
* will be converted. Default: original format.
Expand All @@ -39,11 +44,20 @@ export interface TextureCompressOptions {

/** Quality, 1-100. Default: auto. */
quality?: number | null;
/** Level of CPU effort to reduce file size, 0-100. PNG, WebP, and AVIF only. Default: auto. */
/**
* Level of CPU effort to reduce file size, 0-100. PNG, WebP, and AVIF
* only. Supported only when a Sharp encoder is provided. Default: auto.
*/
effort?: number | null;
/** Use lossless compression mode. WebP and AVIF only. Default: false. */
/**
* Use lossless compression mode. WebP and AVIF only. Supported only when a
* Sharp encoder is provided. Default: false.
*/
lossless?: boolean;
/** Use near lossless compression mode. WebP only. Default: false. */
/**
* Use near lossless compression mode. WebP only. Supported only when a
* Sharp encoder is provided. Default: false.
*/
nearLossless?: boolean;
}

Expand All @@ -64,7 +78,10 @@ export const TEXTURE_COMPRESS_DEFAULTS: Omit<TextureCompressOptions, 'resize' |
/**
* Optimizes images, optionally resizing or converting to JPEG, PNG, WebP, or AVIF formats.
*
* Requires `sharp`, and is available only in Node.js environments.
* For best results use a Node.js environment, install the `sharp` module, and
* provide an encoder. When the encoder is omitted — `sharp` works only in Node.js —
* the implementation will use a platform-specific fallback encoder, and most
* quality- and compression-related options are ignored.
*
* Example:
*
Expand All @@ -85,22 +102,23 @@ export const TEXTURE_COMPRESS_DEFAULTS: Omit<TextureCompressOptions, 'resize' |
* slots: /^(?!normalTexture).*$/ // exclude normal maps
* })
* );
*
* // (C) Resize and convert images to WebP in a browser, without a Sharp
* // encoder. Most quality- and compression-related options are ignored.
* await document.transform(
* textureCompress({ targetFormat: 'webp', resize: [1024, 1024] })
* );
* ```
*
* @category Transforms
*/
export function textureCompress(_options: TextureCompressOptions): Transform {
const options = { ...TEXTURE_COMPRESS_DEFAULTS, ..._options } as Required<TextureCompressOptions>;
const encoder = options.encoder as typeof sharp | null;
const targetFormat = options.targetFormat as Format | undefined;
const patternRe = options.pattern;
const formatsRe = options.formats;
const slotsRe = options.slots;

if (!encoder) {
throw new Error(`${targetFormat}: encoder dependency required — install "sharp".`);
}

return createTransform(NAME, async (document: Document): Promise<void> => {
const logger = document.getLogger();
const textures = document.getRoot().listTextures();
Expand Down Expand Up @@ -176,7 +194,10 @@ export function textureCompress(_options: TextureCompressOptions): Transform {
/**
* Optimizes a single {@link Texture}, optionally resizing or converting to JPEG, PNG, WebP, or AVIF formats.
*
* Requires `sharp`, and is available only in Node.js environments.
* For best results use a Node.js environment, install the `sharp` module, and
* provide an encoder. When the encoder is omitted — `sharp` works only in Node.js —
* the implementation will use a platform-specific fallback encoder, and most
* quality- and compression-related options are ignored.
*
* Example:
*
Expand All @@ -187,28 +208,63 @@ export function textureCompress(_options: TextureCompressOptions): Transform {
* const texture = document.getRoot().listTextures()
* .find((texture) => texture.getName() === 'MyTexture');
*
* // (A) Node.js.
* await compressTexture(texture, {
* encoder: sharp,
* targetFormat: 'webp',
* resize: [1024, 1024]
* });
*
* // (B) Web.
* await compressTexture(texture, {
* targetFormat: 'webp',
* resize: [1024, 1024]
* });
* ```
*/
export async function compressTexture(texture: Texture, _options: CompressTextureOptions) {
const options = { ...TEXTURE_COMPRESS_DEFAULTS, ..._options } as Required<CompressTextureOptions>;
const encoder = options.encoder as typeof sharp | null;

if (!encoder) {
throw new Error(`${options.targetFormat}: encoder dependency required — install "sharp".`);
}

const srcFormat = getFormat(texture);
const dstFormat = options.targetFormat || srcFormat;
const srcMimeType = texture.getMimeType();
const dstMimeType = `image/${dstFormat}`;

const srcImage = texture.getImage()!;
const dstImage = encoder
? await _encodeWithSharp(srcImage, srcMimeType, dstMimeType, options)
: await _encodeWithNdarrayPixels(srcImage, srcMimeType, dstMimeType, options);

const srcByteLength = srcImage.byteLength;
const dstByteLength = dstImage.byteLength;

if (srcMimeType === dstMimeType && dstByteLength >= srcByteLength && !options.resize) {
// Skip if src/dst formats match and dst is larger than the original.
return;
} else if (srcMimeType === dstMimeType) {
// Overwrite if src/dst formats match and dst is smaller than the original.
texture.setImage(dstImage);
} else {
// Overwrite, then update path and MIME type if src/dst formats differ.
const srcExtension = ImageUtils.mimeTypeToExtension(srcMimeType);
const dstExtension = ImageUtils.mimeTypeToExtension(dstMimeType);
const dstURI = texture.getURI().replace(new RegExp(`\\.${srcExtension}$`), `.${dstExtension}`);
texture.setImage(dstImage).setMimeType(dstMimeType).setURI(dstURI);
}
}

async function _encodeWithSharp(
srcImage: Uint8Array,
_srcMimeType: string,
dstMimeType: string,
options: Required<CompressTextureOptions>,
): Promise<Uint8Array> {
const encoder = options.encoder as typeof sharp;
let encoderOptions: sharp.JpegOptions | sharp.PngOptions | sharp.WebpOptions | sharp.AvifOptions = {};

const dstFormat = getFormatFromMimeType(dstMimeType);

switch (dstFormat) {
case 'jpeg':
encoderOptions = { quality: options.quality } as sharp.JpegOptions;
Expand Down Expand Up @@ -236,10 +292,8 @@ export async function compressTexture(texture: Texture, _options: CompressTextur
break;
}

const srcImage = texture.getImage()!;
const instance = encoder(srcImage).toFormat(dstFormat, encoderOptions);

// Resize.
if (options.resize) {
instance.resize(options.resize[0], options.resize[1], {
fit: 'inside',
Expand All @@ -248,28 +302,35 @@ export async function compressTexture(texture: Texture, _options: CompressTextur
});
}

const dstImage = BufferUtils.toView(await instance.toBuffer());
return BufferUtils.toView(await instance.toBuffer());
}

const srcByteLength = srcImage.byteLength;
const dstByteLength = dstImage.byteLength;
async function _encodeWithNdarrayPixels(
srcImage: Uint8Array,
srcMimeType: string,
dstMimeType: string,
options: Required<CompressTextureOptions>,
): Promise<Uint8Array> {
const srcPixels = (await getPixels(srcImage, srcMimeType)) as ndarray.NdArray<Uint8Array>;

if (srcMimeType === dstMimeType && dstByteLength >= srcByteLength) {
// Skip if src/dst formats match and dst is larger than the original.
return;
} else if (srcMimeType === dstMimeType) {
// Overwrite if src/dst formats match and dst is smaller than the original.
texture.setImage(dstImage);
} else {
// Overwrite, then update path and MIME type if src/dst formats differ.
const srcExtension = ImageUtils.mimeTypeToExtension(srcMimeType);
const dstExtension = ImageUtils.mimeTypeToExtension(dstMimeType);
const dstURI = texture.getURI().replace(new RegExp(`\\.${srcExtension}$`), `.${dstExtension}`);
texture.setImage(dstImage).setMimeType(dstMimeType).setURI(dstURI);
if (options.resize) {
const [w, h] = srcPixels.shape;
const dstSize = fitWithin([w, h], options.resize);
const dstPixels = ndarray(new Uint8Array(dstSize[0] * dstSize[1] * 4), [...dstSize, 4]);
options.resizeFilter === TextureResizeFilter.LANCZOS3
? lanczos3(srcPixels, dstPixels)
: lanczos2(srcPixels, dstPixels);
return savePixels(dstPixels, dstMimeType);
}

return savePixels(srcPixels, dstMimeType);
}

function getFormat(texture: Texture): Format {
const mimeType = texture.getMimeType();
return getFormatFromMimeType(texture.getMimeType());
}

function getFormatFromMimeType(mimeType: string): Format {
const format = mimeType.split('/').pop() as Format | undefined;
if (!format || !FORMATS.includes(format)) {
throw new Error(`Unknown MIME type "${mimeType}".`);
Expand Down
29 changes: 9 additions & 20 deletions packages/functions/src/texture-resize.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import ndarray from 'ndarray';
import { lanczos2, lanczos3 } from 'ndarray-lanczos';
import { getPixels, savePixels } from 'ndarray-pixels';
import type { Document, Transform, vec2 } from '@gltf-transform/core';
import { MathUtils, type Document, type Transform, type vec2 } from '@gltf-transform/core';
import { listTextureSlots } from './list-texture-slots.js';
import { createTransform } from './utils.js';
import { createTransform, fitWithin } from './utils.js';

const NAME = 'textureResize';

Expand Down Expand Up @@ -43,8 +43,10 @@ export const TEXTURE_RESIZE_DEFAULTS: TextureResizeOptions = {
*
* Implementation provided by [ndarray-lanczos](https://github.com/donmccurdy/ndarray-lanczos)
* package, which works in Web and Node.js environments. For a faster and more robust implementation
* based on Sharp (available only in Node.js), use {@link textureCompress} with the 'resize' option.
* in Node.js, use {@link textureCompress}, providing a Sharp encoder and 'resize' options instead.
*
* @deprecated Prefer {@link textureCompress}, instead.
* @privateRemarks TODO(v4): Remove this function, using `textureCompress()` instead.
* @category Transforms
*/
export function textureResize(_options: TextureResizeOptions = TEXTURE_RESIZE_DEFAULTS): Transform {
Expand Down Expand Up @@ -73,30 +75,17 @@ export function textureResize(_options: TextureResizeOptions = TEXTURE_RESIZE_DE
continue;
}

const [maxWidth, maxHeight] = options.size;
const [srcWidth, srcHeight] = texture.getSize()!;
const srcSize = texture.getSize()!;
const dstSize = fitWithin(srcSize, options.size);

if (srcWidth <= maxWidth && srcHeight <= maxHeight) {
if (MathUtils.eq(srcSize, dstSize)) {
logger.debug(`${NAME}: Skipping, not within size range.`);
continue;
}

let dstWidth = srcWidth;
let dstHeight = srcHeight;

if (dstWidth > maxWidth) {
dstHeight = Math.floor(dstHeight * (maxWidth / dstWidth));
dstWidth = maxWidth;
}

if (dstHeight > maxHeight) {
dstWidth = Math.floor(dstWidth * (maxHeight / dstHeight));
dstHeight = maxHeight;
}

const srcImage = texture.getImage()!;
const srcPixels = (await getPixels(srcImage, texture.getMimeType())) as ndarray.NdArray<Uint8Array>;
const dstPixels = ndarray(new Uint8Array(dstWidth * dstHeight * 4), [dstWidth, dstHeight, 4]);
const dstPixels = ndarray(new Uint8Array(dstSize[0] * dstSize[1] * 4), [...dstSize, 4]);

logger.debug(`${NAME}: Resizing "${uri || name}", ${srcPixels.shape}${dstPixels.shape}...`);
logger.debug(`${NAME}: Slots → [${slots.join(', ')}]`);
Expand Down
24 changes: 24 additions & 0 deletions packages/functions/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Texture,
Transform,
TransformContext,
vec2,
} from '@gltf-transform/core';

/**
Expand Down Expand Up @@ -246,3 +247,26 @@ export function createPrimGroupKey(prim: Primitive): string {

return `${materialIndex}|${mode}|${indices}|${attributes}|${targets}`;
}

/** @hidden */
export function fitWithin(size: vec2, limit: vec2): vec2 {
const [maxWidth, maxHeight] = limit;
const [srcWidth, srcHeight] = size;

if (srcWidth <= maxWidth && srcHeight <= maxHeight) return size;

let dstWidth = srcWidth;
let dstHeight = srcHeight;

if (dstWidth > maxWidth) {
dstHeight = Math.floor(dstHeight * (maxWidth / dstWidth));
dstWidth = maxWidth;
}

if (dstHeight > maxHeight) {
dstWidth = Math.floor(dstWidth * (maxHeight / dstHeight));
dstHeight = maxHeight;
}

return [dstWidth, dstHeight];
}
30 changes: 30 additions & 0 deletions packages/functions/test/texture-compress.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import test from 'ava';
import { Document } from '@gltf-transform/core';
import { EXTTextureWebP } from '@gltf-transform/extensions';
import { textureCompress } from '@gltf-transform/functions';
import { logger } from '@gltf-transform/test-utils';
import ndarray from 'ndarray';
import { savePixels } from 'ndarray-pixels';

const ORIGINAL_JPEG = new Uint8Array([1, 2, 3, 4]);
const ORIGINAL_PNG = new Uint8Array([5, 6, 7, 8]);
Expand All @@ -13,6 +16,8 @@ const EXPECTED_PNG = new Uint8Array([103, 104]);
const EXPECTED_WEBP = new Uint8Array([105]);
const EXPECTED_AVIF = new Uint8Array([106, 107, 108]); // larger than original; skipped.

const NON_SQUARE = ndarray(new Uint8Array(256 * 512 * 4), [256, 512, 4]);

test('unknown format', async (t) => {
const { encoder, calls } = createMockEncoder();
const document = new Document().setLogger(logger);
Expand Down Expand Up @@ -180,6 +185,31 @@ test('webp', async (t) => {
t.deepEqual(texturePNG.getImage(), EXPECTED_WEBP, 'png optimized');
});

test('fallback to ndarray-pixels', async (t) => {
const document = new Document().setLogger(logger);
document.createExtension(EXTTextureWebP);

const textureA = document
.createTexture('JPEG')
.setImage(await savePixels(NON_SQUARE, 'image/jpeg'))
.setMimeType('image/jpeg');
const textureB = document
.createTexture('PNG')
.setImage(await savePixels(NON_SQUARE, 'image/png'))
.setMimeType('image/png');

await document.transform(textureCompress({ targetFormat: 'webp', resize: [128, 128] }));

t.is(textureA.getMimeType(), 'image/webp');
t.is(textureB.getMimeType(), 'image/webp');

// TODO(cleanup): Not registered automatically without I/O.
EXTTextureWebP.register();

t.deepEqual(textureA.getSize(), [64, 128]);
t.deepEqual(textureB.getSize(), [64, 128]);
});

function createMockEncoder() {
const calls = [];

Expand Down

0 comments on commit cd78ffd

Please sign in to comment.