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

Add EXT_texture_avif #771

Merged
merged 5 commits into from
Feb 3, 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
23 changes: 23 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,29 @@ compression methods are typically more forgiving than GPU texture compression,
and require less tuning to achieve good visual and filesize results.
`.trim();

// AVIF
program
.command('avif', 'AVIF texture compression')
.help(TEXTURE_COMPRESS_SUMMARY.replace(/{VARIANT}/g, 'AVIF'))
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option(
'--formats <formats>',
'Texture formats to include (glob)',
{validator: ['image/png', 'image/jpeg', '*'], default: '*'}
)
.option(
'--slots <slots>',
'Texture slots to include (glob)',
{validator: program.STRING, default: '*'}
)
.action(({args, options, logger}) => {
const formats = micromatch.makeRe(String(options.formats), MICROMATCH_OPTIONS);
const slots = micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS);
return Session.create(io, logger, args.input, args.output)
.transform(textureCompress({targetFormat: 'avif', encoder: sharp, formats, slots}));
});

// WEBP
program
.command('webp', 'WebP texture compression')
Expand Down
1 change: 1 addition & 0 deletions packages/extensions/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const EXT_MESH_GPU_INSTANCING = 'EXT_mesh_gpu_instancing';
export const EXT_MESHOPT_COMPRESSION = 'EXT_meshopt_compression';
export const EXT_TEXTURE_WEBP = 'EXT_texture_webp';
export const EXT_TEXTURE_AVIF = 'EXT_texture_avif';
export const KHR_DRACO_MESH_COMPRESSION = 'KHR_draco_mesh_compression';
export const KHR_LIGHTS_PUNCTUAL = 'KHR_lights_punctual';
export const KHR_MATERIALS_CLEARCOAT = 'KHR_materials_clearcoat';
Expand Down
1 change: 1 addition & 0 deletions packages/extensions/src/ext-texture-avif/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './texture-avif';
171 changes: 171 additions & 0 deletions packages/extensions/src/ext-texture-avif/texture-avif.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
Extension,
ImageUtils,
ImageUtilsFormat,
PropertyType,
ReaderContext,
WriterContext,
vec2,
BufferUtils,
} from '@gltf-transform/core';
import { EXT_TEXTURE_AVIF } from '../constants';

const NAME = EXT_TEXTURE_AVIF;

class AVIFImageUtils implements ImageUtilsFormat {
match(array: Uint8Array): boolean {
return array.length >= 12 && BufferUtils.decodeText(array.slice(4, 12)) === 'ftypavif';
}
/**
* Probes size of AVIF or HEIC image. Assumes a single static image, without
* orientation or other metadata that would affect dimensions.
*/
getSize(array: Uint8Array): vec2 | null {
if (!this.match(array)) return null;

// References:
// - https://stackoverflow.com/questions/66222773/how-to-get-image-dimensions-from-an-avif-file
// - https://github.com/nodeca/probe-image-size/blob/master/lib/parse_sync/avif.js

const view = new DataView(array.buffer, array.byteOffset, array.byteLength);

let box = unbox(view, 0);
if (!box) return null;

let offset = box.end;
while ((box = unbox(view, offset))) {
if (box.type === 'meta') {
offset = box.start + 4; // version + flags
} else if (box.type === 'iprp' || box.type === 'ipco') {
offset = box.start;
} else if (box.type === 'ispe') {
return [view.getUint32(box.start + 4), view.getUint32(box.start + 8)];
} else if (box.type === 'mdat') {
break; // mdat should be last, unlikely to find metadata past here.
} else {
offset = box.end;
}
}

return null;
}
getChannels(_buffer: Uint8Array): number {
return 4;
}
}

/**
* # TextureAVIF
*
* [`EXT_texture_avif`](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/EXT_texture_avif/)
* enables AVIF images for any material texture.
*
* [[include:VENDOR_EXTENSIONS_NOTE.md]]
*
* AVIF offers greatly reduced transmission size, but
* [requires browser support](https://caniuse.com/avif). Like PNG and JPEG, an AVIF image is
* *fully decompressed* when uploaded to the GPU, which increases upload time and GPU memory cost.
* For seamless uploads and minimal GPU memory cost, it is necessary to use a GPU texture format
* like Basis Universal, with the `KHR_texture_basisu` extension.
*
* Defining no {@link ExtensionProperty} types, this {@link Extension} is simply attached to the
* {@link Document}, and affects the entire Document by allowing use of the `image/avif` MIME type
* and passing AVIF image data to the {@link Texture.setImage} method. Without the Extension, the
* same MIME types and image data would yield an invalid glTF document, under the stricter core glTF
* specification.
*
* Properties:
* - N/A
*
* ### Example
*
* ```typescript
* import { TextureAVIF } from '@gltf-transform/extensions';
*
* // Create an Extension attached to the Document.
* const avifExtension = document.createExtension(TextureAVIF)
* .setRequired(true);
* document.createTexture('MyAVIFTexture')
* .setMimeType('image/avif')
* .setImage(fs.readFileSync('my-texture.avif'));
* ```
*
* AVIF conversion is not done automatically when adding the extension as shown above — you must
* convert the image data first, then pass the `.avif` payload to {@link Texture.setImage}.
*
* When the `EXT_texture_avif` extension is added to a file by glTF-Transform, the extension should
* always be required. This tool does not support writing assets that "fall back" to optional PNG or
* JPEG image data.
*/
export class EXTTextureAVIF extends Extension {
public readonly extensionName = NAME;
/** @hidden */
public readonly prereadTypes = [PropertyType.TEXTURE];
public static readonly EXTENSION_NAME = NAME;

/** @hidden */
public static register(): void {
ImageUtils.registerFormat('image/avif', new AVIFImageUtils());
}

/** @hidden */
public preread(context: ReaderContext): this {
const textureDefs = context.jsonDoc.json.textures || [];
textureDefs.forEach((textureDef) => {
if (textureDef.extensions && textureDef.extensions[NAME]) {
textureDef.source = (textureDef.extensions[NAME] as { source: number }).source;
}
});
return this;
}

/** @hidden */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public read(context: ReaderContext): this {
return this;
}

/** @hidden */
public write(context: WriterContext): this {
const jsonDoc = context.jsonDoc;

this.document
.getRoot()
.listTextures()
.forEach((texture) => {
if (texture.getMimeType() === 'image/avif') {
const imageIndex = context.imageIndexMap.get(texture);
const textureDefs = jsonDoc.json.textures || [];
textureDefs.forEach((textureDef) => {
if (textureDef.source === imageIndex) {
textureDef.extensions = textureDef.extensions || {};
textureDef.extensions[NAME] = { source: textureDef.source };
delete textureDef.source;
}
});
}
});

return this;
}
}

interface IBox {
type: string;
start: number;
end: number;
}

function unbox(data: DataView, offset: number): IBox | null {
if (data.byteLength < 4 + offset) return null;

// size includes first 4 bytes (length)
const size = data.getUint32(offset);
if (data.byteLength < size + offset || size < 8) return null;

return {
type: BufferUtils.decodeText(new Uint8Array(data.buffer, data.byteOffset + offset + 4, 4)),
start: offset + 8,
end: offset + size,
};
}
4 changes: 2 additions & 2 deletions packages/extensions/src/ext-texture-webp/texture-webp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ class WEBPImageUtils implements ImageUtilsFormat {
*
* [[include:_VENDOR_EXTENSIONS.md]]
*
* WebP typically provides the minimal transmission
* size, but [requires browser support](https://caniuse.com/webp). Like PNG and JPEG, a WebP image is
* WebP offers greatly reduced transmission size, but
* [requires browser support](https://caniuse.com/webp). Like PNG and JPEG, a WebP image is
* *fully decompressed* when uploaded to the GPU, which increases upload time and GPU memory cost.
* For seamless uploads and minimal GPU memory cost, it is necessary to use a GPU texture format
* like Basis Universal, with the `KHR_texture_basisu` extension.
Expand Down
10 changes: 9 additions & 1 deletion packages/extensions/src/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { EXTMeshGPUInstancing } from './ext-mesh-gpu-instancing';
import { EXTMeshoptCompression } from './ext-meshopt-compression';
import { EXTTextureAVIF } from './ext-texture-avif';
import { EXTTextureWebP } from './ext-texture-webp';
import { KHRDracoMeshCompression } from './khr-draco-mesh-compression';
import { KHRLightsPunctual } from './khr-lights-punctual';
Expand Down Expand Up @@ -41,10 +42,17 @@ export const KHRONOS_EXTENSIONS = [
KHRXMP,
];

export const ALL_EXTENSIONS = [EXTMeshGPUInstancing, EXTMeshoptCompression, EXTTextureWebP, ...KHRONOS_EXTENSIONS];
export const ALL_EXTENSIONS = [
EXTMeshGPUInstancing,
EXTMeshoptCompression,
EXTTextureAVIF,
EXTTextureWebP,
...KHRONOS_EXTENSIONS,
];

export * from './ext-mesh-gpu-instancing';
export * from './ext-meshopt-compression';
export * from './ext-texture-avif';
export * from './ext-texture-webp';
export * from './khr-draco-mesh-compression';
export * from './khr-lights-punctual';
Expand Down
Binary file added packages/extensions/test/in/test.avif
Binary file not shown.
61 changes: 61 additions & 0 deletions packages/extensions/test/texture-avif.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import test from 'ava';
import { Document, GLTF, ImageUtils, JSONDocument, NodeIO } from '@gltf-transform/core';
import { EXTTextureAVIF } from '@gltf-transform/extensions';
import path, { dirname } from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';

const WRITER_OPTIONS = { basename: 'extensionTest' };

const io = new NodeIO().registerExtensions([EXTTextureAVIF]);
const __dirname = dirname(fileURLToPath(import.meta.url));

test('@gltf-transform/extensions::texture-avif', async (t) => {
const doc = new Document();
doc.createBuffer();
const avifExtension = doc.createExtension(EXTTextureAVIF);
const tex1 = doc.createTexture('AVIFTexture').setMimeType('image/avif').setImage(new Uint8Array(10));
const tex2 = doc.createTexture('PNGTexture').setMimeType('image/png').setImage(new Uint8Array(15));
doc.createMaterial().setBaseColorTexture(tex1).setEmissiveTexture(tex2);

let jsonDoc: JSONDocument;

jsonDoc = await io.writeJSON(doc, WRITER_OPTIONS);

// Writing to file.
t.deepEqual(jsonDoc.json.extensionsUsed, ['EXT_texture_avif'], 'writes extensionsUsed');
t.is(jsonDoc.json.textures[0].source, undefined, 'omits .source on AVIF texture');
t.is(jsonDoc.json.textures[1].source, 1, 'includes .source on PNG texture');
t.is(
(jsonDoc.json.textures[0].extensions['EXT_texture_avif'] as GLTF.ITexture).source,
0,
'includes .source on AVIF extension'
);

// Read (roundtrip) from file.
const rtDoc = await io.readJSON(jsonDoc);
const rtRoot = rtDoc.getRoot();
t.is(rtRoot.listTextures()[0].getMimeType(), 'image/avif', 'reads AVIF mimetype');
t.is(rtRoot.listTextures()[1].getMimeType(), 'image/png', 'reads PNG mimetype');
t.is(rtRoot.listTextures()[0].getImage().byteLength, 10, 'reads AVIF payload');
t.is(rtRoot.listTextures()[1].getImage().byteLength, 15, 'reads PNG payload');

// Clean up extension data, revert to core glTF.
avifExtension.dispose();
tex1.dispose();
jsonDoc = await io.writeJSON(doc, WRITER_OPTIONS);
t.is(jsonDoc.json.extensionsUsed, undefined, 'clears extensionsUsed');
t.is(jsonDoc.json.textures.length, 1, 'writes only 1 texture');
t.is(jsonDoc.json.textures[0].source, 0, 'includes .source on PNG texture');
});

test('@gltf-transform/core::image-utils | avif', (t) => {
const avif = fs.readFileSync(path.join(__dirname, 'in', 'test.avif'));
const buffer = new Uint8Array([0, 1, 2, 3]);

t.is(ImageUtils.getSize(new Uint8Array(8), 'image/avif'), null, 'invalid');
t.is(ImageUtils.getSize(buffer, 'image/avif'), null, 'no size');
t.deepEqual(ImageUtils.getSize(avif, 'image/avif'), [256, 256], 'size');
t.is(ImageUtils.getChannels(avif, 'image/avif'), 4, 'channels');
t.is(ImageUtils.getGPUByteLength(avif, 'image/avif'), 349524, 'gpuSize');
});
2 changes: 1 addition & 1 deletion packages/functions/src/texture-compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { TextureResizeFilter } from './texture-resize';
const NAME = 'textureCompress';

type Format = (typeof FORMATS)[number];
const FORMATS = ['jpeg', 'png', 'webp'] as const;
const FORMATS = ['jpeg', 'png', 'webp', 'avif'] as const;
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

export interface TextureCompressOptions {
Expand Down