Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support textureCompress() on web, with fallback to ndarray-pixels #1075

Merged
merged 1 commit into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading