@threenative/assets 0.3.7 → 0.3.10

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.
@@ -15563,9 +15563,9 @@ function readTextureQuality(value) {
15563
15563
  const row = value;
15564
15564
  const positive = (v) => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
15565
15565
  const bounded = (v, min2, max2) => typeof v === "number" && Number.isFinite(v) && v >= min2 && v <= max2;
15566
- if (row.version !== IMAGE_QUALITY_VERSION || ![row.width, row.height, row.sourceWidth, row.sourceHeight].every(positive) || !bounded(row.ssim, -1, 1) || row.meanDeltaE00 !== null && !bounded(row.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || !["pass", "below-floor", "unvalidated-slots"].includes(row.status) || !Array.isArray(row.slots) || !row.slots.every((slot) => typeof slot === "string") || !["uastc", "etc1s", "none"].includes(row.codec) || row.compressionSkipped !== void 0 && !["block-size", "not-smaller", "below-floor"].includes(row.compressionSkipped) || !bounded(row.floor?.ssim, 0, 1) || !bounded(row.floor?.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || row.rung !== void 0 && !["etc1s@150", "uastc+rdo \u03BB3 +zstd", "uastc+rdo \u03BB1 +zstd", "uastc", "none"].includes(
15566
+ if (row.version !== IMAGE_QUALITY_VERSION || ![row.width, row.height, row.sourceWidth, row.sourceHeight].every(positive) || !bounded(row.ssim, -1, 1) || row.meanDeltaE00 !== null && !bounded(row.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || !["pass", "below-floor", "unvalidated-slots"].includes(row.status) || !Array.isArray(row.slots) || !row.slots.every((slot) => typeof slot === "string") || !["uastc", "etc1s", "jpeg", "none"].includes(row.codec) || row.compressionSkipped !== void 0 && !["block-size", "not-smaller", "below-floor"].includes(row.compressionSkipped) || !bounded(row.floor?.ssim, 0, 1) || !bounded(row.floor?.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || row.rung !== void 0 && !["etc1s@150", "uastc+rdo \u03BB3 +zstd", "uastc+rdo \u03BB1 +zstd", "uastc", "none"].includes(
15567
15567
  row.rung
15568
- ) && !/^etc1s@\d+$/u.test(row.rung) || !bounded(row.alpha?.ssim, -1, 1) || !bounded(row.alpha?.meanAbsoluteError, 0, 1) || !Array.isArray(row.alpha?.coverage) || !row.alpha.coverage.every(
15568
+ ) && !/^etc1s@\d+$/u.test(row.rung) && !/^jpeg@\d+$/u.test(row.rung) || !bounded(row.alpha?.ssim, -1, 1) || !bounded(row.alpha?.meanAbsoluteError, 0, 1) || !Array.isArray(row.alpha?.coverage) || !row.alpha.coverage.every(
15569
15569
  (c) => c !== null && typeof c === "object" && bounded(c.threshold, 0, 1) && [c.source, c.decoded, c.changedPixels].every(
15570
15570
  (v) => Number.isSafeInteger(v) && v >= 0 && v <= row.width * row.height
15571
15571
  )
@@ -20110,7 +20110,7 @@ function scriptsDirectory() {
20110
20110
  // Built: `dist/index.js` sits beside `dist/blender-gpl/`.
20111
20111
  path2.join(here, "blender-gpl"),
20112
20112
  // Source: `src/passes/` -> the workspace sibling.
20113
- path2.resolve(here, "..", "..", "..", "blender-mcp", "gpl")
20113
+ path2.resolve(here, "..", "..", "..", "blender-bridge", "gpl")
20114
20114
  ];
20115
20115
  const found = candidates.find((candidate) => existsSync(path2.join(candidate, "convert.py")));
20116
20116
  if (found === void 0) {
@@ -20531,6 +20531,7 @@ function bakeStaticLightmap(document, width, height, padding) {
20531
20531
  width
20532
20532
  };
20533
20533
  }
20534
+ var BASIS_TRANSCODER_BYTES = 600 * 1024;
20534
20535
  var DEFAULT_MAX_SIZE = 2048;
20535
20536
  var DEFAULT_ETC1S_QUALITY = 150;
20536
20537
  var BLOCK_SIZE = 4;
@@ -20825,6 +20826,29 @@ async function encodeTextureLadder(data, width, height, options) {
20825
20826
  if (smallest !== void 0) return smallest;
20826
20827
  throw new Error("TN_ASSETS_TEXTURE_FLOOR: forced codec fails its configured floor.");
20827
20828
  }
20829
+ var JPEG_QUALITY_RUNGS = [75, 80, 85, 90];
20830
+ async function encodeJpegCandidate(data, width, height, options) {
20831
+ const sharp = (await import('sharp')).default;
20832
+ for (const quality of JPEG_QUALITY_RUNGS) {
20833
+ const buffer = await sharp(Buffer.from(data), {
20834
+ raw: { channels: 4, height, width }
20835
+ }).jpeg({ mozjpeg: true, quality }).toBuffer();
20836
+ const decoded = await decodeImageBytes(buffer, "jpeg-candidate");
20837
+ const score = imageQuality(data, decoded.data, width, height, options);
20838
+ if (score.status !== "pass" || score.alpha.meanAbsoluteError !== 0) continue;
20839
+ return {
20840
+ buffer,
20841
+ quality: {
20842
+ ...score,
20843
+ codec: "jpeg",
20844
+ rung: `jpeg@${String(quality)}`,
20845
+ sourceHeight: height,
20846
+ sourceWidth: width
20847
+ }
20848
+ };
20849
+ }
20850
+ return void 0;
20851
+ }
20828
20852
  function gpuBytes(width, height, codec) {
20829
20853
  return Math.round(width * height * (GPU_BYTES_PER_PIXEL[codec] ?? 4) * MIP_FACTOR);
20830
20854
  }
@@ -20836,6 +20860,7 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
20836
20860
  const textures = root.listTextures();
20837
20861
  if (textures.length === 0) return void 0;
20838
20862
  const decoderFree = options.decoderFree === true;
20863
+ const transcoderBytes = Math.max(0, options.transcoderBytes ?? BASIS_TRANSCODER_BYTES);
20839
20864
  const quality = Math.min(255, Math.max(1, Math.round(options.quality ?? DEFAULT_ETC1S_QUALITY)));
20840
20865
  const keys = textureKeys(root);
20841
20866
  const formats = {};
@@ -20847,6 +20872,9 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
20847
20872
  let gpuBytesAfter = 0;
20848
20873
  let resized = 0;
20849
20874
  let compressed = 0;
20875
+ let transcoderSavings = 0;
20876
+ let transcoderRequired = false;
20877
+ const transcoderByImage = {};
20850
20878
  for (const [index, texture] of textures.entries()) {
20851
20879
  const key = keys[index] ?? `texture#${String(index)}`;
20852
20880
  const image = texture.getImage();
@@ -20875,6 +20903,15 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
20875
20903
  if (shape.width !== score.sourceWidth || shape.height !== score.sourceHeight) resized += 1;
20876
20904
  }
20877
20905
  if (fromStore.codec !== "none") compressed += 1;
20906
+ if (fromStore.transcoderSavings !== void 0)
20907
+ transcoderSavings += fromStore.transcoderSavings;
20908
+ if (fromStore.transcoderRequired === true) transcoderRequired = true;
20909
+ if (fromStore.transcoderSavings !== void 0 || fromStore.transcoderRequired === true) {
20910
+ transcoderByImage[key] = {
20911
+ ...fromStore.transcoderSavings === void 0 ? {} : { savings: fromStore.transcoderSavings },
20912
+ ...fromStore.transcoderRequired === true ? { required: true } : {}
20913
+ };
20914
+ }
20878
20915
  continue;
20879
20916
  }
20880
20917
  const slots = listTextureSlots(texture);
@@ -20890,6 +20927,10 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
20890
20927
  }
20891
20928
  }
20892
20929
  if (texture.getMimeType() === "image/ktx2" && !authoredBasis) {
20930
+ if (!isRawAstc(texture)) {
20931
+ transcoderRequired = true;
20932
+ transcoderByImage[key] = { ...transcoderByImage[key], required: true };
20933
+ }
20893
20934
  const shape = imageShape(image, "image/ktx2");
20894
20935
  bytesBefore += image.byteLength;
20895
20936
  bytesAfter += image.byteLength;
@@ -21006,6 +21047,14 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
21006
21047
  };
21007
21048
  continue;
21008
21049
  }
21050
+ const authoredContainer = !explicit && !authoredBasis && options.jpeg !== false && options.astc !== true && target.width === decoded.width && target.height === decoded.height && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType()) && slots.some((slot) => COLOUR_SLOTS.has(slot));
21051
+ const jpeg = authoredContainer ? await encodeJpegCandidate(data, target.width, target.height, {
21052
+ ...textureQualitySemantics(texture),
21053
+ floor: options.floor
21054
+ }) : void 0;
21055
+ const jpegLimit = jpeg !== void 0 && jpeg.buffer.byteLength < image.byteLength ? jpeg.buffer.byteLength : image.byteLength;
21056
+ const automatic = !explicit && target.width === decoded.width && target.height === decoded.height && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType());
21057
+ const charged = automatic && options.astc !== true;
21009
21058
  let selected;
21010
21059
  try {
21011
21060
  if (authoredBasis && target.width === decoded.width && target.height === decoded.height) {
@@ -21035,7 +21084,7 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
21035
21084
  srgb,
21036
21085
  quality,
21037
21086
  ...options.astc === true ? { astc: true, gltfColorPrimaries: srgb ? 1 : 0 } : {},
21038
- ...!explicit && target.width === decoded.width && target.height === decoded.height && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType()) ? { maxBytes: image.byteLength } : {},
21087
+ ...automatic ? { maxBytes: jpegLimit } : {},
21039
21088
  ...forced === void 0 ? {} : { forced }
21040
21089
  });
21041
21090
  }
@@ -21047,7 +21096,21 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
21047
21096
  if (!authoredBasis) throw error;
21048
21097
  throw new Error(`TN_ASSETS_MODEL_BASIS_ASTC: '${logicalPath}#${key}': ${messageOf2(error)}`);
21049
21098
  }
