usd-gltf 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -37,7 +37,20 @@ USD to glTF composes the stage first (sublayers, references, payloads, inherits,
37
37
 
38
38
  glTF to USD writes `UsdPreviewSurface` materials, UsdSkel skins and blend shapes, time sampled animation, variant sets for material variants and multiple animations, cameras and lights. glTF features that `UsdPreviewSurface` can't express (transmission, sheen, iridescence, ...) are kept in the material's `customData`, so converting back to glTF is lossless. Primitives of a mesh that share their vertex data become one USD Mesh with a GeomSubset per primitive, and each Mesh keeps only the vertices it uses, so vertex data is never repeated per primitive.
39
39
 
40
- glTF compression: meshopt (`EXT_meshopt_compression`, `KHR_meshopt_compression`) and `KHR_mesh_quantization` are decoded. Draco (`KHR_draco_mesh_compression`) and KTX2 textures (`KHR_texture_basisu`) are not: when an asset lists them in `extensionsRequired` the conversion fails with an error naming them, and when they are optional the uncompressed fallback data is used.
40
+ glTF compression: meshopt (`EXT_meshopt_compression`, `KHR_meshopt_compression`), `KHR_mesh_quantization` and Draco (`KHR_draco_mesh_compression`, see below) are decoded. KTX2 textures (`KHR_texture_basisu`) are not: an asset that requires them fails with an error naming the extension, and when they are optional the PNG or JPEG fallback is used.
41
+
42
+ ### Draco
43
+
44
+ usd-gltf doesn't bundle a Draco decoder; it loads Google's only when a file is Draco compressed, and uses the uncompressed fallback without loading anything when a file has one.
45
+
46
+ - **Node:** install [`draco3d`](https://www.npmjs.com/package/draco3d) alongside, an optional peer dependency, and Draco files just work: `npm install draco3d`.
47
+ - **Browsers:** the decoder (about 250 KB) is fetched from Google's CDN, `www.gstatic.com/draco/versioned/decoders/1.5.7/`, pinned to that version and checked against its SHA-256 before it runs. This needs a secure context, and a Content Security Policy that allows the fetch, `unsafe-eval` and WebAssembly.
48
+ - **Your own:** pass a decoder module as `dracoDecoder` to use a self hosted copy, or where the CDN isn't allowed:
49
+
50
+ ```js
51
+ import draco3d from 'draco3d';
52
+ const usdz = await glbToUSD( glb, { dracoDecoder: await draco3d.createDecoderModule() } );
53
+ ```
41
54
 
42
55
  ## Limits
43
56
 
@@ -62,7 +75,7 @@ Going over a limit rejects with a `ConversionLimitError` whose `stage`, `resourc
62
75
  Current status:
63
76
 
64
77
  - All 18 USD files in Apple's AR Quick Look gallery convert to GLB. Per mesh triangle counts and world space bounds at the start, middle and end of each animation match OpenUSD with skinning baked, and the Khronos glTF Validator reports no errors. (The gallery's three `.reality` files are RealityKit's format, not USD.)
65
- - All 150 Khronos glTF sample models convert to USDZ that OpenUSD's validators accept with no errors or warnings, with geometry matching the source at several animation times, and convert back to glTF with identical materials, material variants and animations. Each model is checked as `.gltf`, and also as `.glb` (120 models) and embedded `.gltf` (32 models) where the sample set has those versions: 302 conversions in all.
78
+ - All 150 Khronos glTF sample models convert to USDZ that OpenUSD's validators accept with no errors or warnings, with geometry matching the source at several animation times, and convert back to glTF with identical materials, material variants and animations. Each model is checked as `.gltf`, and also as `.glb` (120 models), embedded `.gltf` (32 models) and Draco compressed (16 models) where the sample set has those versions: 318 conversions in all.
66
79
 
67
80
  The acceptance corpora are every model in [Apple's AR Quick Look gallery](https://developer.apple.com/augmented-reality/quick-look/) and every model in the [Khronos glTF Sample Assets](https://github.com/KhronosGroup/glTF-Sample-Assets). Conversions are checked against Pixar's OpenUSD (`usd-core`) and the Khronos glTF Validator: per mesh triangle counts and world space bounds at several animation times, with skinning baked by OpenUSD, must match.
68
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usd-gltf",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Convert between USD (usda, usdc, usdz) and glTF / GLB, pure JavaScript with no dependencies",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -35,7 +35,8 @@
35
35
  "gltf",
36
36
  "glb",
37
37
  "converter",
38
- "3d"
38
+ "3d",
39
+ "draco"
39
40
  ],
40
41
  "author": "Richard Monette",
41
42
  "license": "MIT",
@@ -47,7 +48,16 @@
47
48
  "url": "https://github.com/richardmonette/usd-gltf/issues"
48
49
  },
49
50
  "homepage": "https://github.com/richardmonette/usd-gltf#readme",
51
+ "peerDependencies": {
52
+ "draco3d": "^1.5.7"
53
+ },
54
+ "peerDependenciesMeta": {
55
+ "draco3d": {
56
+ "optional": true
57
+ }
58
+ },
50
59
  "devDependencies": {
60
+ "draco3d": "^1.5.7",
51
61
  "gltf-validator": "^2.0.0-dev.3.10",
52
62
  "meshoptimizer": "^1.3.0"
53
63
  },
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readGLB, writeGLB } from './glb.js';
4
4
  import { decodeMeshoptView } from './meshopt.js';
5
+ import { decodeDracoPrimitive, loadDracoDecoder, needsDraco } from './draco.js';
5
6
  import { Budget, checkSize } from '../limits.js';
6
7
 
7
8
  const COMPONENT_ARRAYS = {
@@ -18,13 +19,13 @@ export const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3:
18
19
  // Extensions an asset may require. Material extensions without a UsdPreviewSurface equivalent are
19
20
  // approximated and kept on the USD material for the way back, so every KHR_materials_* is accepted.
20
21
  const SUPPORTED_EXTENSIONS = new Set( [
21
- 'EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'KHR_meshopt_compression', 'KHR_mesh_quantization',
22
+ 'EXT_mesh_gpu_instancing', 'EXT_meshopt_compression', 'KHR_meshopt_compression', 'KHR_mesh_quantization', 'KHR_draco_mesh_compression',
22
23
  'EXT_texture_webp', 'EXT_texture_avif', 'KHR_texture_transform', 'KHR_animation_pointer', 'KHR_lights_punctual',
23
24
  'KHR_node_visibility', 'KHR_xmp_json_ld',
24
25
  ] );
25
26
 
26
27
  /**
27
- * The asset's required extensions that can't be converted (e.g. KHR_draco_mesh_compression, KHR_texture_basisu).
28
+ * The asset's required extensions that can't be converted (e.g. KHR_texture_basisu).
28
29
  * @param {object} json
29
30
  * @returns {string[]}
30
31
  */
@@ -34,24 +35,6 @@ export function unsupportedExtensions( json ) {
34
35
 
35
36
  }
36
37
 
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
-
55
38
  function decodeDataURI( uri ) {
56
39
 
57
40
  const comma = uri.indexOf( ',' );
@@ -84,6 +67,7 @@ export class GLTFDocument {
84
67
  this.budget = budget;
85
68
  this.accessorCache = new Map();
86
69
  this.decodedViews = new Map(); // bufferView index -> bytes, for compressed views
70
+ this.decodedAccessors = new Map(); // accessor index -> data, for Draco compressed primitives
87
71
 
88
72
  }
89
73
 
@@ -139,8 +123,9 @@ export class GLTFDocument {
139
123
 
140
124
  }
141
125
 
142
- this.budget.decode( size, stage );
143
- let array = new ArrayType( count * itemSize );
126
+ const decoded = accessor.bufferView === undefined ? this.decodedAccessors.get( index ) : undefined; // charged when decoded
127
+ if ( ! decoded ) this.budget.decode( size, stage );
128
+ let array = decoded || new ArrayType( count * itemSize );
144
129
 
145
130
  if ( read ) {
146
131
 
@@ -230,9 +215,9 @@ export class GLTFDocument {
230
215
  /**
231
216
  * Loads a glTF or GLB.
232
217
  * @param {Uint8Array} bytes
233
- * @param {{ resolver?: ( uri: string ) => Promise<Uint8Array>|Uint8Array }} [options] loads external uris (already decoded from URI escaping)
218
+ * @param {{ resolver?: ( uri: string ) => Promise<Uint8Array>|Uint8Array, dracoDecoder?: object|Promise<object> }} [options] resolver loads external uris (already decoded from URI escaping); dracoDecoder is a Draco decoder module, loaded when needed if not given
234
219
  */
235
- export async function readGLTF( bytes, { resolver = null, budget = new Budget() } = {} ) {
220
+ export async function readGLTF( bytes, { resolver = null, budget = new Budget(), dracoDecoder = null } = {} ) {
236
221
 
237
222
  let json, bin = null;
238
223
  bytes = plainBytes( bytes );
@@ -242,7 +227,6 @@ export async function readGLTF( bytes, { resolver = null, budget = new Budget()
242
227
 
243
228
  const unsupported = unsupportedExtensions( json );
244
229
  if ( unsupported.length ) throw new Error( `usd-gltf: required extension${ unsupported.length > 1 ? 's' : '' } ${ unsupported.join( ', ' ) } not supported` );
245
- checkDracoFallbacks( json );
246
230
 
247
231
  async function load( uri ) {
248
232
 
@@ -283,6 +267,23 @@ export async function readGLTF( bytes, { resolver = null, budget = new Budget()
283
267
 
284
268
  } );
285
269
 
270
+ // Draco primitives without uncompressed fallback data
271
+ const draco = ( json.meshes || [] ).flatMap( ( mesh ) => mesh.primitives ).filter( ( p ) => needsDraco( json, p ) );
272
+
273
+ if ( draco.length ) {
274
+
275
+ const decoder = await ( dracoDecoder || loadDracoDecoder() );
276
+
277
+ for ( const primitive of draco ) {
278
+
279
+ budget.check();
280
+ const bytes = doc.bufferViewBytes( primitive.extensions.KHR_draco_mesh_compression.bufferView );
281
+ for ( const [ index, data ] of decodeDracoPrimitive( decoder, bytes, json, primitive, budget ) ) doc.decodedAccessors.set( index, data );
282
+
283
+ }
284
+
285
+ }
286
+
286
287
  doc.images = await Promise.all( ( json.images || [] ).map( async ( image ) => {
287
288
 
288
289
  if ( image.bufferView !== undefined ) return { bytes: doc.bufferViewBytes( image.bufferView ), mimeType: image.mimeType, name: image.name, uri: null };
@@ -0,0 +1,191 @@
1
+ // KHR_draco_mesh_compression, decoded with Google's Draco decoder, which usd-gltf doesn't bundle.
2
+ // The decoder is the dracoDecoder option when given; otherwise the draco3d package in Node (an
3
+ // optional peer dependency), or in browsers Google's CDN build, pinned to one version and checked
4
+ // against its hash before it runs. Only loaded when a file needs it.
5
+ // https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_draco_mesh_compression
6
+
7
+ const CDN = 'https://www.gstatic.com/draco/versioned/decoders/1.5.7/';
8
+ const CDN_FILES = {
9
+ 'draco_wasm_wrapper_gltf.js': 'i7KVLSun1n4UFPjfgZQQywQ0pma+U/Zx//dfaIQ9dvY=',
10
+ 'draco_decoder_gltf.wasm': 'cS2zRJriBB1uiiJMOVvabO20nlEyL644t9ub64s4GIk=',
11
+ };
12
+
13
+ const DATA_TYPES = { 5120: 'DT_INT8', 5121: 'DT_UINT8', 5122: 'DT_INT16', 5123: 'DT_UINT16', 5125: 'DT_UINT32', 5126: 'DT_FLOAT32' };
14
+ const COMPONENT_ARRAYS = { 5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array };
15
+ const TYPE_SIZES = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
16
+
17
+ let defaultDecoder = null;
18
+
19
+ /**
20
+ * The default Draco decoder module: draco3d in Node, Google's CDN in browsers. Loaded once.
21
+ * @returns {Promise<object>}
22
+ */
23
+ export function loadDracoDecoder() {
24
+
25
+ defaultDecoder ||= ( isNode() ? loadNode() : loadDracoFromCDN() ).catch( ( e ) => {
26
+
27
+ defaultDecoder = null; // let a later conversion try again
28
+ throw e;
29
+
30
+ } );
31
+
32
+ return defaultDecoder;
33
+
34
+ }
35
+
36
+ function isNode() {
37
+
38
+ return typeof process !== 'undefined' && !! process.versions?.node;
39
+
40
+ }
41
+
42
+ async function loadNode() {
43
+
44
+ const name = 'draco3d'; // not a literal, so bundlers don't try to include it
45
+ let draco3d;
46
+
47
+ try {
48
+
49
+ draco3d = await import( /* webpackIgnore: true */ /* @vite-ignore */ name );
50
+
51
+ } catch {
52
+
53
+ throw new Error( 'usd-gltf: this glTF is Draco compressed; install the decoder with npm install draco3d, or pass a dracoDecoder' );
54
+
55
+ }
56
+
57
+ return ( draco3d.default ?? draco3d ).createDecoderModule();
58
+
59
+ }
60
+
61
+ // Exported for tests; loadDracoDecoder picks the source.
62
+ export async function loadDracoFromCDN() {
63
+
64
+ if ( ! globalThis.crypto?.subtle ) throw new Error( 'usd-gltf: loading the Draco decoder needs a secure context (https) to check it; pass a dracoDecoder instead' );
65
+ const [ wrapper, wasm ] = await Promise.all( Object.keys( CDN_FILES ).map( fetchVerified ) );
66
+ // the wrapper is a classic script defining DracoDecoderModule, so it can't be imported as a module
67
+ const factory = new Function( new TextDecoder().decode( wrapper ) + '\nreturn DracoDecoderModule;' )();
68
+ return factory( { wasmBinary: wasm.buffer } );
69
+
70
+ }
71
+
72
+ async function fetchVerified( name ) {
73
+
74
+ const response = await fetch( CDN + name );
75
+ if ( ! response.ok ) throw new Error( `usd-gltf: could not load the Draco decoder (${ response.status } for ${ CDN + name })` );
76
+ const bytes = new Uint8Array( await response.arrayBuffer() );
77
+ const digest = new Uint8Array( await crypto.subtle.digest( 'SHA-256', bytes ) );
78
+ if ( btoa( String.fromCharCode( ...digest ) ) !== CDN_FILES[ name ] ) throw new Error( `usd-gltf: ${ CDN + name } doesn't match its expected hash` );
79
+ return bytes;
80
+
81
+ }
82
+
83
+ /**
84
+ * Whether a primitive's data is only in its Draco extension: Draco is decoded only when the
85
+ * accessors have no uncompressed fallback.
86
+ */
87
+ export function needsDraco( json, primitive ) {
88
+
89
+ if ( ! primitive.extensions?.KHR_draco_mesh_compression ) return false;
90
+ const used = [ ...Object.values( primitive.attributes ), primitive.indices ].filter( ( a ) => a !== undefined );
91
+ return used.some( ( a ) => json.accessors[ a ]?.bufferView === undefined && ! json.accessors[ a ]?.sparse );
92
+
93
+ }
94
+
95
+ /**
96
+ * Decodes one Draco compressed primitive into the data of the accessors it describes.
97
+ * @param {object} draco decoder module
98
+ * @param {Uint8Array} bytes the extension's bufferView
99
+ * @param {object} json
100
+ * @param {object} primitive
101
+ * @param {import('../limits.js').Budget} budget
102
+ * @returns {Map<number, ArrayLike<number>>} accessor index -> data, in the accessor's component type
103
+ */
104
+ export function decodeDracoPrimitive( draco, bytes, json, primitive, budget ) {
105
+
106
+ const ext = primitive.extensions.KHR_draco_mesh_compression;
107
+ const decoder = new draco.Decoder();
108
+ const array = new Int8Array( bytes.buffer, bytes.byteOffset, bytes.byteLength );
109
+ const out = new Map();
110
+ let geometry = null;
111
+
112
+ try {
113
+
114
+ const type = decoder.GetEncodedGeometryType( array );
115
+ let status;
116
+
117
+ if ( type === draco.TRIANGULAR_MESH ) {
118
+
119
+ geometry = new draco.Mesh();
120
+ status = decoder.DecodeArrayToMesh( array, array.byteLength, geometry );
121
+
122
+ } else if ( type === draco.POINT_CLOUD ) {
123
+
124
+ geometry = new draco.PointCloud();
125
+ status = decoder.DecodeArrayToPointCloud( array, array.byteLength, geometry );
126
+
127
+ } else {
128
+
129
+ throw new Error( 'usd-gltf: Draco data is neither a mesh nor a point cloud' );
130
+
131
+ }
132
+
133
+ if ( ! status.ok() || geometry.ptr === 0 ) throw new Error( `usd-gltf: Draco decoding failed: ${ status.error_msg() }` );
134
+
135
+ const points = geometry.num_points();
136
+
137
+ for ( const [ name, id ] of Object.entries( ext.attributes ) ) {
138
+
139
+ const index = primitive.attributes[ name ];
140
+ const accessor = json.accessors[ index ];
141
+ if ( accessor === undefined ) continue;
142
+ const ArrayType = COMPONENT_ARRAYS[ accessor.componentType ];
143
+ const size = TYPE_SIZES[ accessor.type ];
144
+ const attribute = decoder.GetAttributeByUniqueId( geometry, id );
145
+ if ( ! ArrayType || ! size || ! attribute || attribute.ptr === 0 ) throw new Error( `usd-gltf: Draco data has no ${ name } attribute ${ id }` );
146
+ if ( points !== accessor.count || attribute.num_components() !== size ) throw new Error( `usd-gltf: Draco ${ name } doesn't match accessor ${ index }` );
147
+ out.set( index, read( draco, points * size, ArrayType, budget, ( length, ptr ) => decoder.GetAttributeDataArrayForAllPoints( geometry, attribute, draco[ DATA_TYPES[ accessor.componentType ] ], length, ptr ) ) );
148
+
149
+ }
150
+
151
+ if ( primitive.indices !== undefined && type === draco.TRIANGULAR_MESH ) {
152
+
153
+ const accessor = json.accessors[ primitive.indices ];
154
+ const count = geometry.num_faces() * 3;
155
+ if ( ! accessor || accessor.count !== count ) throw new Error( `usd-gltf: Draco indices don't match accessor ${ primitive.indices }` );
156
+ const indices = read( draco, count, Uint32Array, budget, ( length, ptr ) => decoder.GetTrianglesUInt32Array( geometry, length, ptr ) );
157
+ const IndexType = COMPONENT_ARRAYS[ accessor.componentType ] || Uint32Array;
158
+ out.set( primitive.indices, IndexType === Uint32Array ? indices : IndexType.from( indices ) );
159
+
160
+ }
161
+
162
+ } finally {
163
+
164
+ if ( geometry ) draco.destroy( geometry );
165
+ draco.destroy( decoder );
166
+
167
+ }
168
+
169
+ return out;
170
+
171
+ }
172
+
173
+ // Has the decoder fill a buffer on its heap, then copies it out.
174
+ function read( draco, count, ArrayType, budget, fill ) {
175
+
176
+ const length = count * ArrayType.BYTES_PER_ELEMENT;
177
+ budget.decode( length, 'draco' );
178
+ const ptr = draco._malloc( length );
179
+
180
+ try {
181
+
182
+ if ( ! fill( length, ptr ) ) throw new Error( 'usd-gltf: Draco attribute could not be read' );
183
+ return new ArrayType( draco.HEAPF32.buffer, ptr, count ).slice();
184
+
185
+ } finally {
186
+
187
+ draco._free( ptr );
188
+
189
+ }
190
+
191
+ }
package/src/index.d.ts CHANGED
@@ -53,6 +53,11 @@ export interface GLBToUSDOptions extends ConversionLimits {
53
53
  format?: 'usdz' | 'usda' | 'usdc';
54
54
  /** Needed for .gltf files with external buffers or images. */
55
55
  resolver?: Resolver;
56
+ /**
57
+ * A Draco decoder module (from draco3d's createDecoderModule, or Google's decoder build), or a
58
+ * promise of one. Without it, Draco files load draco3d in Node and Google's CDN build in browsers.
59
+ */
60
+ dracoDecoder?: object | Promise<object>;
56
61
  imageDecoder?: ImageDecoder;
57
62
  log?: ( message: string ) => void;
58
63
  }
package/src/index.js CHANGED
@@ -50,13 +50,13 @@ export async function usdToGLB( data, options = {} ) {
50
50
  /**
51
51
  * Converts GLB (or glTF JSON with embedded or resolvable buffers) to USD.
52
52
  * @param {ArrayBuffer|Uint8Array} data
53
- * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array>, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
53
+ * @param {{ format?: 'usda'|'usdc'|'usdz', resolver?: ( uri: string ) => Promise<Uint8Array>, dracoDecoder?: object, maxDecodedBytes?: number, maxOutputBytes?: number, signal?: AbortSignal }} [options]
54
54
  * @returns {Promise<Uint8Array>}
55
55
  */
56
- export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder, ...limits } = {} ) {
56
+ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, imageDecoder, dracoDecoder, ...limits } = {} ) {
57
57
 
58
58
  const budget = budgetFor( limits );
59
- const doc = await readGLTF( toBytes( data ), { resolver, budget } );
59
+ const doc = await readGLTF( toBytes( data ), { resolver, budget, dracoDecoder } );
60
60
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
61
61
  let out;
62
62
 
@@ -73,10 +73,10 @@ export async function glbToUSD( data, { format = 'usdz', resolver = null, log, i
73
73
  * For usda / usdc output the textures are separate files; this returns them alongside the layer.
74
74
  * @returns {Promise<{ layer: Uint8Array, files: Array<{ name: string, data: Uint8Array }> }>}
75
75
  */
76
- export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder, ...limits } = {} ) {
76
+ export async function glbToUSDFiles( data, { format = 'usda', resolver = null, log, imageDecoder, dracoDecoder, ...limits } = {} ) {
77
77
 
78
78
  const budget = budgetFor( limits );
79
- const doc = await readGLTF( toBytes( data ), { resolver, budget } );
79
+ const doc = await readGLTF( toBytes( data ), { resolver, budget, dracoDecoder } );
80
80
  const { layer, files } = await new GLTFToUSD( doc, { log, imageDecoder } ).convert();
81
81
  const bytes = format === 'usdc' ? writeUSDC( layer, { budget } ) : new TextEncoder().encode( writeUSDA( layer, { budget } ) );
82
82
  budget.output( files.reduce( ( n, f ) => n + f.data.byteLength, bytes.byteLength ), 'usd files' );