usd-gltf 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readGLB, writeGLB } from './glb.js';
4
4
  import { decodeMeshoptView } from './meshopt.js';
5
+ import { Budget, checkSize } from '../limits.js';
5
6
 
6
7
  const COMPONENT_ARRAYS = {
7
8
  5120: Int8Array,
@@ -14,6 +15,43 @@ const COMPONENT_ARRAYS = {
14
15
 
15
16
  export const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
16
17
 
18
+ // Extensions an asset may require. Material extensions without a UsdPreviewSurface equivalent are
19
+ // approximated and kept on the USD material for the way back, so every KHR_materials_* is accepted.
20
+ const SUPPORTED_EXTENSIONS = new Set( [
21
+ 'EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'KHR_meshopt_compression', 'KHR_mesh_quantization',
22
+ 'EXT_texture_webp', 'EXT_texture_avif', 'KHR_texture_transform', 'KHR_animation_pointer', 'KHR_lights_punctual',
23
+ 'KHR_node_visibility', 'KHR_xmp_json_ld',
24
+ ] );
25
+
26
+ /**
27
+ * The asset's required extensions that can't be converted (e.g. KHR_draco_mesh_compression, KHR_texture_basisu).
28
+ * @param {object} json
29
+ * @returns {string[]}
30
+ */
31
+ export function unsupportedExtensions( json ) {
32
+
33
+ return ( json.extensionsRequired || [] ).filter( ( name ) => ! SUPPORTED_EXTENSIONS.has( name ) && ! name.startsWith( 'KHR_materials_' ) );
34
+
35
+ }
36
+
37
+ // Optional Draco needs uncompressed fallback data: without a bufferView or sparse values the
38
+ // accessors would read as zeros.
39
+ function checkDracoFallbacks( json ) {
40
+
41
+ ( json.meshes || [] ).forEach( ( mesh, m ) => mesh.primitives.forEach( ( primitive, p ) => {
42
+
43
+ if ( ! primitive.extensions?.KHR_draco_mesh_compression ) return;
44
+ const used = [ ...Object.values( primitive.attributes ), primitive.indices ].filter( ( a ) => a !== undefined );
45
+ if ( used.some( ( a ) => json.accessors[ a ].bufferView === undefined && ! json.accessors[ a ].sparse ) ) {
46
+
47
+ throw new Error( `usd-gltf: mesh ${ m } primitive ${ p } is Draco compressed with no uncompressed fallback; KHR_draco_mesh_compression is not supported` );
48
+
49
+ }
50
+
51
+ } ) );
52
+
53
+ }
54
+
17
55
  function decodeDataURI( uri ) {
18
56
 
19
57
  const comma = uri.indexOf( ',' );
@@ -38,11 +76,12 @@ function decodeDataURI( uri ) {
38
76
  */
39
77
  export class GLTFDocument {
40
78
 
41
- constructor( json, buffers, images ) {
79
+ constructor( json, buffers, images, budget = new Budget() ) {
42
80
 
43
81
  this.json = json;
44
82
  this.buffers = buffers; // Uint8Array per json.buffers entry
45
83
  this.images = images; // { bytes, mimeType, name } per json.images entry
84
+ this.budget = budget;
46
85
  this.accessorCache = new Map();
47
86
  this.decodedViews = new Map(); // bufferView index -> bytes, for compressed views
48
87
 
@@ -52,9 +91,11 @@ export class GLTFDocument {
52
91
 
53
92
  const decoded = this.decodedViews.get( index );
54
93
  if ( decoded ) return decoded;
55
- const view = this.json.bufferViews[ index ];
56
- const buffer = this.buffers[ view.buffer ];
57
- return buffer.subarray( view.byteOffset || 0, ( view.byteOffset || 0 ) + view.byteLength );
94
+ const view = this.json.bufferViews?.[ index ];
95
+ const buffer = view && this.buffers[ view.buffer ];
96
+ const start = view?.byteOffset || 0;
97
+ if ( ! buffer || ! ( start + view.byteLength <= buffer.length ) ) throw new Error( `usd-gltf: bufferView ${ index } is outside its buffer` );
98
+ return buffer.subarray( start, start + view.byteLength );
58
99
 
59
100
  }
60
101
 
@@ -68,11 +109,16 @@ export class GLTFDocument {
68
109
  const key = index + ( normalize ? 'n' : '' );
69
110
  if ( this.accessorCache.has( key ) ) return this.accessorCache.get( key );
70
111
 
71
- const accessor = this.json.accessors[ index ];
72
- const ArrayType = COMPONENT_ARRAYS[ accessor.componentType ];
73
- const itemSize = TYPE_SIZES[ accessor.type ];
112
+ const accessor = this.json.accessors?.[ index ];
113
+ const ArrayType = COMPONENT_ARRAYS[ accessor?.componentType ];
114
+ const itemSize = TYPE_SIZES[ accessor?.type ];
115
+ if ( ! ArrayType || ! itemSize ) throw new Error( `usd-gltf: accessor ${ index } is missing or has an invalid type` );
74
116
  const count = accessor.count;
75
- let array = new ArrayType( count * itemSize );
117
+ const stage = `accessor ${ index }`;
118
+ const size = count * itemSize * ArrayType.BYTES_PER_ELEMENT;
119
+ checkSize( size, stage );
120
+
121
+ let read = null;
76
122
 
77
123
  if ( accessor.bufferView !== undefined ) {
78
124
 
@@ -88,6 +134,17 @@ export class GLTFDocument {
88
134
  const columnStride = Math.ceil( rows * elementBytes / 4 ) * 4;
89
135
  const packed = columns === 1 ? itemSize * elementBytes : columnStride * columns;
90
136
  const stride = view.byteStride || packed;
137
+ if ( count > 0 && ! ( offset + stride * ( count - 1 ) + packed <= bytes.length ) ) throw new Error( `usd-gltf: ${ stage } reads past the end of its bufferView` );
138
+ read = { view, bytes, elementBytes, offset, columns, rows, columnStride, stride };
139
+
140
+ }
141
+
142
+ this.budget.decode( size, stage );
143
+ let array = new ArrayType( count * itemSize );
144
+
145
+ if ( read ) {
146
+
147
+ const { bytes, elementBytes, offset, columns, rows, columnStride, stride } = read;
91
148
 
92
149
  const dv = new DataView( bytes.buffer, bytes.byteOffset, bytes.byteLength );
93
150
  const getters = { 5120: 'getInt8', 5121: 'getUint8', 5122: 'getInt16', 5123: 'getUint16', 5125: 'getUint32', 5126: 'getFloat32' };
@@ -120,9 +177,16 @@ export class GLTFDocument {
120
177
  const IndexType = COMPONENT_ARRAYS[ indices.componentType ];
121
178
  const ib = this.bufferViewBytes( indices.bufferView );
122
179
  const vb = this.bufferViewBytes( values.bufferView );
180
+ if ( ! IndexType || ! Number.isSafeInteger( n ) || n < 0 || n > count ) throw new Error( `usd-gltf: ${ stage } has an invalid sparse count` );
181
+ if ( ! ( ( indices.byteOffset || 0 ) + n * IndexType.BYTES_PER_ELEMENT <= ib.length && ( values.byteOffset || 0 ) + n * itemSize * ArrayType.BYTES_PER_ELEMENT <= vb.length ) ) throw new Error( `usd-gltf: ${ stage } sparse data is outside its bufferViews` );
123
182
  const idx = new IndexType( ib.slice( indices.byteOffset || 0, ( indices.byteOffset || 0 ) + n * IndexType.BYTES_PER_ELEMENT ).buffer );
124
183
  const vals = new ArrayType( vb.slice( values.byteOffset || 0, ( values.byteOffset || 0 ) + n * itemSize * ArrayType.BYTES_PER_ELEMENT ).buffer );
125
- for ( let i = 0; i < n; i ++ ) for ( let c = 0; c < itemSize; c ++ ) array[ idx[ i ] * itemSize + c ] = vals[ i * itemSize + c ];
184
+ for ( let i = 0; i < n; i ++ ) {
185
+
186
+ if ( idx[ i ] >= count ) throw new Error( `usd-gltf: ${ stage } sparse index is out of range` );
187
+ for ( let c = 0; c < itemSize; c ++ ) array[ idx[ i ] * itemSize + c ] = vals[ i * itemSize + c ];
188
+
189
+ }
126
190
 
127
191
  }
128
192
 
@@ -130,6 +194,7 @@ export class GLTFDocument {
130
194
 
131
195
  if ( normalize && normalized ) {
132
196
 
197
+ this.budget.decode( array.length * 4, stage );
133
198
  const out = new Float32Array( array.length );
134
199
  const ct = accessor.componentType;
135
200
  for ( let i = 0; i < array.length; i ++ ) {
@@ -154,7 +219,9 @@ export class GLTFDocument {
154
219
  getFloats( index ) {
155
220
 
156
221
  const { array, itemSize, count } = this.getAccessor( index );
157
- return { array: array instanceof Float32Array ? array : Float32Array.from( array ), itemSize, count };
222
+ if ( array instanceof Float32Array ) return { array, itemSize, count };
223
+ this.budget.decode( array.length * 4, `accessor ${ index }` );
224
+ return { array: Float32Array.from( array ), itemSize, count };
158
225
 
159
226
  }
160
227
 
@@ -165,13 +232,18 @@ export class GLTFDocument {
165
232
  * @param {Uint8Array} bytes
166
233
  * @param {{ resolver?: ( uri: string ) => Promise<Uint8Array>|Uint8Array }} [options] loads external uris (already decoded from URI escaping)
167
234
  */
168
- export async function readGLTF( bytes, { resolver = null } = {} ) {
235
+ export async function readGLTF( bytes, { resolver = null, budget = new Budget() } = {} ) {
169
236
 
170
237
  let json, bin = null;
238
+ bytes = plainBytes( bytes );
171
239
 
172
240
  if ( bytes[ 0 ] === 0x67 && bytes[ 1 ] === 0x6C && bytes[ 2 ] === 0x54 && bytes[ 3 ] === 0x46 ) ( { json, bin } = readGLB( bytes ) );
173
241
  else json = JSON.parse( new TextDecoder().decode( bytes ).replace( /^/, '' ) );
174
242
 
243
+ const unsupported = unsupportedExtensions( json );
244
+ if ( unsupported.length ) throw new Error( `usd-gltf: required extension${ unsupported.length > 1 ? 's' : '' } ${ unsupported.join( ', ' ) } not supported` );
245
+ checkDracoFallbacks( json );
246
+
175
247
  async function load( uri ) {
176
248
 
177
249
  if ( uri.startsWith( 'data:' ) ) return decodeDataURI( uri ).bytes;
@@ -179,7 +251,7 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
179
251
  let path = uri;
180
252
  try { path = decodeURIComponent( uri ); } catch { /* keep as is */ }
181
253
  const data = await resolver( path );
182
- return data instanceof Uint8Array ? data : new Uint8Array( data );
254
+ return plainBytes( data );
183
255
 
184
256
  }
185
257
 
@@ -188,8 +260,9 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
188
260
  if ( buffer.uri === undefined ) {
189
261
 
190
262
  if ( i === 0 && bin ) return bin;
191
- // e.g. EXT_meshopt_compression fallback buffers have no data
192
- return new Uint8Array( buffer.byteLength );
263
+ // e.g. EXT_meshopt_compression fallback buffers: only read through decoded views, so they
264
+ // need no storage, and any other view into one fails its bounds check
265
+ return new Uint8Array( 0 );
193
266
 
194
267
  }
195
268
 
@@ -197,12 +270,16 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
197
270
 
198
271
  } ) );
199
272
 
200
- const doc = new GLTFDocument( json, buffers, [] );
273
+ const doc = new GLTFDocument( json, buffers, [], budget );
201
274
 
202
275
  ( json.bufferViews || [] ).forEach( ( view, i ) => {
203
276
 
204
277
  const ext = view.extensions?.KHR_meshopt_compression || view.extensions?.EXT_meshopt_compression;
205
- if ( ext ) doc.decodedViews.set( i, decodeMeshoptView( ext, buffers ) );
278
+ if ( ! ext ) return;
279
+ const start = ext.byteOffset || 0;
280
+ if ( ! buffers[ ext.buffer ] || ! ( start + ext.byteLength <= buffers[ ext.buffer ].length ) ) throw new Error( `usd-gltf: bufferView ${ i } compressed data is outside its buffer` );
281
+ budget.decode( ext.count * ext.byteStride, `bufferView ${ i }` );
282
+ doc.decodedViews.set( i, decodeMeshoptView( ext, buffers ) );
206
283
 
207
284
  } );
208
285
 
@@ -228,6 +305,14 @@ export async function readGLTF( bytes, { resolver = null } = {} ) {
228
305
 
229
306
  }
230
307
 
308
+ // A plain Uint8Array view: a Node Buffer's slice() aliases its memory instead of copying, so data
309
+ // read through .slice().buffer would be the wrong bytes.
310
+ function plainBytes( data ) {
311
+
312
+ return ArrayBuffer.isView( data ) ? new Uint8Array( data.buffer, data.byteOffset, data.byteLength ) : new Uint8Array( data );
313
+
314
+ }
315
+
231
316
  function guessMime( uri ) {
232
317
 
233
318
  const ext = uri.split( '?' )[ 0 ].split( '.' ).pop().toLowerCase();
@@ -240,8 +325,9 @@ function guessMime( uri ) {
240
325
  */
241
326
  export class GLTFBuilder {
242
327
 
243
- constructor( generator = 'usd-gltf' ) {
328
+ constructor( generator = 'usd-gltf', budget = new Budget() ) {
244
329
 
330
+ this.budget = budget;
245
331
  this.json = { asset: { version: '2.0', generator }, scenes: [], nodes: [], meshes: [], materials: [], textures: [], images: [], samplers: [], accessors: [], bufferViews: [], buffers: [] };
246
332
  this.chunks = [];
247
333
  this.byteLength = 0;
@@ -262,6 +348,7 @@ export class GLTFBuilder {
262
348
  const pad = ( 4 - ( this.byteLength % 4 ) ) % 4;
263
349
  if ( pad ) { this.chunks.push( new Uint8Array( pad ) ); this.byteLength += pad; }
264
350
 
351
+ this.budget.output( this.byteLength + bytes.byteLength, 'glb buffer' );
265
352
  const view = { buffer: 0, byteOffset: this.byteLength, byteLength: bytes.byteLength };
266
353
  if ( target ) view.target = target;
267
354
  this.chunks.push( bytes instanceof Uint8Array ? bytes : new Uint8Array( bytes.buffer, bytes.byteOffset, bytes.byteLength ) );
@@ -323,7 +410,9 @@ export class GLTFBuilder {
323
410
 
324
411
  toGLB() {
325
412
 
326
- const json = this.finalize();
413
+ const json = new TextEncoder().encode( JSON.stringify( this.finalize() ) );
414
+ const pad4 = ( n ) => Math.ceil( n / 4 ) * 4;
415
+ this.budget.output( 20 + pad4( json.byteLength ) + ( this.byteLength ? 8 + pad4( this.byteLength ) : 0 ), 'glb' );
327
416
  const bin = new Uint8Array( this.byteLength );
328
417
  let offset = 0;
329
418
  for ( const chunk of this.chunks ) {
package/src/gltf/glb.js CHANGED
@@ -68,13 +68,13 @@ export function readGLB( data ) {
68
68
 
69
69
  /**
70
70
  * Packs glTF JSON and an optional binary buffer into a GLB.
71
- * @param {object} json
71
+ * @param {object|Uint8Array} json the glTF JSON, or it already encoded as UTF-8
72
72
  * @param {Uint8Array|null} [bin]
73
73
  * @returns {Uint8Array}
74
74
  */
75
75
  export function writeGLB( json, bin = null ) {
76
76
 
77
- const jsonBytes = encoder.encode( JSON.stringify( json ) );
77
+ const jsonBytes = json instanceof Uint8Array ? json : encoder.encode( JSON.stringify( json ) );
78
78
  const jsonLength = pad4( jsonBytes.byteLength );
79
79
  const binLength = bin ? pad4( bin.byteLength ) : 0;
80
80
 
@@ -65,6 +65,54 @@ export function isGreyscaleImage( bytes ) {
65
65
 
66
66
  }
67
67
 
68
+ /**
69
+ * Width, height and bytes per pixel of a PNG, JPEG or WebP from its header, or null.
70
+ * @param {Uint8Array} bytes
71
+ * @returns {{ width: number, height: number, pixelBytes: number }|null}
72
+ */
73
+ export function imageSize( bytes ) {
74
+
75
+ bytes = toBytes( bytes );
76
+ const type = imageMimeType( bytes );
77
+ const be16 = ( o ) => ( bytes[ o ] << 8 ) | bytes[ o + 1 ];
78
+ const le16 = ( o ) => bytes[ o ] | ( bytes[ o + 1 ] << 8 );
79
+ const le24 = ( o ) => le16( o ) | ( bytes[ o + 2 ] << 16 );
80
+
81
+ if ( type === 'image/png' && bytes.length >= 26 ) {
82
+
83
+ const width = ( be16( 16 ) * 65536 ) + be16( 18 ), height = ( be16( 20 ) * 65536 ) + be16( 22 );
84
+ return { width, height, pixelBytes: bytes[ 24 ] === 16 ? 8 : 4 };
85
+
86
+ }
87
+
88
+ if ( type === 'image/jpeg' ) {
89
+
90
+ for ( let o = 2; o + 9 < bytes.length; ) {
91
+
92
+ if ( bytes[ o ] !== 0xFF ) return null;
93
+ const marker = bytes[ o + 1 ];
94
+ if ( marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC ) return { width: be16( o + 7 ), height: be16( o + 5 ), pixelBytes: 4 };
95
+ o += 2 + be16( o + 2 );
96
+
97
+ }
98
+
99
+ return null;
100
+
101
+ }
102
+
103
+ if ( type === 'image/webp' && bytes.length >= 30 ) {
104
+
105
+ const chunk = String.fromCharCode( bytes[ 12 ], bytes[ 13 ], bytes[ 14 ], bytes[ 15 ] );
106
+ if ( chunk === 'VP8X' ) return { width: le24( 24 ) + 1, height: le24( 27 ) + 1, pixelBytes: 4 };
107
+ if ( chunk === 'VP8 ' ) return { width: le16( 26 ) & 0x3FFF, height: le16( 28 ) & 0x3FFF, pixelBytes: 4 };
108
+ if ( chunk === 'VP8L' ) { const b = bytes[ 21 ] | ( bytes[ 22 ] << 8 ) | ( bytes[ 23 ] << 16 ) | ( bytes[ 24 ] << 24 ); return { width: ( b & 0x3FFF ) + 1, height: ( ( b >>> 14 ) & 0x3FFF ) + 1, pixelBytes: 4 }; }
109
+
110
+ }
111
+
112
+ return null;
113
+
114
+ }
115
+
68
116
  /**
69
117
  * Decodes a PNG, JPEG or WebP. The format is sniffed from the bytes; mimeType is only a fallback.
70
118
  * @param {Uint8Array|ArrayBuffer} bytes
@@ -141,10 +189,13 @@ export async function encodePNGAsync( image ) {
141
189
  * @param {Uint8Array} bytes
142
190
  * @param {string} [mimeType]
143
191
  * @param {( bytes: Uint8Array, mimeType?: string ) => Promise<object>|object} [decoder] overrides both
192
+ * @param {import('../limits.js').Budget} [budget] charged with the decoded size, from the header, before decoding
144
193
  */
145
- export async function decodeImageAsync( bytes, mimeType, decoder = null ) {
194
+ export async function decodeImageAsync( bytes, mimeType, decoder = null, budget = null ) {
146
195
 
147
196
  bytes = toBytes( bytes );
197
+ const size = budget && imageSize( bytes );
198
+ if ( size ) budget.decode( size.width * size.height * size.pixelBytes, 'image' );
148
199
  if ( decoder ) return decoder( bytes, mimeType );
149
200
  const type = imageMimeType( bytes ) || mimeType;
150
201
 
@@ -137,12 +137,12 @@ function corrupt( what ) {
137
137
  }
138
138
 
139
139
  // Inflates the raw DEFLATE stream starting at src[ pos ]. Returns the output and the byte offset just past the stream.
140
- function inflateStream( src, pos, sizeHint ) {
140
+ function inflateStream( src, pos, sizeHint, maxSize ) {
141
141
 
142
142
  const srcLen = src.length;
143
143
  const overrun = srcLen + 8;
144
144
 
145
- let out = new Uint8Array( sizeHint > 0 ? sizeHint : Math.max( 1 << 15, srcLen * 4 ) );
145
+ let out = new Uint8Array( Math.min( sizeHint > 0 ? sizeHint : Math.max( 1 << 15, srcLen * 4 ), maxSize ) );
146
146
  let op = 0;
147
147
 
148
148
  let bitBuf = 0;
@@ -154,7 +154,7 @@ function inflateStream( src, pos, sizeHint ) {
154
154
  let clTable = null;
155
155
 
156
156
  let final = 0;
157
- const state = { src, overrun, pos, bitBuf, bitCnt, out, op };
157
+ const state = { src, overrun, pos, bitBuf, bitCnt, out, op, maxSize };
158
158
 
159
159
  do {
160
160
 
@@ -188,7 +188,7 @@ function inflateStream( src, pos, sizeHint ) {
188
188
  pos += 4;
189
189
  if ( pos + len > srcLen ) throw corrupt( 'unexpected end' );
190
190
 
191
- if ( op + len > out.length ) out = grow( out, op + len );
191
+ if ( op + len > out.length ) out = grow( out, op + len, maxSize );
192
192
  out.set( src.subarray( pos, pos + len ), op );
193
193
  op += len;
194
194
  pos += len;
@@ -371,7 +371,7 @@ function decodeSymbols( state, litTable, litMask, distTable, distMask ) {
371
371
 
372
372
  if ( op === outLen ) {
373
373
 
374
- out = grow( out, op + 1 );
374
+ out = grow( out, op + 1, state.maxSize );
375
375
  outLen = out.length;
376
376
 
377
377
  }
@@ -435,7 +435,7 @@ function decodeSymbols( state, litTable, litMask, distTable, distMask ) {
435
435
 
436
436
  if ( op + len > outLen ) {
437
437
 
438
- out = grow( out, op + len );
438
+ out = grow( out, op + len, state.maxSize );
439
439
  outLen = out.length;
440
440
 
441
441
  }
@@ -464,9 +464,10 @@ function decodeSymbols( state, litTable, litMask, distTable, distMask ) {
464
464
 
465
465
  }
466
466
 
467
- function grow( out, need ) {
467
+ function grow( out, need, maxSize ) {
468
468
 
469
- const next = new Uint8Array( Math.max( need, out.length * 2 ) );
469
+ if ( need > maxSize ) throw corrupt( `more than the expected ${ maxSize } bytes` );
470
+ const next = new Uint8Array( Math.min( Math.max( need, out.length * 2 ), maxSize ) );
470
471
  next.set( out );
471
472
  return next;
472
473
 
@@ -482,11 +483,12 @@ function toBytes( data ) {
482
483
  * Decompresses raw DEFLATE data.
483
484
  * @param {Uint8Array} bytes
484
485
  * @param {number} [sizeHint] expected output size, avoids reallocation when known
486
+ * @param {number} [maxSize] fails rather than produce more than this
485
487
  * @returns {Uint8Array}
486
488
  */
487
- export function inflate( bytes, sizeHint = 0 ) {
489
+ export function inflate( bytes, sizeHint = 0, maxSize = Infinity ) {
488
490
 
489
- return inflateStream( toBytes( bytes ), 0, sizeHint ).data;
491
+ return inflateStream( toBytes( bytes ), 0, sizeHint, maxSize ).data;
490
492
 
491
493
  }
492
494
 
@@ -496,7 +498,7 @@ export function inflate( bytes, sizeHint = 0 ) {
496
498
  * @param {number} [sizeHint]
497
499
  * @returns {Uint8Array}
498
500
  */
499
- export function inflateZlib( bytes, sizeHint = 0 ) {
501
+ export function inflateZlib( bytes, sizeHint = 0, maxSize = Infinity ) {
500
502
 
501
503
  bytes = toBytes( bytes );
502
504
  if ( bytes.length < 2 ) throw corrupt( 'missing zlib header' );
@@ -506,7 +508,7 @@ export function inflateZlib( bytes, sizeHint = 0 ) {
506
508
  if ( ( cmf & 15 ) !== 8 || ( cmf >> 4 ) > 7 || ( ( cmf << 8 ) | flg ) % 31 !== 0 ) throw corrupt( 'bad zlib header' );
507
509
  if ( flg & 32 ) throw corrupt( 'zlib preset dictionary not supported' );
508
510
 
509
- const { data, end } = inflateStream( bytes, 2, sizeHint );
511
+ const { data, end } = inflateStream( bytes, 2, sizeHint, maxSize );
510
512
 
511
513
  if ( end + 4 <= bytes.length ) {
512
514
 
package/src/image/png.js CHANGED
@@ -219,7 +219,7 @@ export function decodePNG( bytes ) {
219
219
 
220
220
  }
221
221
 
222
- const raw = inflateZlib( zdata, expected );
222
+ const raw = inflateZlib( zdata, expected, expected * 2 + 1024 ); // tolerates trailing data, not a decompression bomb
223
223
  if ( raw.length < expected ) throw new Error( 'usd-gltf: PNG image data is truncated' );
224
224
 
225
225
  const channels = colorType === 3 ? ( trns !== null ? 4 : 3 ) : samples + ( trns !== null ? 1 : 0 );
package/src/index.d.ts CHANGED
@@ -13,7 +13,28 @@ export interface DecodedImage {
13
13
  /** Replaces the built in image decoding (the platform decoder in browsers, JavaScript decoders in Node). */
14
14
  export type ImageDecoder = ( bytes: Uint8Array, mimeType?: string ) => DecodedImage | Promise<DecodedImage>;
15
15
 
16
- export interface USDToGLBOptions {
16
+ /** Opt-in resource limits, for both directions. See README "Limits". */
17
+ export interface ConversionLimits {
18
+ /** Decoded data held during the conversion: accessors, USD arrays, decompressed data, images, arrays written to the output layer. */
19
+ maxDecodedBytes?: number;
20
+ /** Largest output buffer: checked as writers grow and before the final usdz or glb is allocated. */
21
+ maxOutputBytes?: number;
22
+ /** Cancels the conversion, which then rejects with the signal's reason. */
23
+ signal?: AbortSignal;
24
+ }
25
+
26
+ /** Thrown when a conversion would go over one of its limits. Carries sizes only, never input data or file names. */
27
+ export class ConversionLimitError extends Error {
28
+ name: 'ConversionLimitError';
29
+ code: 'USD_GLTF_LIMIT';
30
+ /** Where the limit was reached, e.g. 'accessor 3', 'usdc', 'image', 'glb'. */
31
+ stage: string;
32
+ resource: 'decoded' | 'output';
33
+ limit: number;
34
+ attempted: number;
35
+ }
36
+
37
+ export interface USDToGLBOptions extends ConversionLimits {
17
38
  /** Name of the input file, used to resolve relative paths. */
18
39
  identifier?: string;
19
40
  /** Needed when the USD references files outside a usdz package. */
@@ -27,7 +48,7 @@ export interface USDToGLBOptions {
27
48
  log?: ( message: string ) => void;
28
49
  }
29
50
 
30
- export interface GLBToUSDOptions {
51
+ export interface GLBToUSDOptions extends ConversionLimits {
31
52
  /** Output encoding: a usdz package (default), or a single usda / usdc layer without its textures. */
32
53
  format?: 'usdz' | 'usda' | 'usdc';
33
54
  /** Needed for .gltf files with external buffers or images. */
@@ -51,8 +72,8 @@ export function detectFormat( data: Uint8Array | ArrayBuffer ): 'glb' | 'gltf' |
51
72
  /** Splits a GLB into its JSON and binary chunk. */
52
73
  export function readGLB( data: Uint8Array | ArrayBuffer ): { json: any, bin: Uint8Array | null };
53
74
 
54
- /** Packs glTF JSON and a binary buffer into a GLB. */
55
- export function writeGLB( json: object, bin?: Uint8Array | null ): Uint8Array;
75
+ /** Packs glTF JSON (an object, or already UTF-8 encoded) and a binary buffer into a GLB. */
76
+ export function writeGLB( json: object | Uint8Array, bin?: Uint8Array | null ): Uint8Array;
56
77
 
57
78
  /** The uncomposed contents of one USD layer; see src/usd/model.js for the value representation. */
58
79
  export interface Layer {
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ import { readGLTF } from './gltf/document.js';
5
5
  import { writeUSDA } from './usd/usda/writer.js';
6
6
  import { writeUSDC } from './usd/usdc/writer.js';
7
7
  import { writeUSDZ } from './usd/usdz.js';
8
+ import { Budget } from './limits.js';
8
9
 
9
10
  export { readGLB, writeGLB } from './gltf/glb.js';
10
11
  export { detectFormat } from './detect.js';
@@ -13,6 +14,7 @@ export { readUSDA } from './usd/usda/reader.js';
13
14
  export { writeUSDA } from './usd/usda/writer.js';
14
15
  export { readUSDC } from './usd/usdc/reader.js';
15
16
  export { writeUSDC } from './usd/usdc/writer.js';
17
+ export { ConversionLimitError } from './limits.js';
16
18
 
17
19
  function toBytes( data ) {
18
20
 
@@ -20,37 +22,50 @@ function toBytes( data ) {
20
22
 
21
23
  }
22
24
 
25
+ // Limits shared by both directions; see README "Limits".
26
+ function budgetFor( { maxDecodedBytes, maxOutputBytes, signal } ) {
27
+
28
+ return new Budget( { maxDecodedBytes, maxOutputBytes, signal } );
29
+
30
+ }
31
+
23
32
  /**
24
33
  * Converts USD (usda, usdc or usdz, detected from the bytes) to GLB.
25
34
  * @param {ArrayBuffer|Uint8Array} data
26
- * @param {{ identifier?: string, resolver?: ( path: string ) => Promise<Uint8Array>, variants?: object, log?: ( message: string ) => void }} [options]
35
+ * @param {{ identifier?: string, resolver?: ( path: string ) => Promise<Uint8Array>, variants?: object, log?: ( message: string ) => void, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
27
36
  * resolver loads files referenced by relative paths (not needed for self-contained usdz)
28
37
  * @returns {Promise<Uint8Array>}
29
38
  */
30
39
  export async function usdToGLB( data, options = {} ) {
31
40
 
32
- const stage = await openStage( toBytes( data ), options );
33
- const builder = await new USDToGLTF( stage, options ).convert();
34
- return builder.toGLB();
41
+ const budget = budgetFor( options );
42
+ const stage = await openStage( toBytes( data ), { ...options, budget } );
43
+ const builder = await new USDToGLTF( stage, { ...options, budget } ).convert();
44
+ const glb = builder.toGLB();
45
+ budget.check();
46
+ return glb;
35
47
 
36
48
  }
37
49
 
38
50
  /**
39
51
  * Converts GLB (or glTF JSON with embedded or resolvable buffers) to USD.
40
52
  * @param {ArrayBuffer|Uint8Array} data
41
- * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array> }} [options]
53
+ * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array>, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
42
54
  * @returns {Promise<Uint8Array>}
43
55
  */
44
- export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder } = {} ) {
56
+ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder, ...limits } = {} ) {
45
57
 
46
- const doc = await readGLTF( toBytes( data ), { resolver } );
58
+ const budget = budgetFor( limits );
59
+ const doc = await readGLTF( toBytes( data ), { resolver, budget } );
47
60
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
61
+ let out;
48
62
 
49
- if ( format === 'usda' ) return new TextEncoder().encode( writeUSDA( layer ) );
50
- if ( format === 'usdc' ) return writeUSDC( layer );
63
+ if ( format === 'usda' ) out = new TextEncoder().encode( writeUSDA( layer, { budget } ) );
64
+ else if ( format === 'usdc' ) out = writeUSDC( layer, { budget } );
65
+ else out = writeUSDZ( [ { name: 'root.usdc', data: writeUSDC( layer, { budget } ) }, ...files ], { budget } ); // the root layer must come first
51
66
 
52
- // the root layer must be the first file in the package
53
- return writeUSDZ( [ { name: 'root.usdc', data: writeUSDC( layer ) }, ...files ] );
67
+ budget.check();
68
+ return out;
54
69
 
55
70
  }
56
71
 
@@ -58,10 +73,14 @@ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, i
58
73
  * For usda / usdc output the textures are separate files; this returns them alongside the layer.
59
74
  * @returns {Promise<{ layer: Uint8Array, files: Array<{ name: string, data: Uint8Array }> }>}
60
75
  */
61
- export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder } = {} ) {
76
+ export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder, ...limits } = {} ) {
62
77
 
63
- const doc = await readGLTF( toBytes( data ), { resolver } );
78
+ const budget = budgetFor( limits );
79
+ const doc = await readGLTF( toBytes( data ), { resolver, budget } );
64
80
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
65
- return { layer: format === 'usdc' ? writeUSDC( layer ) : new TextEncoder().encode( writeUSDA( layer ) ), files };
81
+ const bytes = format === 'usdc' ? writeUSDC( layer, { budget } ) : new TextEncoder().encode( writeUSDA( layer, { budget } ) );
82
+ budget.output( files.reduce( ( n, f ) => n + f.data.byteLength, bytes.byteLength ), 'usd files' );
83
+ budget.check();
84
+ return { layer: bytes, files };
66
85
 
67
86
  }