21050
- const encoded = selected.encoded;
21099
+ let encoded = selected.encoded;
21100
+ if (charged && encoded !== void 0) {
21101
+ const savings = jpegLimit - encoded.byteLength;
21102
+ transcoderSavings += savings;
21103
+ if (savings > 0) transcoderByImage[key] = { ...transcoderByImage[key], savings };
21104
+ if (encoded.byteLength >= jpegLimit - transcoderBytes) encoded = void 0;
21105
+ }
21106
+ if (encoded === void 0 && jpeg !== void 0 && jpeg.buffer.byteLength < image.byteLength) {
21107
+ texture.setImage(jpeg.buffer).setMimeType("image/jpeg");
21108
+ bytesAfter += jpeg.buffer.byteLength;
21109
+ gpuBytesAfter += gpuBytes(target.width, target.height, "none");
21110
+ formats[key] = "none";
21111
+ if (options.measureQuality !== false) scores[key] = jpeg.quality;
21112
+ continue;
21113
+ }
21051
21114
  if (encoded === void 0 || encoded.byteLength >= image.byteLength && target.width === decoded.width && target.height === decoded.height && !explicit && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType())) {
21052
21115
  const downsampled = target.width !== decoded.width || target.height !== decoded.height;
21053
21116
  let retained = image;
@@ -21090,6 +21153,10 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
21090
21153
  };
21091
21154
  }
21092
21155
  texture.setImage(encoded).setMimeType("image/ktx2");
21156
+ if (!charged && options.astc !== true) {
21157
+ transcoderRequired = true;
21158
+ transcoderByImage[key] = { ...transcoderByImage[key], required: true };
21159
+ }
21093
21160
  formats[key] = options.astc === true ? "astc4x4" : selected.codec;
21094
21161
  bytesAfter += encoded.byteLength;
21095
21162
  gpuBytesAfter += gpuBytes(target.width, target.height, formats[key] ?? selected.codec);
@@ -21117,7 +21184,10 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
21117
21184
  gpuBytesAfter,
21118
21185
  gpuBytesBefore,
21119
21186
  resized,
21120
- skippedCompression
21187
+ skippedCompression,
21188
+ ...transcoderSavings > 0 ? { transcoderSavings } : {},
21189
+ ...transcoderRequired ? { transcoderRequired: true } : {},
21190
+ ...Object.keys(transcoderByImage).length === 0 ? {} : { transcoderByImage }
21121
21191
  };
21122
21192
  }
21123
21193
  function textureQualitySemantics(texture) {
@@ -21175,12 +21245,6 @@ function texturePass(options = {}) {
21175
21245
  },
21176
21246
  apply: async (input, logicalPath) => {
21177
21247
  if (classify2(logicalPath) !== "texture") return input;
21178
- const stats = textureStats(input);
21179
- if (stats.width <= 0 || stats.height <= 0) {
21180
- throw new Error(
21181
- `TN_ASSETS_TEXTURE_UNREADABLE: '${logicalPath}' has no readable PNG/JPEG header; the KTX2 pass cannot encode it.`
21182
- );
21183
- }
21184
21248
  const decoded = await decodeImageBytes(input, logicalPath);
21185
21249
  const choice = texturePolicy(logicalPath, options);
21186
21250
  if (choice.codec === "none")
@@ -22609,6 +22673,90 @@ function resolveCompactOptions(options) {
22609
22673
  };
22610
22674
  }
22611
22675
 
22676
+ // src/passes/glb-spec-defaults.ts
22677
+ var JSON_CHUNK = 1313821514;
22678
+ var HEADER = 12;
22679
+ function withoutSpecDefaults(glb) {
22680
+ if (glb.readUInt32LE(16) !== JSON_CHUNK) return glb;
22681
+ const jsonLength = glb.readUInt32LE(12);
22682
+ const json = JSON.parse(glb.subarray(20, 20 + jsonLength).toString("utf8"));
22683
+ for (const accessor of json.accessors ?? []) {
22684
+ if (accessor.normalized === false) accessor.normalized = void 0;
22685
+ if (accessor.byteOffset === 0) accessor.byteOffset = void 0;
22686
+ }
22687
+ for (const view of json.bufferViews ?? []) if (view.byteOffset === 0) view.byteOffset = void 0;
22688
+ for (const animation of json.animations ?? [])
22689
+ for (const sampler of animation.samplers)
22690
+ if (sampler.interpolation === "LINEAR") sampler.interpolation = void 0;
22691
+ const text = Buffer.from(JSON.stringify(json), "utf8");
22692
+ const padded2 = Buffer.alloc(Math.ceil(text.length / 4) * 4, 32);
22693
+ text.copy(padded2);
22694
+ const rest = glb.subarray(HEADER + 8 + jsonLength);
22695
+ const head = Buffer.alloc(HEADER + 8);
22696
+ glb.copy(head, 0, 0, HEADER);
22697
+ head.writeUInt32LE(HEADER + 8 + padded2.length + rest.length, 8);
22698
+ head.writeUInt32LE(padded2.length, 12);
22699
+ head.writeUInt32LE(JSON_CHUNK, 16);
22700
+ return Buffer.concat([head, padded2, rest]);
22701
+ }
22702
+
22703
+ // src/passes/prune-rest-tracks.ts
22704
+ var COMPONENTS = { rotation: 4, scale: 3, translation: 3 };
22705
+ var REST_TOLERANCE = 1e-6;
22706
+ function restOf(node, path7) {
22707
+ if (path7 === "rotation") return node.getRotation();
22708
+ return path7 === "translation" ? node.getTranslation() : node.getScale();
22709
+ }
22710
+ function holdsRest(channel) {
22711
+ const path7 = channel.getTargetPath();
22712
+ const node = channel.getTargetNode();
22713
+ const output = channel.getSampler()?.getOutput();
22714
+ if (path7 === null || !(path7 in COMPONENTS) || node === null || !output) return false;
22715
+ const rest = restOf(node, path7);
22716
+ const element = new Array(COMPONENTS[path7]).fill(0);
22717
+ for (let key = 0; key < output.getCount(); key += 1) {
22718
+ output.getElement(key, element);
22719
+ if (element.some((value, c) => Math.abs(value - (rest[c] ?? 0)) > REST_TOLERANCE)) return false;
22720
+ }
22721
+ return output.getCount() > 0;
22722
+ }
22723
+ var lastKey = (channel) => {
22724
+ const input = channel.getSampler()?.getInput();
22725
+ return input === null || input === void 0 ? 0 : input.getMax([0])[0] ?? 0;
22726
+ };
22727
+ function dropChannel(animation, channel) {
22728
+ const sampler = channel.getSampler();
22729
+ animation.removeChannel(channel);
22730
+ channel.dispose();
22731
+ const shared = sampler?.listParents().some((parent) => parent !== animation && parent.propertyType === "AnimationChannel");
22732
+ if (sampler === null || sampler === void 0 || shared === true) return;
22733
+ animation.removeSampler(sampler);
22734
+ sampler.dispose();
22735
+ }
22736
+ function pruneRestPoseTracks(document, logicalPath) {
22737
+ let dropped = 0;
22738
+ let total = 0;
22739
+ for (const animation of document.getRoot().listAnimations()) {
22740
+ const kept = new Set(animation.listChannels());
22741
+ total += kept.size;
22742
+ const duration = Math.max(0, ...[...kept].map(lastKey));
22743
+ for (const channel of animation.listChannels()) {
22744
+ if (kept.size === 1 || !holdsRest(channel)) continue;
22745
+ kept.delete(channel);
22746
+ if (Math.max(0, ...[...kept].map(lastKey)) < duration) kept.add(channel);
22747
+ else {
22748
+ dropChannel(animation, channel);
22749
+ dropped += 1;
22750
+ }
22751
+ }
22752
+ }
22753
+ if (dropped > 0 && logicalPath !== void 0)
22754
+ console.log(
22755
+ `animation ${logicalPath}: dropped ${String(dropped)} of ${String(total)} tracks that hold the rest pose`
22756
+ );
22757
+ return { dropped, total };
22758
+ }
22759
+
22612
22760
  // src/passes/quantize-static.ts
22613
22761
  async function quantizeStaticGeometry(document, quantize2) {
22614
22762
  const held = holdDeformingMeshes(document);
@@ -22773,6 +22921,14 @@ function parseFileName(name) {
22773
22921
  if (key === void 0 || codec === void 0 || mimeType === void 0) return void 0;
22774
22922
  return { codec, key, mimeType };
22775
22923
  }
22924
+ function readTranscoderRecord(value) {
22925
+ if (typeof value !== "object" || value === null) return {};
22926
+ const record = value;
22927
+ return {
22928
+ ...typeof record.savings === "number" && Number.isFinite(record.savings) && record.savings > 0 ? { transcoderSavings: record.savings } : {},
22929
+ ...record.required === true ? { transcoderRequired: true } : {}
22930
+ };
22931
+ }
22776
22932
  function sharedImageKey(source, settings) {
22777
22933
  return createHash("sha256").update(source).update(JSON.stringify(settings), "utf8").digest("hex").slice(0, KEY_LENGTH);
22778
22934
  }
@@ -22849,6 +23005,17 @@ function createSharedImageStore(outputRoot, options = {}) {
22849
23005
  return {};
22850
23006
  throw error;
22851
23007
  }
23008
+ })(),
23009
+ ...await (async () => {
23010
+ try {
23011
+ return readTranscoderRecord(
23012
+ JSON.parse(await readFile(path2.join(directory, `${name}.transcoder.json`), "utf8"))
23013
+ );
23014
+ } catch (error) {
23015
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
23016
+ return {};
23017
+ throw error;
23018
+ }
22852
23019
  })()
22853
23020
  };
22854
23021
  memory.set(key, image);
@@ -22860,7 +23027,8 @@ function createSharedImageStore(outputRoot, options = {}) {
22860
23027
  if (directory === void 0 || options.writeThrough === false) return;
22861
23028
  const name = fileNameFor(key, image);
22862
23029
  const known = await onDisk();
22863
- if (known.has(key) && image.quality === void 0) return;
23030
+ if (known.has(key) && image.quality === void 0 && image.transcoderSavings === void 0 && image.transcoderRequired !== true)
23031
+ return;
22864
23032
  await mkdir(directory, { recursive: true });
22865
23033
  const target = path2.join(directory, name);
22866
23034
  const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
@@ -22881,6 +23049,23 @@ function createSharedImageStore(outputRoot, options = {}) {
22881
23049
  await rm(qualityTemporary, { force: true }).catch(() => void 0);
22882
23050
  }
22883
23051
  }
23052
+ if (image.transcoderSavings !== void 0 || image.transcoderRequired === true) {
23053
+ const transcoderTarget = `${target}.transcoder.json`;
23054
+ const transcoderTemporary = `${transcoderTarget}.${process.pid}.${randomUUID()}.tmp`;
23055
+ try {
23056
+ await writeFile(
23057
+ transcoderTemporary,
23058
+ `${JSON.stringify({
23059
+ ...image.transcoderSavings === void 0 ? {} : { savings: image.transcoderSavings },
23060
+ ...image.transcoderRequired === true ? { required: true } : {}
23061
+ })}
23062
+ `
23063
+ );
23064
+ await rename(transcoderTemporary, transcoderTarget);
23065
+ } finally {
23066
+ await rm(transcoderTemporary, { force: true }).catch(() => void 0);
23067
+ }
23068
+ }
22884
23069
  known.set(key, name);
22885
23070
  }
22886
23071
  };
@@ -23003,6 +23188,7 @@ async function readSharedGlb(io, buffer, resolve2) {
23003
23188
  var DRACO_EXTENSION2 = "KHR_draco_mesh_compression";
23004
23189
  var EXT_MESHOPT_EXTENSION = "EXT_meshopt_compression";
23005
23190
  var MATERIAL_MERGE_VERSION = 1;
23191
+ var SHARED_TRANSCODER_ACCOUNTING_VERSION = 1;
23006
23192
  var MODEL_DEDUPE_VERSION = 1;
23007
23193
  var MODEL_REORDER_VERSION = 1;
23008
23194
  var MODEL_QUANTIZE_VERSION = 1;
@@ -23032,7 +23218,7 @@ async function writeDocument(document, logicalPath, vertexLayout = "interleaved"
23032
23218
  await MeshoptEncoder.ready;
23033
23219
  const io = new NodeIO().setVertexLayout(vertexLayout === "separate" ? VertexLayout.SEPARATE : VertexLayout.INTERLEAVED).registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc, TNVirtualGeometry, TNDiscreteLod]).registerDependencies({ "meshopt.encoder": MeshoptEncoder });
23034
23220
  try {
23035
- const buffer = Buffer.from(await io.writeBinary(document));
23221
+ const buffer = withoutSpecDefaults(Buffer.from(await io.writeBinary(document)));
23036
23222
  return { buffer, extensions: jsonOfGlb(buffer).extensionsUsed ?? [] };
23037
23223
  } catch (error) {
23038
23224
  const detail = error instanceof Error ? error.message : String(error);
@@ -23406,7 +23592,8 @@ function modelPass(options = {}) {
23406
23592
  meshopt: options.passes?.meshopt ?? true,
23407
23593
  prune: options.passes?.prune ?? true,
23408
23594
  quantize: options.passes?.quantize ?? true,
23409
- reorder: options.passes?.reorder ?? true
23595
+ reorder: options.passes?.reorder ?? true,
23596
+ restTracks: options.passes?.restTracks ?? true
23410
23597
  },
23411
23598
  ...options.vertexLayout === void 0 ? {} : { vertexLayout: options.vertexLayout },
23412
23599
  quantize: {
@@ -23441,13 +23628,17 @@ function modelPass(options = {}) {
23441
23628
  bakeVersion: VIRTUAL_BAKE_VERSION
23442
23629
  },
23443
23630
  sharedImages: options.sharedImages !== void 0,
23631
+ ...options.sharedImages === void 0 ? {} : { sharedTranscoderAccounting: SHARED_TRANSCODER_ACCOUNTING_VERSION },
23444
23632
  textures: options.textures === "none" ? "none" : {
23445
23633
  ...options.textures?.astc === true ? { astc: "raw4x4-gltf-authored-basis-v2" } : {},
23446
23634
  decoderFree: options.textures?.decoderFree ?? false,
23447
23635
  encoder: KTX2_ENCODER_VERSION,
23448
23636
  instrument: IMAGE_QUALITY_IDENTITY,
23637
+ jpeg: options.textures?.jpeg ?? null,
23638
+ jpegLadder: "colour-opaque-q75-90-v1",
23449
23639
  floor: resolveImageQualityFloor(options.textures?.floor),
23450
23640
  maxSize: options.textures?.maxSize ?? null,
23641
+ transcoderBytes: options.textures?.transcoderBytes ?? null,
23451
23642
  // Retention is now conditioned on the container, so it is spelled out rather than
23452
23643
  // named by a boolean: a warm cache keyed on the old rule would keep shipping the
23453
23644
  // WebP-requiring models this replaced.
@@ -23468,7 +23659,8 @@ function modelPass(options = {}) {
23468
23659
  meshopt: passes2.meshopt ?? true,
23469
23660
  prune: passes2.prune ?? true,
23470
23661
  quantize: passes2.quantize ?? true,
23471
- reorder: passes2.reorder ?? true
23662
+ reorder: passes2.reorder ?? true,
23663
+ restTracks: passes2.restTracks ?? true
23472
23664
  };
23473
23665
  const textureOptions = options.textures === "none" ? void 0 : options.textures ?? {};
23474
23666
  const virtualOptions = options.virtual === "none" ? void 0 : options.virtual ?? {};
@@ -23486,6 +23678,7 @@ function modelPass(options = {}) {
23486
23678
  if (enabled.dedup) await dedup()(document);
23487
23679
  const namesBeforeCompact = compactRequested(options.compact) ? sceneNodeNames(document) : void 0;
23488
23680
  let compact = compactRequested(options.compact) ? await compactModel(document, resolveCompactOptions(options.compact)) : void 0;
23681
+ if (enabled.restTracks) pruneRestPoseTracks(document, logicalPath);
23489
23682
  if (enabled.prune) {
23490
23683
  await prune({ keepAttributes: options.preserveLightmapUv === true })(document);
23491
23684
  if (compact !== void 0 && namesBeforeCompact !== void 0) {
@@ -23543,7 +23736,8 @@ function modelPass(options = {}) {
23543
23736
  store,
23544
23737
  shared,
23545
23738
  embeddedTextures?.formats,
23546
- embeddedTextures?.quality
23739
+ embeddedTextures?.quality,
23740
+ embeddedTextures?.transcoderByImage
23547
23741
  );
23548
23742
  if (enabled.meshopt) {
23549
23743
  document.createExtension(EXTMeshoptCompression).setRequired(true).setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
@@ -23616,11 +23810,14 @@ function sharedSettings(texture, textureOptions) {
23616
23810
  decoderFree: textureOptions.decoderFree ?? false,
23617
23811
  encoder: KTX2_ENCODER_VERSION,
23618
23812
  instrument: IMAGE_QUALITY_IDENTITY,
23813
+ jpeg: textureOptions.jpeg ?? null,
23814
+ jpegLadder: "colour-opaque-q75-90-v1",
23619
23815
  floor: resolveImageQualityFloor(textureOptions.floor),
23620
23816
  keepSmallerSource: "universal-containers",
23621
23817
  maxSize: resolveTextureMaxSize(textureOptions.maxSize, listTextureSlots(texture)),
23622
23818
  overrides: textureOptions.overrides ?? [],
23623
- quality: textureOptions.quality ?? null
23819
+ quality: textureOptions.quality ?? null,
23820
+ transcoderBytes: textureOptions.transcoderBytes ?? null
23624
23821
  }
23625
23822
  };
23626
23823
  }
@@ -23644,7 +23841,9 @@ async function recallSharedImages(document, store, textureOptions) {
23644
23841
  recalled.set(index, {
23645
23842
  codec: stored.codec,
23646
23843
  sourceBytes: image.byteLength,
23647
- quality: stored.quality
23844
+ quality: stored.quality,
23845
+ ...stored.transcoderSavings === void 0 ? {} : { transcoderSavings: stored.transcoderSavings },
23846
+ ...stored.transcoderRequired === true ? { transcoderRequired: true } : {}
23648
23847
  });
23649
23848
  }
23650
23849
  return { keys, recalled };
@@ -23653,7 +23852,7 @@ function codecOf(mimeType, formats, name) {
23653
23852
  if (mimeType !== "image/ktx2") return "none";
23654
23853
  return formats?.[name] ?? "uastc";
23655
23854
  }
23656
- async function rememberSharedImages(document, store, plan, formats, quality) {
23855
+ async function rememberSharedImages(document, store, plan, formats, quality, transcoder) {
23657
23856
  const textures = document.getRoot().listTextures();
23658
23857
  const names = textureKeys(document.getRoot());
23659
23858
  for (const [index, texture] of textures.entries()) {
@@ -23661,11 +23860,15 @@ async function rememberSharedImages(document, store, plan, formats, quality) {
23661
23860
  const key = plan.keys[index];
23662
23861
  const image = texture.getImage();
23663
23862
  if (key === void 0 || key === "" || image === null) continue;
23863
+ const name = names[index] ?? "";
23864
+ const contribution = transcoder?.[name];
23664
23865
  await store.put(key, {
23665
23866
  buffer: Buffer.from(image.buffer, image.byteOffset, image.byteLength),
23666
- codec: codecOf(texture.getMimeType(), formats, names[index] ?? ""),
23867
+ codec: codecOf(texture.getMimeType(), formats, name),
23667
23868
  mimeType: texture.getMimeType(),
23668
- ...quality?.[names[index] ?? ""] === void 0 ? {} : { quality: quality?.[names[index] ?? ""] }
23869
+ ...quality?.[name] === void 0 ? {} : { quality: quality?.[name] },
23870
+ ...contribution?.savings === void 0 ? {} : { transcoderSavings: contribution.savings },
23871
+ ...contribution?.required === true ? { transcoderRequired: true } : {}
23669
23872
  });
23670
23873
  }
23671
23874
  }
@@ -23678,6 +23881,8 @@ function declareSharedImage(store, logicalPath, auxiliaryOutputs, candidate) {
23678
23881
  metadata: {
23679
23882
  codec: candidate.image.codec,
23680
23883
  key: candidate.key,
23884
+ transcoderRequired: candidate.image.transcoderRequired === true,
23885
+ transcoderSavings: candidate.image.transcoderSavings ?? 0,
23681
23886
  ...candidate.image.quality === void 0 ? {} : { quality: candidate.image.quality }
23682
23887
  },
23683
23888
  outputPath,
package/dist/index.d.ts CHANGED
@@ -32,25 +32,6 @@ interface ITextureQuality extends IImageQuality {
32
32
  readonly codec: string;
33
33
  }
34
34
 
35
- /**
36
- * Compresses the images embedded *inside* a `.glb` — the half of a model the geometry passes
37
- * never touched. A prop carrying three 2048x2048 JPEGs is a small file and 67 MB of VRAM once
38
- * the driver decodes it; meshopt shrinks the other, cheaper half. This stage transcodes each
39
- * embedded image to KTX2/Basis and declares `KHR_texture_basisu`, so the GPU stores blocks
40
- * instead of RGBA, and caps the resolution so a 4K texture an image model produced cannot
41
- * silently eat a phone's whole budget.
42
- *
43
- * Slot constraints feed one measured ladder: normal/data slots retain UASTC/none, while
44
- * colour escalates from ETC1S through RDO and plain UASTC. Alpha coverage is checked separately;
45
- * a forced codec must still meet the floor. Colour space comes from the slot too — `getTextureColorSpace`
46
- * knows base colour and emissive are sRGB and that metallic-roughness and normals are data —
47
- * so a linear map is never encoded through a perceptual metric.
48
- *
49
- * The encoder is the same in-process Basis WASM the standalone texture pass uses, so no new
50
- * dependency and nothing extra to install. Everything fails closed: an image no decoder reads,
51
- * an image whose dimensions are not a whole number of 4x4 blocks, and a texture whose binding
52
- * did not survive the round trip all throw naming the model and the image.
53
- */
54
35
  interface IModelTextureOverride {
55
36
  readonly codec: TextureCodec;
56
37
  /** glTF slot the override applies to, e.g. `normalTexture` or `baseColorTexture`. */
@@ -70,6 +51,20 @@ interface IModelTexturesOptions {
70
51
  * per-image deduplication, colour space — is unchanged.
71
52
  */
72
53
  readonly decoderFree?: boolean;
54
+ /**
55
+ * Bytes the runtime pays once for the Basis transcoder (`basis_transcoder.js` plus
56
+ * `basis_transcoder.wasm`) when any model publishes KTX2. An image is encoded only when its
57
+ * KTX2 output saves at least this many bytes against the authored container, so one small
58
+ * texture can never make every game download a transcoder larger than the saving. Defaults to
59
+ * {@link BASIS_TRANSCODER_BYTES}. `compileAssets` passes 0 when the whole build repays it
60
+ * (see {@link IEmbeddedTextureSummary.transcoderSavings}).
61
+ */
62
+ readonly transcoderBytes?: number;
63
+ /**
64
+ * Internal switch for the authored-container JPEG rung (default on). A caller that needs the
65
+ * KTX2 ladder alone, such as a test of rung selection, turns it off.
66
+ */
67
+ readonly jpeg?: boolean;
73
68
  /**
74
69
  * Longest edge an embedded image may keep, default 2048. Larger images are box-resampled
75
70
  * down preserving aspect ratio; smaller ones are never upscaled.
@@ -93,6 +88,22 @@ interface IEmbeddedTextureSummary {
93
88
  readonly resized: number;
94
89
  /** Images retained at their authored bytes, by name, with the reason each one was not cooked. */
95
90
  readonly skippedCompression: Readonly<Record<string, TextureSkipReason>>;
91
+ /**
92
+ * Bytes KTX2 saves over the decoder-free container across this model's automatic images,
93
+ * whether or not the transcoder charge let it ship. The build sums it over every model to
94
+ * decide once whether the shared transcoder pays for itself. Absent when zero.
95
+ */
96
+ readonly transcoderSavings?: number;
97
+ /** Some image ships Basis KTX2 whatever the charge (a codec override, a resize, authored KTX2). */
98
+ readonly transcoderRequired?: true;
99
+ /**
100
+ * What each image contributed to the two fields above, keyed like `formats`. Internal: the
101
+ * shared-image store replays it so a warm cook reports the same summary as a cold one.
102
+ */
103
+ readonly transcoderByImage?: Readonly<Record<string, {
104
+ readonly savings?: number;
105
+ readonly required?: true;
106
+ }>>;
96
107
  }
97
108
  /** One embedded image as the verification compares it, source against re-read output. */
98
109
  interface IModelTextureBinding {
@@ -544,6 +555,10 @@ interface ISharedImage {
544
555
  /** `uastc`, `etc1s` or `none`, spelled into the filename so a cache hit knows what it found. */
545
556
  readonly codec: string;
546
557
  readonly mimeType: string;
558
+ /** Bytes KTX2 saved against the decoder-free container here, so a recall reports it too. */
559
+ readonly transcoderSavings?: number;
560
+ /** This image ships Basis KTX2 whatever the transcoder charge; replayed on a recall. */
561
+ readonly transcoderRequired?: true;
547
562
  }
548
563
  interface ISharedImageStore {
549
564
  /** The image encoded from this key, from memory or from a previous build's output. */
@@ -583,6 +598,8 @@ interface IModelPassesOptions {
583
598
  readonly prune?: boolean;
584
599
  readonly quantize?: boolean;
585
600
  readonly reorder?: boolean;
601
+ /** Drop animation tracks that only hold the pose their node already has. Default true. */
602
+ readonly restTracks?: boolean;
586
603
  }
587
604
  interface IModelQuantizeOptions {
588
605
  /** Normal precision in bits, default 8. */
@@ -690,6 +707,10 @@ interface IEmbeddedTextureRow {
690
707
  readonly gpuBytesAfter: number;
691
708
  readonly gpuBytesBefore: number;
692
709
  readonly resized: number;
710
+ /** Bytes KTX2 saves over the decoder-free container here; the build sums it once per bake. */
711
+ readonly transcoderSavings?: number;
712
+ /** Some image ships Basis KTX2 whatever the transcoder charge. */
713
+ readonly transcoderRequired?: true;
693
714
  }
694
715
  /** What the cluster-DAG bake produced for one model. */
695
716
  interface IVirtualRow {
@@ -1117,6 +1138,12 @@ interface IAssetCompileOptions {
1117
1138
  readonly source?: string;
1118
1139
  /** Overrides resolution of three's Basis transcoder for the copy into the output root. */
1119
1140
  readonly transcoder?: IBasisTranscoder;
1141
+ /**
1142
+ * Internal: the bytes an embedded model image must save before KTX2 pays for the shared Basis
1143
+ * transcoder. Absent charges the measured default; a caller that already ships the transcoder
1144
+ * passes 0.
1145
+ */
1146
+ readonly modelTranscoderBytes?: number;
1120
1147
  }
1121
1148
  interface IBasisTranscoder {
1122
1149
  readonly javascriptPath: string;
package/dist/index.js CHANGED
@@ -531,7 +531,8 @@ var MODEL_PASS_KEYS = [
531
531
  "meshopt",
532
532
  "prune",
533
533
  "quantize",
534
- "reorder"
534
+ "reorder",
535
+ "restTracks"
535
536
  ];
536
537
  var MODEL_QUANTIZE_KEYS = ["normalBits", "positionBits", "uvBits"];
537
538
  var MODEL_VIRTUAL_COUNT_KEYS = [
@@ -16008,9 +16009,9 @@ function readTextureQuality(value) {
16008
16009
  const row = value;
16009
16010
  const positive = (v) => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
16010
16011
  const bounded = (v, min2, max2) => typeof v === "number" && Number.isFinite(v) && v >= min2 && v <= max2;
16011
- if (row.version !== IMAGE_QUALITY_VERSION || ![row.width, row.height, row.sourceWidth, row.sourceHeight].every(positive) || !bounded(row.ssim, -1, 1) || row.meanDeltaE00 !== null && !bounded(row.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || !["pass", "below-floor", "unvalidated-slots"].includes(row.status) || !Array.isArray(row.slots) || !row.slots.every((slot) => typeof slot === "string") || !["uastc", "etc1s", "none"].includes(row.codec) || row.compressionSkipped !== void 0 && !["block-size", "not-smaller", "below-floor"].includes(row.compressionSkipped) || !bounded(row.floor?.ssim, 0, 1) || !bounded(row.floor?.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || row.rung !== void 0 && !["etc1s@150", "uastc+rdo \u03BB3 +zstd", "uastc+rdo \u03BB1 +zstd", "uastc", "none"].includes(
16012
+ if (row.version !== IMAGE_QUALITY_VERSION || ![row.width, row.height, row.sourceWidth, row.sourceHeight].every(positive) || !bounded(row.ssim, -1, 1) || row.meanDeltaE00 !== null && !bounded(row.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || !["pass", "below-floor", "unvalidated-slots"].includes(row.status) || !Array.isArray(row.slots) || !row.slots.every((slot) => typeof slot === "string") || !["uastc", "etc1s", "jpeg", "none"].includes(row.codec) || row.compressionSkipped !== void 0 && !["block-size", "not-smaller", "below-floor"].includes(row.compressionSkipped) || !bounded(row.floor?.ssim, 0, 1) || !bounded(row.floor?.meanDeltaE00, 0, Number.POSITIVE_INFINITY) || row.rung !== void 0 && !["etc1s@150", "uastc+rdo \u03BB3 +zstd", "uastc+rdo \u03BB1 +zstd", "uastc", "none"].includes(
16012
16013
  row.rung
16013
- ) && !/^etc1s@\d+$/u.test(row.rung) || !bounded(row.alpha?.ssim, -1, 1) || !bounded(row.alpha?.meanAbsoluteError, 0, 1) || !Array.isArray(row.alpha?.coverage) || !row.alpha.coverage.every(
16014
+ ) && !/^etc1s@\d+$/u.test(row.rung) && !/^jpeg@\d+$/u.test(row.rung) || !bounded(row.alpha?.ssim, -1, 1) || !bounded(row.alpha?.meanAbsoluteError, 0, 1) || !Array.isArray(row.alpha?.coverage) || !row.alpha.coverage.every(
16014
16015
  (c) => c !== null && typeof c === "object" && bounded(c.threshold, 0, 1) && [c.source, c.decoded, c.changedPixels].every(
16015
16016
  (v) => Number.isSafeInteger(v) && v >= 0 && v <= row.width * row.height
16016
16017
  )
@@ -21682,7 +21683,7 @@ function scriptsDirectory() {
21682
21683
  // Built: `dist/index.js` sits beside `dist/blender-gpl/`.
21683
21684
  path8.join(here, "blender-gpl"),
21684
21685
  // Source: `src/passes/` -> the workspace sibling.
21685
- path8.resolve(here, "..", "..", "..", "blender-mcp", "gpl")
21686
+ path8.resolve(here, "..", "..", "..", "blender-bridge", "gpl")
21686
21687
  ];
21687
21688
  const found = candidates.find((candidate) => existsSync(path8.join(candidate, "convert.py")));
21688
21689
  if (found === void 0) {
@@ -22065,6 +22066,7 @@ function bakeStaticLightmap(document, width, height, padding) {
22065
22066
  width
22066
22067
  };
22067
22068
  }
22069
+ var BASIS_TRANSCODER_BYTES = 600 * 1024;
22068
22070
  var DEFAULT_MAX_SIZE = 2048;
22069
22071
  var DEFAULT_ETC1S_QUALITY = 150;
22070
22072
  var BLOCK_SIZE = 4;
@@ -22359,6 +22361,29 @@ async function encodeTextureLadder(data, width, height, options) {
22359
22361
  if (smallest !== void 0) return smallest;
22360
22362
  throw new Error("TN_ASSETS_TEXTURE_FLOOR: forced codec fails its configured floor.");
22361
22363
  }
22364
+ var JPEG_QUALITY_RUNGS = [75, 80, 85, 90];
22365
+ async function encodeJpegCandidate(data, width, height, options) {
22366
+ const sharp = (await import('sharp')).default;
22367
+ for (const quality2 of JPEG_QUALITY_RUNGS) {
22368
+ const buffer = await sharp(Buffer.from(data), {
22369
+ raw: { channels: 4, height, width }
22370
+ }).jpeg({ mozjpeg: true, quality: quality2 }).toBuffer();
22371
+ const decoded = await decodeImageBytes(buffer, "jpeg-candidate");
22372
+ const score = imageQuality(data, decoded.data, width, height, options);
22373
+ if (score.status !== "pass" || score.alpha.meanAbsoluteError !== 0) continue;
22374
+ return {
22375
+ buffer,
22376
+ quality: {
22377
+ ...score,
22378
+ codec: "jpeg",
22379
+ rung: `jpeg@${String(quality2)}`,
22380
+ sourceHeight: height,
22381
+ sourceWidth: width
22382
+ }
22383
+ };
22384
+ }
22385
+ return void 0;
22386
+ }
22362
22387
  function gpuBytes(width, height, codec) {
22363
22388
  return Math.round(width * height * (GPU_BYTES_PER_PIXEL[codec] ?? 4) * MIP_FACTOR);
22364
22389
  }
@@ -22370,6 +22395,7 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22370
22395
  const textures = root.listTextures();
22371
22396
  if (textures.length === 0) return void 0;
22372
22397
  const decoderFree = options.decoderFree === true;
22398
+ const transcoderBytes = Math.max(0, options.transcoderBytes ?? BASIS_TRANSCODER_BYTES);
22373
22399
  const quality2 = Math.min(255, Math.max(1, Math.round(options.quality ?? DEFAULT_ETC1S_QUALITY)));
22374
22400
  const keys = textureKeys(root);
22375
22401
  const formats = {};
@@ -22381,6 +22407,9 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22381
22407
  let gpuBytesAfter = 0;
22382
22408
  let resized = 0;
22383
22409
  let compressed = 0;
22410
+ let transcoderSavings = 0;
22411
+ let transcoderRequired = false;
22412
+ const transcoderByImage = {};
22384
22413
  for (const [index, texture] of textures.entries()) {
22385
22414
  const key = keys[index] ?? `texture#${String(index)}`;
22386
22415
  const image = texture.getImage();
@@ -22409,6 +22438,15 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22409
22438
  if (shape.width !== score.sourceWidth || shape.height !== score.sourceHeight) resized += 1;
22410
22439
  }
22411
22440
  if (fromStore.codec !== "none") compressed += 1;
22441
+ if (fromStore.transcoderSavings !== void 0)
22442
+ transcoderSavings += fromStore.transcoderSavings;
22443
+ if (fromStore.transcoderRequired === true) transcoderRequired = true;
22444
+ if (fromStore.transcoderSavings !== void 0 || fromStore.transcoderRequired === true) {
22445
+ transcoderByImage[key] = {
22446
+ ...fromStore.transcoderSavings === void 0 ? {} : { savings: fromStore.transcoderSavings },
22447
+ ...fromStore.transcoderRequired === true ? { required: true } : {}
22448
+ };
22449
+ }
22412
22450
  continue;
22413
22451
  }
22414
22452
  const slots = listTextureSlots(texture);
@@ -22424,6 +22462,10 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22424
22462
  }
22425
22463
  }
22426
22464
  if (texture.getMimeType() === "image/ktx2" && !authoredBasis) {
22465
+ if (!isRawAstc(texture)) {
22466
+ transcoderRequired = true;
22467
+ transcoderByImage[key] = { ...transcoderByImage[key], required: true };
22468
+ }
22427
22469
  const shape = imageShape(image, "image/ktx2");
22428
22470
  bytesBefore += image.byteLength;
22429
22471
  bytesAfter += image.byteLength;
@@ -22540,6 +22582,14 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22540
22582
  };
22541
22583
  continue;
22542
22584
  }
22585
+ const authoredContainer = !explicit && !authoredBasis && options.jpeg !== false && options.astc !== true && target.width === decoded.width && target.height === decoded.height && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType()) && slots.some((slot) => COLOUR_SLOTS.has(slot));
22586
+ const jpeg = authoredContainer ? await encodeJpegCandidate(data, target.width, target.height, {
22587
+ ...textureQualitySemantics(texture),
22588
+ floor: options.floor
22589
+ }) : void 0;
22590
+ const jpegLimit = jpeg !== void 0 && jpeg.buffer.byteLength < image.byteLength ? jpeg.buffer.byteLength : image.byteLength;
22591
+ const automatic = !explicit && target.width === decoded.width && target.height === decoded.height && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType());
22592
+ const charged = automatic && options.astc !== true;
22543
22593
  let selected;
22544
22594
  try {
22545
22595
  if (authoredBasis && target.width === decoded.width && target.height === decoded.height) {
@@ -22569,7 +22619,7 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22569
22619
  srgb,
22570
22620
  quality: quality2,
22571
22621
  ...options.astc === true ? { astc: true, gltfColorPrimaries: srgb ? 1 : 0 } : {},
22572
- ...!explicit && target.width === decoded.width && target.height === decoded.height && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType()) ? { maxBytes: image.byteLength } : {},
22622
+ ...automatic ? { maxBytes: jpegLimit } : {},
22573
22623
  ...forced === void 0 ? {} : { forced }
22574
22624
  });
22575
22625
  }
@@ -22581,7 +22631,21 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22581
22631
  if (!authoredBasis) throw error;
22582
22632
  throw new Error(`TN_ASSETS_MODEL_BASIS_ASTC: '${logicalPath}#${key}': ${messageOf3(error)}`);
22583
22633
  }
22584
- const encoded = selected.encoded;
22634
+ let encoded = selected.encoded;
22635
+ if (charged && encoded !== void 0) {
22636
+ const savings = jpegLimit - encoded.byteLength;
22637
+ transcoderSavings += savings;
22638
+ if (savings > 0) transcoderByImage[key] = { ...transcoderByImage[key], savings };
22639
+ if (encoded.byteLength >= jpegLimit - transcoderBytes) encoded = void 0;
22640
+ }
22641
+ if (encoded === void 0 && jpeg !== void 0 && jpeg.buffer.byteLength < image.byteLength) {
22642
+ texture.setImage(jpeg.buffer).setMimeType("image/jpeg");
22643
+ bytesAfter += jpeg.buffer.byteLength;
22644
+ gpuBytesAfter += gpuBytes(target.width, target.height, "none");
22645
+ formats[key] = "none";
22646
+ if (options.measureQuality !== false) scores[key] = jpeg.quality;
22647
+ continue;
22648
+ }
22585
22649
  if (encoded === void 0 || encoded.byteLength >= image.byteLength && target.width === decoded.width && target.height === decoded.height && !explicit && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType())) {
22586
22650
  const downsampled = target.width !== decoded.width || target.height !== decoded.height;
22587
22651
  let retained = image;
@@ -22624,6 +22688,10 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22624
22688
  };
22625
22689
  }
22626
22690
  texture.setImage(encoded).setMimeType("image/ktx2");
22691
+ if (!charged && options.astc !== true) {
22692
+ transcoderRequired = true;
22693
+ transcoderByImage[key] = { ...transcoderByImage[key], required: true };
22694
+ }
22627
22695
  formats[key] = options.astc === true ? "astc4x4" : selected.codec;
22628
22696
  bytesAfter += encoded.byteLength;
22629
22697
  gpuBytesAfter += gpuBytes(target.width, target.height, formats[key] ?? selected.codec);
@@ -22651,7 +22719,10 @@ async function compressEmbeddedTextures(document, logicalPath, options = {}, rec
22651
22719
  gpuBytesAfter,
22652
22720
  gpuBytesBefore,
22653
22721
  resized,
22654
- skippedCompression
22722
+ skippedCompression,
22723
+ ...transcoderSavings > 0 ? { transcoderSavings } : {},
22724
+ ...transcoderRequired ? { transcoderRequired: true } : {},
22725
+ ...Object.keys(transcoderByImage).length === 0 ? {} : { transcoderByImage }
22655
22726
  };
22656
22727
  }
22657
22728
  function textureQualitySemantics(texture) {
@@ -22709,12 +22780,6 @@ function texturePass(options = {}) {
22709
22780
  },
22710
22781
  apply: async (input, logicalPath) => {
22711
22782
  if (classify(logicalPath) !== "texture") return input;
22712
- const stats = textureStats(input);
22713
- if (stats.width <= 0 || stats.height <= 0) {
22714
- throw new Error(
22715
- `TN_ASSETS_TEXTURE_UNREADABLE: '${logicalPath}' has no readable PNG/JPEG header; the KTX2 pass cannot encode it.`
22716
- );
22717
- }
22718
22783
  const decoded = await decodeImageBytes(input, logicalPath);
22719
22784
  const choice = texturePolicy(logicalPath, options);
22720
22785
  if (choice.codec === "none")
@@ -24146,6 +24211,90 @@ function resolveCompactOptions(options) {
24146
24211
  };
24147
24212
  }
24148
24213
 
24214
+ // src/passes/glb-spec-defaults.ts
24215
+ var JSON_CHUNK = 1313821514;
24216
+ var HEADER = 12;
24217
+ function withoutSpecDefaults(glb) {
24218
+ if (glb.readUInt32LE(16) !== JSON_CHUNK) return glb;
24219
+ const jsonLength = glb.readUInt32LE(12);
24220
+ const json = JSON.parse(glb.subarray(20, 20 + jsonLength).toString("utf8"));
24221
+ for (const accessor of json.accessors ?? []) {
24222
+ if (accessor.normalized === false) accessor.normalized = void 0;
24223
+ if (accessor.byteOffset === 0) accessor.byteOffset = void 0;
24224
+ }
24225
+ for (const view of json.bufferViews ?? []) if (view.byteOffset === 0) view.byteOffset = void 0;
24226
+ for (const animation of json.animations ?? [])
24227
+ for (const sampler of animation.samplers)
24228
+ if (sampler.interpolation === "LINEAR") sampler.interpolation = void 0;
24229
+ const text = Buffer.from(JSON.stringify(json), "utf8");
24230
+ const padded2 = Buffer.alloc(Math.ceil(text.length / 4) * 4, 32);
24231
+ text.copy(padded2);
24232
+ const rest = glb.subarray(HEADER + 8 + jsonLength);
24233
+ const head = Buffer.alloc(HEADER + 8);
24234
+ glb.copy(head, 0, 0, HEADER);
24235
+ head.writeUInt32LE(HEADER + 8 + padded2.length + rest.length, 8);
24236
+ head.writeUInt32LE(padded2.length, 12);
24237
+ head.writeUInt32LE(JSON_CHUNK, 16);
24238
+ return Buffer.concat([head, padded2, rest]);
24239
+ }
24240
+
24241
+ // src/passes/prune-rest-tracks.ts
24242
+ var COMPONENTS = { rotation: 4, scale: 3, translation: 3 };
24243
+ var REST_TOLERANCE = 1e-6;
24244
+ function restOf(node, path10) {
24245
+ if (path10 === "rotation") return node.getRotation();
24246
+ return path10 === "translation" ? node.getTranslation() : node.getScale();
24247
+ }
24248
+ function holdsRest(channel) {
24249
+ const path10 = channel.getTargetPath();
24250
+ const node = channel.getTargetNode();
24251
+ const output = channel.getSampler()?.getOutput();
24252
+ if (path10 === null || !(path10 in COMPONENTS) || node === null || !output) return false;
24253
+ const rest = restOf(node, path10);
24254
+ const element = new Array(COMPONENTS[path10]).fill(0);
24255
+ for (let key = 0; key < output.getCount(); key += 1) {
24256
+ output.getElement(key, element);
24257
+ if (element.some((value, c) => Math.abs(value - (rest[c] ?? 0)) > REST_TOLERANCE)) return false;
24258
+ }
24259
+ return output.getCount() > 0;
24260
+ }
24261
+ var lastKey = (channel) => {
24262
+ const input = channel.getSampler()?.getInput();
24263
+ return input === null || input === void 0 ? 0 : input.getMax([0])[0] ?? 0;
24264
+ };
24265
+ function dropChannel(animation, channel) {
24266
+ const sampler = channel.getSampler();
24267
+ animation.removeChannel(channel);
24268
+ channel.dispose();
24269
+ const shared = sampler?.listParents().some((parent) => parent !== animation && parent.propertyType === "AnimationChannel");
24270
+ if (sampler === null || sampler === void 0 || shared === true) return;
24271
+ animation.removeSampler(sampler);
24272
+ sampler.dispose();
24273
+ }
24274
+ function pruneRestPoseTracks(document, logicalPath) {
24275
+ let dropped = 0;
24276
+ let total = 0;
24277
+ for (const animation of document.getRoot().listAnimations()) {
24278
+ const kept = new Set(animation.listChannels());
24279
+ total += kept.size;
24280
+ const duration = Math.max(0, ...[...kept].map(lastKey));
24281
+ for (const channel of animation.listChannels()) {
24282
+ if (kept.size === 1 || !holdsRest(channel)) continue;
24283
+ kept.delete(channel);
24284
+ if (Math.max(0, ...[...kept].map(lastKey)) < duration) kept.add(channel);
24285
+ else {
24286
+ dropChannel(animation, channel);
24287
+ dropped += 1;
24288
+ }
24289
+ }
24290
+ }
24291
+ if (dropped > 0 && logicalPath !== void 0)
24292
+ console.log(
24293
+ `animation ${logicalPath}: dropped ${String(dropped)} of ${String(total)} tracks that hold the rest pose`
24294
+ );
24295
+ return { dropped, total };
24296
+ }
24297
+
24149
24298
  // src/passes/quantize-static.ts
24150
24299
  async function quantizeStaticGeometry(document, quantize2) {
24151
24300
  const held = holdDeformingMeshes(document);
@@ -24310,6 +24459,14 @@ function parseFileName(name) {
24310
24459
  if (key === void 0 || codec === void 0 || mimeType === void 0) return void 0;
24311
24460
  return { codec, key, mimeType };
24312
24461
  }
24462
+ function readTranscoderRecord(value) {
24463
+ if (typeof value !== "object" || value === null) return {};
24464
+ const record = value;
24465
+ return {
24466
+ ...typeof record.savings === "number" && Number.isFinite(record.savings) && record.savings > 0 ? { transcoderSavings: record.savings } : {},
24467
+ ...record.required === true ? { transcoderRequired: true } : {}
24468
+ };
24469
+ }
24313
24470
  function sharedImageKey(source, settings) {
24314
24471
  return createHash("sha256").update(source).update(JSON.stringify(settings), "utf8").digest("hex").slice(0, KEY_LENGTH);
24315
24472
  }
@@ -24386,6 +24543,17 @@ function createSharedImageStore(outputRoot, options = {}) {
24386
24543
  return {};
24387
24544
  throw error;
24388
24545
  }
24546
+ })(),
24547
+ ...await (async () => {
24548
+ try {
24549
+ return readTranscoderRecord(
24550
+ JSON.parse(await readFile(path8.join(directory, `${name}.transcoder.json`), "utf8"))
24551
+ );
24552
+ } catch (error) {
24553
+ if (error.code === "ENOENT" || error instanceof SyntaxError)
24554
+ return {};
24555
+ throw error;
24556
+ }
24389
24557
  })()
24390
24558
  };
24391
24559
  memory.set(key, image);
@@ -24397,7 +24565,8 @@ function createSharedImageStore(outputRoot, options = {}) {
24397
24565
  if (directory === void 0 || options.writeThrough === false) return;
24398
24566
  const name = fileNameFor(key, image);
24399
24567
  const known = await onDisk();
24400
- if (known.has(key) && image.quality === void 0) return;
24568
+ if (known.has(key) && image.quality === void 0 && image.transcoderSavings === void 0 && image.transcoderRequired !== true)
24569
+ return;
24401
24570
  await mkdir(directory, { recursive: true });
24402
24571
  const target = path8.join(directory, name);
24403
24572
  const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
@@ -24418,6 +24587,23 @@ function createSharedImageStore(outputRoot, options = {}) {
24418
24587
  await rm(qualityTemporary, { force: true }).catch(() => void 0);
24419
24588
  }
24420
24589
  }
24590
+ if (image.transcoderSavings !== void 0 || image.transcoderRequired === true) {
24591
+ const transcoderTarget = `${target}.transcoder.json`;
24592
+ const transcoderTemporary = `${transcoderTarget}.${process.pid}.${randomUUID()}.tmp`;
24593
+ try {
24594
+ await writeFile(
24595
+ transcoderTemporary,
24596
+ `${JSON.stringify({
24597
+ ...image.transcoderSavings === void 0 ? {} : { savings: image.transcoderSavings },
24598
+ ...image.transcoderRequired === true ? { required: true } : {}
24599
+ })}
24600
+ `
24601
+ );
24602
+ await rename(transcoderTemporary, transcoderTarget);
24603
+ } finally {
24604
+ await rm(transcoderTemporary, { force: true }).catch(() => void 0);
24605
+ }
24606
+ }
24421
24607
  known.set(key, name);
24422
24608
  }
24423
24609
  };
@@ -24540,6 +24726,7 @@ async function readSharedGlb(io, buffer, resolve2) {
24540
24726
  var DRACO_EXTENSION2 = "KHR_draco_mesh_compression";
24541
24727
  var EXT_MESHOPT_EXTENSION = "EXT_meshopt_compression";
24542
24728
  var MATERIAL_MERGE_VERSION = 1;
24729
+ var SHARED_TRANSCODER_ACCOUNTING_VERSION = 1;
24543
24730
  var MODEL_DEDUPE_VERSION = 1;
24544
24731
  var MODEL_REORDER_VERSION = 1;
24545
24732
  var MODEL_QUANTIZE_VERSION = 1;
@@ -24569,7 +24756,7 @@ async function writeDocument(document, logicalPath, vertexLayout = "interleaved"
24569
24756
  await MeshoptEncoder.ready;
24570
24757
  const io = new NodeIO().setVertexLayout(vertexLayout === "separate" ? VertexLayout.SEPARATE : VertexLayout.INTERLEAVED).registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc, TNVirtualGeometry, TNDiscreteLod]).registerDependencies({ "meshopt.encoder": MeshoptEncoder });
24571
24758
  try {
24572
- const buffer = Buffer.from(await io.writeBinary(document));
24759
+ const buffer = withoutSpecDefaults(Buffer.from(await io.writeBinary(document)));
24573
24760
  return { buffer, extensions: jsonOfGlb(buffer).extensionsUsed ?? [] };
24574
24761
  } catch (error) {
24575
24762
  const detail = error instanceof Error ? error.message : String(error);
@@ -24943,7 +25130,8 @@ function modelPass(options = {}) {
24943
25130
  meshopt: options.passes?.meshopt ?? true,
24944
25131
  prune: options.passes?.prune ?? true,
24945
25132
  quantize: options.passes?.quantize ?? true,
24946
- reorder: options.passes?.reorder ?? true
25133
+ reorder: options.passes?.reorder ?? true,
25134
+ restTracks: options.passes?.restTracks ?? true
24947
25135
  },
24948
25136
  ...options.vertexLayout === void 0 ? {} : { vertexLayout: options.vertexLayout },
24949
25137
  quantize: {
@@ -24978,13 +25166,17 @@ function modelPass(options = {}) {
24978
25166
  bakeVersion: VIRTUAL_BAKE_VERSION
24979
25167
  },
24980
25168
  sharedImages: options.sharedImages !== void 0,
25169
+ ...options.sharedImages === void 0 ? {} : { sharedTranscoderAccounting: SHARED_TRANSCODER_ACCOUNTING_VERSION },
24981
25170
  textures: options.textures === "none" ? "none" : {
24982
25171
  ...options.textures?.astc === true ? { astc: "raw4x4-gltf-authored-basis-v2" } : {},
24983
25172
  decoderFree: options.textures?.decoderFree ?? false,
24984
25173
  encoder: KTX2_ENCODER_VERSION,
24985
25174
  instrument: IMAGE_QUALITY_IDENTITY,
25175
+ jpeg: options.textures?.jpeg ?? null,
25176
+ jpegLadder: "colour-opaque-q75-90-v1",
24986
25177
  floor: resolveImageQualityFloor(options.textures?.floor),
24987
25178
  maxSize: options.textures?.maxSize ?? null,
25179
+ transcoderBytes: options.textures?.transcoderBytes ?? null,
24988
25180
  // Retention is now conditioned on the container, so it is spelled out rather than
24989
25181
  // named by a boolean: a warm cache keyed on the old rule would keep shipping the
24990
25182
  // WebP-requiring models this replaced.
@@ -25005,7 +25197,8 @@ function modelPass(options = {}) {
25005
25197
  meshopt: passes.meshopt ?? true,
25006
25198
  prune: passes.prune ?? true,
25007
25199
  quantize: passes.quantize ?? true,
25008
- reorder: passes.reorder ?? true
25200
+ reorder: passes.reorder ?? true,
25201
+ restTracks: passes.restTracks ?? true
25009
25202
  };
25010
25203
  const textureOptions = options.textures === "none" ? void 0 : options.textures ?? {};
25011
25204
  const virtualOptions = options.virtual === "none" ? void 0 : options.virtual ?? {};
@@ -25023,6 +25216,7 @@ function modelPass(options = {}) {
25023
25216
  if (enabled.dedup) await dedup()(document);
25024
25217
  const namesBeforeCompact = compactRequested(options.compact) ? sceneNodeNames(document) : void 0;
25025
25218
  let compact = compactRequested(options.compact) ? await compactModel(document, resolveCompactOptions(options.compact)) : void 0;
25219
+ if (enabled.restTracks) pruneRestPoseTracks(document, logicalPath);
25026
25220
  if (enabled.prune) {
25027
25221
  await prune({ keepAttributes: options.preserveLightmapUv === true })(document);
25028
25222
  if (compact !== void 0 && namesBeforeCompact !== void 0) {
@@ -25080,7 +25274,8 @@ function modelPass(options = {}) {
25080
25274
  store,
25081
25275
  shared,
25082
25276
  embeddedTextures?.formats,
25083
- embeddedTextures?.quality
25277
+ embeddedTextures?.quality,
25278
+ embeddedTextures?.transcoderByImage
25084
25279
  );
25085
25280
  if (enabled.meshopt) {
25086
25281
  document.createExtension(EXTMeshoptCompression).setRequired(true).setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
@@ -25153,11 +25348,14 @@ function sharedSettings(texture, textureOptions) {
25153
25348
  decoderFree: textureOptions.decoderFree ?? false,
25154
25349
  encoder: KTX2_ENCODER_VERSION,
25155
25350
  instrument: IMAGE_QUALITY_IDENTITY,
25351
+ jpeg: textureOptions.jpeg ?? null,
25352
+ jpegLadder: "colour-opaque-q75-90-v1",
25156
25353
  floor: resolveImageQualityFloor(textureOptions.floor),
25157
25354
  keepSmallerSource: "universal-containers",
25158
25355
  maxSize: resolveTextureMaxSize(textureOptions.maxSize, listTextureSlots(texture)),
25159
25356
  overrides: textureOptions.overrides ?? [],
25160
- quality: textureOptions.quality ?? null
25357
+ quality: textureOptions.quality ?? null,
25358
+ transcoderBytes: textureOptions.transcoderBytes ?? null
25161
25359
  }
25162
25360
  };
25163
25361
  }
@@ -25181,7 +25379,9 @@ async function recallSharedImages(document, store, textureOptions) {
25181
25379
  recalled.set(index, {
25182
25380
  codec: stored.codec,
25183
25381
  sourceBytes: image.byteLength,
25184
- quality: stored.quality
25382
+ quality: stored.quality,
25383
+ ...stored.transcoderSavings === void 0 ? {} : { transcoderSavings: stored.transcoderSavings },
25384
+ ...stored.transcoderRequired === true ? { transcoderRequired: true } : {}
25185
25385
  });
25186
25386
  }
25187
25387
  return { keys, recalled };
@@ -25190,7 +25390,7 @@ function codecOf(mimeType, formats, name) {
25190
25390
  if (mimeType !== "image/ktx2") return "none";
25191
25391
  return formats?.[name] ?? "uastc";
25192
25392
  }
25193
- async function rememberSharedImages(document, store, plan, formats, quality2) {
25393
+ async function rememberSharedImages(document, store, plan, formats, quality2, transcoder) {
25194
25394
  const textures = document.getRoot().listTextures();
25195
25395
  const names = textureKeys(document.getRoot());
25196
25396
  for (const [index, texture] of textures.entries()) {
@@ -25198,11 +25398,15 @@ async function rememberSharedImages(document, store, plan, formats, quality2) {
25198
25398
  const key = plan.keys[index];
25199
25399
  const image = texture.getImage();
25200
25400
  if (key === void 0 || key === "" || image === null) continue;
25401
+ const name = names[index] ?? "";
25402
+ const contribution = transcoder?.[name];
25201
25403
  await store.put(key, {
25202
25404
  buffer: Buffer.from(image.buffer, image.byteOffset, image.byteLength),
25203
- codec: codecOf(texture.getMimeType(), formats, names[index] ?? ""),
25405
+ codec: codecOf(texture.getMimeType(), formats, name),
25204
25406
  mimeType: texture.getMimeType(),
25205
- ...quality2?.[names[index] ?? ""] === void 0 ? {} : { quality: quality2?.[names[index] ?? ""] }
25407
+ ...quality2?.[name] === void 0 ? {} : { quality: quality2?.[name] },
25408
+ ...contribution?.savings === void 0 ? {} : { transcoderSavings: contribution.savings },
25409
+ ...contribution?.required === true ? { transcoderRequired: true } : {}
25206
25410
  });
25207
25411
  }
25208
25412
  }
@@ -25215,6 +25419,8 @@ function declareSharedImage(store, logicalPath, auxiliaryOutputs, candidate) {
25215
25419
  metadata: {
25216
25420
  codec: candidate.image.codec,
25217
25421
  key: candidate.key,
25422
+ transcoderRequired: candidate.image.transcoderRequired === true,
25423
+ transcoderSavings: candidate.image.transcoderSavings ?? 0,
25218
25424
  ...candidate.image.quality === void 0 ? {} : { quality: candidate.image.quality }
25219
25425
  },
25220
25426
  outputPath,
@@ -26414,7 +26620,9 @@ function embeddedTextureRow(value) {
26414
26620
  } : {},
26415
26621
  gpuBytesAfter: value.gpuBytesAfter,
26416
26622
  gpuBytesBefore: value.gpuBytesBefore,
26417
- resized: value.resized
26623
+ resized: value.resized,
26624
+ ...typeof value.transcoderSavings === "number" && value.transcoderSavings > 0 ? { transcoderSavings: value.transcoderSavings } : {},
26625
+ ...value.transcoderRequired === true ? { transcoderRequired: true } : {}
26418
26626
  };
26419
26627
  }
26420
26628
  function audioRow(value) {
@@ -27128,7 +27336,16 @@ function resolveLayout(cwd, options) {
27128
27336
  });
27129
27337
  if (models !== void 0) {
27130
27338
  const modelOptions = options.platform === void 0 || options.platform === "web" ? models : { ...models, vertexLayout: "separate" };
27131
- const modelOptionsWithLod = { ...modelOptions, lod: configuredLod };
27339
+ const modelOptionsWithLod = {
27340
+ ...modelOptions,
27341
+ lod: configuredLod,
27342
+ ...options.modelTranscoderBytes === void 0 || modelOptions.textures === "none" ? {} : {
27343
+ textures: {
27344
+ ...modelOptions.textures ?? {},
27345
+ transcoderBytes: options.modelTranscoderBytes
27346
+ }
27347
+ }
27348
+ };
27132
27349
  const pass = modelPass({
27133
27350
  ...modelOptionsWithLod,
27134
27351
  preserveLightmapUv: lightmap !== void 0,
@@ -27469,7 +27686,8 @@ async function writeOutput(outputRoot, entry, buffer) {
27469
27686
  }
27470
27687
  async function nativeTextureEntry(name, data) {
27471
27688
  const jpeg = data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255;
27472
- if (!jpeg && parsePng(data) === void 0) return void 0;
27689
+ const webp = data.length >= 12 && data.toString("ascii", 0, 4) === "RIFF" && data.toString("ascii", 8, 12) === "WEBP";
27690
+ if (!jpeg && !webp && parsePng(data) === void 0) return void 0;
27473
27691
  const image = await decodeImageBytes(data, name);
27474
27692
  const header = Buffer.alloc(12);
27475
27693
  header.writeUInt32LE(image.width, 0);
@@ -27673,7 +27891,62 @@ async function copyBasisTranscoder(outputRoot, transcoder) {
27673
27891
  );
27674
27892
  }
27675
27893
  }
27894
+ function addTranscoderCharge(entry, state) {
27895
+ if (entry.kind === "texture" && entry.format !== void 0) {
27896
+ if (entry.format !== "none" && entry.format !== "astc4x4") state.paid = true;
27897
+ return;
27898
+ }
27899
+ const row = entry.embeddedTextures;
27900
+ if (row === void 0 || state.countedOutputs.has(entry.output)) return;
27901
+ state.countedOutputs.add(entry.output);
27902
+ state.paid ||= row.transcoderRequired === true;
27903
+ const images = entry.sharedImages;
27904
+ if (images === void 0 || images.length === 0) {
27905
+ state.savings += row.transcoderSavings ?? 0;
27906
+ return;
27907
+ }
27908
+ const detailed = images.filter(isAccountedSharedImage);
27909
+ if (detailed.length !== images.length) {
27910
+ state.needsSharedRecount ||= (row.transcoderSavings ?? 0) > 0;
27911
+ return;
27912
+ }
27913
+ for (const image of detailed) {
27914
+ if (state.countedImages.has(image.key)) continue;
27915
+ state.countedImages.add(image.key);
27916
+ state.paid ||= image.transcoderRequired;
27917
+ state.savings += image.transcoderSavings;
27918
+ }
27919
+ }
27920
+ function modelTranscoderCharge(entries) {
27921
+ const state = {
27922
+ countedImages: /* @__PURE__ */ new Set(),
27923
+ countedOutputs: /* @__PURE__ */ new Set(),
27924
+ needsSharedRecount: false,
27925
+ paid: false,
27926
+ savings: 0
27927
+ };
27928
+ for (const entry of Object.values(entries)) addTranscoderCharge(entry, state);
27929
+ if (state.needsSharedRecount) return 0;
27930
+ if (state.savings === 0) return "either";
27931
+ return state.paid || state.savings >= BASIS_TRANSCODER_BYTES ? 0 : void 0;
27932
+ }
27933
+ function isAccountedSharedImage(value) {
27934
+ return isRecord4(value) && typeof value.key === "string" && typeof value.transcoderRequired === "boolean" && typeof value.transcoderSavings === "number";
27935
+ }
27676
27936
  async function compileAssets(options = {}) {
27937
+ if (options.modelTranscoderBytes !== void 0) return compileOnce(options);
27938
+ const cwd = path8.resolve(options.cwd ?? process.cwd());
27939
+ const manifestPath = path8.join(resolveLayout(cwd, options).outputRoot, MANIFEST_NAME);
27940
+ const charge = async () => modelTranscoderCharge((await readExistingManifest(manifestPath)).entries);
27941
+ const predicted = await charge();
27942
+ const used = predicted === "either" ? void 0 : predicted;
27943
+ const result = await compileOnce({ ...options, modelTranscoderBytes: used });
27944
+ const decided = await charge();
27945
+ if (decided === "either" || decided === used) return result;
27946
+ const second = await compileOnce({ ...options, modelTranscoderBytes: decided });
27947
+ return { ...second, written: result.written + second.written };
27948
+ }
27949
+ async function compileOnce(options) {
27677
27950
  const cwd = path8.resolve(options.cwd ?? process.cwd());
27678
27951
  const layout = resolveLayout(cwd, options);
27679
27952
  const receiptPath = path8.join(layout.outputRoot, RECEIPT_NAME);
@@ -27809,7 +28082,7 @@ async function compileAssets(options = {}) {
27809
28082
  let written = 0;
27810
28083
  let skipped = 0;
27811
28084
  let dedupeSavedBytes = 0;
27812
- let textureCount = 0;
28085
+ let ktx2TextureCount = 0;
27813
28086
  let compressedModelCount = 0;
27814
28087
  if (logicals.length === 0) {
27815
28088
  for (const line of formatBudget(
@@ -27884,7 +28157,8 @@ async function compileAssets(options = {}) {
27884
28157
  format: entry.format,
27885
28158
  logicalPath: logical
27886
28159
  });
27887
- textureCount += 1;
28160
+ if (entry.format !== void 0 && entry.format !== "none" && entry.format !== "astc4x4")
28161
+ ktx2TextureCount += 1;
27888
28162
  }
27889
28163
  };
27890
28164
  const concurrency = resolveConcurrency(options.concurrency ?? layout.concurrency);
@@ -28040,7 +28314,7 @@ async function compileAssets(options = {}) {
28040
28314
  } finally {
28041
28315
  await pool?.dispose();
28042
28316
  }
28043
- if (layout.texturesActive && textureCount > 0 || compressedModelCount > 0) {
28317
+ if (ktx2TextureCount > 0 || compressedModelCount > 0) {
28044
28318
  const basisJs = path8.join(layout.outputRoot, BASIS_DIRECTORY, "basis_transcoder.js");
28045
28319
  if (written > 0 || !await outputExists(basisJs)) {
28046
28320
  await recordPendingOutputs([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threenative/assets",
3
- "version": "0.3.7",
3
+ "version": "0.3.10",
4
4
  "license": "MIT",
5
5
  "description": "Content-addressed asset compile step for ThreeNative games.",
6
6
  "keywords": [
@@ -51,7 +51,7 @@
51
51
  "tsup": "8.5.1",
52
52
  "typescript": "npm:@typescript/typescript6@6.0.2",
53
53
  "@typescript/native": "npm:typescript@7.0.2",
54
- "threenative-blender-mcp": "0.1.6"
54
+ "@threenative/blender-bridge": "0.2.2"
55
55
  },
56
56
  "dependencies": {
57
57
  "@gltf-transform/core": "4.4.2",