diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e6870340a..1ac6a3d26 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -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_DESC) + .argument('', OUTPUT_DESC) + .option( + '--formats ', + 'Texture formats to include (glob)', + {validator: ['image/png', 'image/jpeg', '*'], default: '*'} + ) + .option( + '--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') diff --git a/packages/extensions/src/constants.ts b/packages/extensions/src/constants.ts index 9ebd2debc..33710bee2 100644 --- a/packages/extensions/src/constants.ts +++ b/packages/extensions/src/constants.ts @@ -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'; diff --git a/packages/extensions/src/ext-texture-avif/index.ts b/packages/extensions/src/ext-texture-avif/index.ts new file mode 100644 index 000000000..88ad7bee9 --- /dev/null +++ b/packages/extensions/src/ext-texture-avif/index.ts @@ -0,0 +1 @@ +export * from './texture-avif'; diff --git a/packages/extensions/src/ext-texture-avif/texture-avif.ts b/packages/extensions/src/ext-texture-avif/texture-avif.ts new file mode 100644 index 000000000..cceaf8fdc --- /dev/null +++ b/packages/extensions/src/ext-texture-avif/texture-avif.ts @@ -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, + }; +} diff --git a/packages/extensions/src/ext-texture-webp/texture-webp.ts b/packages/extensions/src/ext-texture-webp/texture-webp.ts index 9b32bbdc2..76714a736 100644 --- a/packages/extensions/src/ext-texture-webp/texture-webp.ts +++ b/packages/extensions/src/ext-texture-webp/texture-webp.ts @@ -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. diff --git a/packages/extensions/src/extensions.ts b/packages/extensions/src/extensions.ts index 552088a87..2e8b63ced 100644 --- a/packages/extensions/src/extensions.ts +++ b/packages/extensions/src/extensions.ts @@ -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'; @@ -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'; diff --git a/packages/extensions/test/in/test.avif b/packages/extensions/test/in/test.avif new file mode 100644 index 000000000..b10da706f Binary files /dev/null and b/packages/extensions/test/in/test.avif differ diff --git a/packages/extensions/test/texture-avif.test.ts b/packages/extensions/test/texture-avif.test.ts new file mode 100644 index 000000000..7358f7840 --- /dev/null +++ b/packages/extensions/test/texture-avif.test.ts @@ -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'); +}); diff --git a/packages/functions/src/texture-compress.ts b/packages/functions/src/texture-compress.ts index c48d61bd3..835831d7b 100644 --- a/packages/functions/src/texture-compress.ts +++ b/packages/functions/src/texture-compress.ts @@ -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 {