-
-
Notifications
You must be signed in to change notification settings - Fork 151
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[extensions] Add EXT_texture_avif (#771)
- Loading branch information
1 parent
fb97455
commit 8062f29
Showing
9 changed files
with
269 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from './texture-avif'; |
171 changes: 171 additions & 0 deletions
171
packages/extensions/src/ext-texture-avif/texture-avif.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters