threenative-asset-mcp 0.9.2 → 0.9.4
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 +4 -1
- package/dist/bundle/client.d.ts +2 -0
- package/dist/bundle/client.d.ts.map +1 -1
- package/dist/bundle/client.js +6 -0
- package/dist/bundle/client.js.map +1 -1
- package/dist/download/direct-asset-downloader.d.ts +3 -0
- package/dist/download/direct-asset-downloader.d.ts.map +1 -1
- package/dist/download/direct-asset-downloader.js +4 -1
- package/dist/download/direct-asset-downloader.js.map +1 -1
- package/dist/fab/api-download.d.ts.map +1 -1
- package/dist/fab/api-download.js +23 -3
- package/dist/fab/api-download.js.map +1 -1
- package/dist/fab/client.d.ts.map +1 -1
- package/dist/fab/client.js +5 -10
- package/dist/fab/client.js.map +1 -1
- package/dist/fab/direct-transport.d.ts +3 -0
- package/dist/fab/direct-transport.d.ts.map +1 -1
- package/dist/fab/direct-transport.js.map +1 -1
- package/dist/fab/download-store.d.ts.map +1 -1
- package/dist/fab/download-store.js +6 -0
- package/dist/fab/download-store.js.map +1 -1
- package/dist/fab/fabcli.d.ts +1 -1
- package/dist/fab/fabcli.d.ts.map +1 -1
- package/dist/fab/fabcli.js +20 -4
- package/dist/fab/fabcli.js.map +1 -1
- package/dist/fab/normalize.d.ts.map +1 -1
- package/dist/fab/normalize.js +4 -0
- package/dist/fab/normalize.js.map +1 -1
- package/dist/gltf-size.d.ts +19 -0
- package/dist/gltf-size.d.ts.map +1 -0
- package/dist/gltf-size.js +33 -0
- package/dist/gltf-size.js.map +1 -0
- package/dist/server.js +5 -5
- package/dist/server.js.map +1 -1
- package/dist/tools/bundle.d.ts +10 -0
- package/dist/tools/bundle.d.ts.map +1 -1
- package/dist/tools/bundle.js +6 -0
- package/dist/tools/bundle.js.map +1 -1
- package/dist/tools/direct-download.d.ts +10 -0
- package/dist/tools/direct-download.d.ts.map +1 -1
- package/dist/tools/direct-download.js +7 -0
- package/dist/tools/direct-download.js.map +1 -1
- package/dist/tools/download-free-asset.d.ts +10 -0
- package/dist/tools/download-free-asset.d.ts.map +1 -1
- package/dist/tools/download-free-asset.js +6 -0
- package/dist/tools/download-free-asset.js.map +1 -1
- package/dist/tools/import-unreal.d.ts +40 -0
- package/dist/tools/import-unreal.d.ts.map +1 -1
- package/dist/tools/import-unreal.js +13 -0
- package/dist/tools/import-unreal.js.map +1 -1
- package/dist/tools/list-filters.d.ts +1 -15
- package/dist/tools/list-filters.d.ts.map +1 -1
- package/dist/tools/list-filters.js +1 -3
- package/dist/tools/list-filters.js.map +1 -1
- package/dist/tools/search-assets.d.ts +2 -4
- package/dist/tools/search-assets.d.ts.map +1 -1
- package/dist/tools/search-assets.js +1 -2
- package/dist/tools/search-assets.js.map +1 -1
- package/dist/unreal/cooking.d.ts +7 -0
- package/dist/unreal/cooking.d.ts.map +1 -1
- package/dist/unreal/cooking.js +101 -62
- package/dist/unreal/cooking.js.map +1 -1
- package/dist/unreal/importer.d.ts +8 -1
- package/dist/unreal/importer.d.ts.map +1 -1
- package/dist/unreal/importer.js +181 -75
- package/dist/unreal/importer.js.map +1 -1
- package/dist/unreal/materials.d.ts.map +1 -1
- package/dist/unreal/materials.js +39 -7
- package/dist/unreal/materials.js.map +1 -1
- package/dist/unreal/provision.d.ts +2 -0
- package/dist/unreal/provision.d.ts.map +1 -1
- package/dist/unreal/provision.js +15 -4
- package/dist/unreal/provision.js.map +1 -1
- package/dist/unreal/scenes.d.ts +7 -0
- package/dist/unreal/scenes.d.ts.map +1 -1
- package/dist/unreal/scenes.js +13 -1
- package/dist/unreal/scenes.js.map +1 -1
- package/dist/unreal/toolchain.d.ts.map +1 -1
- package/dist/unreal/toolchain.js +11 -1
- package/dist/unreal/toolchain.js.map +1 -1
- package/package.json +1 -1
package/dist/unreal/importer.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Document, NodeIO, VertexLayout } from "@gltf-transform/core";
|
|
|
8
8
|
import { EXTMeshGPUInstancing, KHRLightsPunctual, KHRMaterialsUnlit } from "@gltf-transform/extensions";
|
|
9
9
|
import { attachPsaAnimations, parsePsa } from "./psa.js";
|
|
10
10
|
import { resolveMaterial, } from "./materials.js";
|
|
11
|
-
import { readPackageCooking } from "./cooking.js";
|
|
11
|
+
import { readPackageCooking, readPackageObjectNames } from "./cooking.js";
|
|
12
12
|
import { extractUnrealFonts } from "./fonts.js";
|
|
13
13
|
import { parseOfflineFontDescriptor, writeOfflineFont } from "./bitmap-fonts.js";
|
|
14
14
|
import { paperObjectPathToPackage, parsePaperFlipbookDescriptor, parsePaperSpriteDescriptor, writePaperSpriteGlb, } from "./paper2d.js";
|
|
@@ -18,7 +18,21 @@ import { assembleSceneGlb, parseUnrealSceneSource, } from "./scenes.js";
|
|
|
18
18
|
import { ToolchainError, assertSupportedHost, runBounded } from "./toolchain.js";
|
|
19
19
|
const statfsAsync = promisify(statfs);
|
|
20
20
|
/** Bumped whenever the conversion contract changes; it participates in the reuse cache key. */
|
|
21
|
-
export const IMPORTER_VERSION =
|
|
21
|
+
export const IMPORTER_VERSION = 46;
|
|
22
|
+
/** First and last UE4 object versions whose uncooked StaticMesh source models are FMeshDescription
|
|
23
|
+
* bulk data (UE4.25–4.27), which only the engine-free converter reads. Below that window UE Viewer
|
|
24
|
+
* reads uncooked source geometry itself, static and skeletal alike: verified on FAB packs saved at
|
|
25
|
+
* object versions 401–516 (UE4.0–4.20), where it matched or beat every hand-written decoder. */
|
|
26
|
+
const MESH_DESCRIPTION_FIRST_VERSION = 517;
|
|
27
|
+
const MESH_DESCRIPTION_LAST_VERSION = 522;
|
|
28
|
+
/** Which tool decodes an uncooked mesh package, or undefined when it must be refused. */
|
|
29
|
+
export function uncookedMeshRoute(meshKind, fileVersionUE4) {
|
|
30
|
+
if (fileVersionUE4 !== undefined && fileVersionUE4 < MESH_DESCRIPTION_FIRST_VERSION)
|
|
31
|
+
return "umodel";
|
|
32
|
+
if (meshKind === "skeletal")
|
|
33
|
+
return "modern";
|
|
34
|
+
return fileVersionUE4 !== undefined && fileVersionUE4 <= MESH_DESCRIPTION_LAST_VERSION ? "mesh-description" : undefined;
|
|
35
|
+
}
|
|
22
36
|
export class ImportError extends Error {
|
|
23
37
|
code;
|
|
24
38
|
retryable;
|
|
@@ -57,6 +71,23 @@ export function summarizeClasses(classes, keep = 4) {
|
|
|
57
71
|
return unique.join(", ");
|
|
58
72
|
return `${unique.slice(0, keep).join(", ")} and ${unique.length - keep} more`;
|
|
59
73
|
}
|
|
74
|
+
/** Staging older than this belongs to an import that was killed before its `finally` ran. */
|
|
75
|
+
const STALE_STAGING_MS = 24 * 60 * 60 * 1000;
|
|
76
|
+
/** Removes `run-*` staging left by killed imports. One such run can hold gigabytes, and they
|
|
77
|
+
* accumulate until the cache volume fills and every later import fails its free-space check. */
|
|
78
|
+
export async function sweepStaleStaging(root, now = Date.now()) {
|
|
79
|
+
for (const key of await readdir(root).catch(() => [])) {
|
|
80
|
+
for (const run of await readdir(join(root, key)).catch(() => [])) {
|
|
81
|
+
if (!run.startsWith("run-"))
|
|
82
|
+
continue;
|
|
83
|
+
const path = join(root, key, run);
|
|
84
|
+
const modified = (await stat(path).catch(() => undefined))?.mtimeMs;
|
|
85
|
+
if (modified !== undefined && now - modified > STALE_STAGING_MS) {
|
|
86
|
+
await rm(path, { recursive: true, force: true }).catch(() => { });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
60
91
|
function cacheRoot(environment) {
|
|
61
92
|
return (environment.THREENATIVE_UNREAL_CACHE_DIR?.trim() ||
|
|
62
93
|
join(environment.XDG_CACHE_HOME?.trim() || join(homedir(), ".cache"), "threenative-asset-mcp", "unreal-import"));
|
|
@@ -815,7 +846,9 @@ export function inspectWebAudio(data) {
|
|
|
815
846
|
}
|
|
816
847
|
return undefined;
|
|
817
848
|
}
|
|
818
|
-
async function freeBytes(path) {
|
|
849
|
+
async function freeBytes(path, override) {
|
|
850
|
+
if (override !== undefined)
|
|
851
|
+
return override;
|
|
819
852
|
try {
|
|
820
853
|
const info = await statfsAsync(path);
|
|
821
854
|
return Number(info.bavail) * Number(info.bsize);
|
|
@@ -824,6 +857,66 @@ async function freeBytes(path) {
|
|
|
824
857
|
return Number.POSITIVE_INFINITY;
|
|
825
858
|
}
|
|
826
859
|
}
|
|
860
|
+
/** `Foo.uasset` is accompanied by `Foo.uexp`, `Foo.ubulk`, and `Foo.uptnl`; a partial import
|
|
861
|
+
* reads every file of the stem it converts, and only files of that stem. */
|
|
862
|
+
function packageStem(file) {
|
|
863
|
+
return join(dirname(file), basename(file, extname(file)));
|
|
864
|
+
}
|
|
865
|
+
/** Source bytes a filtered import will actually read: the requested packages, their sidecars, and
|
|
866
|
+
* the packages they name in their import tables — a mesh's materials and textures are separate
|
|
867
|
+
* packages and hold most of a pack's bytes. Counting the whole tree instead refuses every
|
|
868
|
+
* partial import on a full volume, and counting only the requested `.uasset` would let an import
|
|
869
|
+
* start that cannot finish. */
|
|
870
|
+
async function importedPackageBytes(files, wanted) {
|
|
871
|
+
const groups = new Map();
|
|
872
|
+
for (const file of files) {
|
|
873
|
+
const stem = packageStem(file.path);
|
|
874
|
+
const group = groups.get(stem) ?? { primary: file.path, bytes: 0 };
|
|
875
|
+
group.bytes += file.size;
|
|
876
|
+
if ([".uasset", ".umap"].includes(extname(file.path).toLowerCase()))
|
|
877
|
+
group.primary = file.path;
|
|
878
|
+
groups.set(stem, group);
|
|
879
|
+
}
|
|
880
|
+
const stemsByName = new Map();
|
|
881
|
+
for (const stem of groups.keys()) {
|
|
882
|
+
const name = basename(stem);
|
|
883
|
+
stemsByName.set(name, [...(stemsByName.get(name) ?? []), stem]);
|
|
884
|
+
}
|
|
885
|
+
let total = 0;
|
|
886
|
+
const counted = new Set();
|
|
887
|
+
let frontier = [...groups.keys()].filter((stem) => wanted.has(basename(stem)));
|
|
888
|
+
while (frontier.length > 0) {
|
|
889
|
+
const next = [];
|
|
890
|
+
for (const stem of frontier) {
|
|
891
|
+
counted.add(stem);
|
|
892
|
+
total += groups.get(stem).bytes;
|
|
893
|
+
// ponytail: a bounded byte scan for names, not a parsed import table. A name that merely
|
|
894
|
+
// shares a string with the package counts its bytes; a name in another encoding does not.
|
|
895
|
+
for (const name of await readPackageObjectNames(groups.get(stem).primary)) {
|
|
896
|
+
for (const referenced of stemsByName.get(name) ?? []) {
|
|
897
|
+
if (counted.has(referenced))
|
|
898
|
+
continue;
|
|
899
|
+
next.push(referenced);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
frontier = next;
|
|
904
|
+
}
|
|
905
|
+
return total;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Why the modern converter produced nothing, in the terms that decide the next move: what it
|
|
909
|
+
* printed (its last lines, not only the one it exits on), which engine wrote the package, and
|
|
910
|
+
* whether the mesh is Nanite — the commonest reason a UE5 editor mesh has no render data.
|
|
911
|
+
*/
|
|
912
|
+
function describeModernFailure(stderr, packages) {
|
|
913
|
+
const lines = stderr.trim().split(/\r?\n/).filter((line) => line.trim()).slice(-20);
|
|
914
|
+
const versions = packages.map(({ entry, legacyFileVersion, fileVersionUE4, fileVersionUE5, naniteHint }) => `${entry.package} is ${legacyFileVersion !== undefined && legacyFileVersion <= -8 ? "UE5" : "UE4"} (legacy file version ${legacyFileVersion ?? "unknown"}), UE4 object version ${fileVersionUE4 ?? "unknown"}, UE5 object version ${fileVersionUE5 ?? "unknown"}; Nanite: ${naniteHint ? "likely (NaniteSettings present)" : "not detected"}`);
|
|
915
|
+
return [
|
|
916
|
+
...versions,
|
|
917
|
+
...(lines.length > 0 ? [`Converter output (last ${lines.length} lines):`, ...lines.map((line) => ` ${line.trim()}`)] : []),
|
|
918
|
+
].join(" ");
|
|
919
|
+
}
|
|
827
920
|
/**
|
|
828
921
|
* Converts a local Unreal asset directory into self-contained source GLBs plus a provenance
|
|
829
922
|
* report. Provider-independent: a pack downloaded by FabCLI and one unzipped by hand take the
|
|
@@ -854,6 +947,11 @@ export async function importUnrealDirectory(request) {
|
|
|
854
947
|
throw new ImportError("UNREAL_SOURCE_EMPTY", `"${sourceDir}" contains no .uasset or .umap files.`);
|
|
855
948
|
}
|
|
856
949
|
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
950
|
+
// Only a single-package request is actually filtered: both converters take `--filter` for one
|
|
951
|
+
// package alone and convert the whole tree otherwise, so two or more still need the whole tree.
|
|
952
|
+
const neededBytes = request.onlyPackages?.length === 1 && request.onlyPackages[0]
|
|
953
|
+
? await importedPackageBytes(files, new Set(request.onlyPackages))
|
|
954
|
+
: totalBytes;
|
|
857
955
|
const sourceHash = await hashSourceTree(sourceDir, files);
|
|
858
956
|
const umodel = request.umodel ?? (await ensureUmodel(environment, log));
|
|
859
957
|
const cacheKey = createHash("sha256")
|
|
@@ -886,6 +984,7 @@ export async function importUnrealDirectory(request) {
|
|
|
886
984
|
throw new ImportError("UNREAL_OUTPUT_COLLISION", `${outputDir} is not empty and holds no import report. Choose a new output directory.`);
|
|
887
985
|
}
|
|
888
986
|
}
|
|
987
|
+
await sweepStaleStaging(cacheRoot(environment));
|
|
889
988
|
const stagingRoot = join(cacheRoot(environment), cacheKey);
|
|
890
989
|
await mkdir(stagingRoot, { recursive: true });
|
|
891
990
|
// The cache key identifies reusable results, not ownership of mutable scratch space. Give every
|
|
@@ -899,17 +998,17 @@ export async function importUnrealDirectory(request) {
|
|
|
899
998
|
// Unbounded scene assembly can temporarily hold model GLBs plus a source-sized aggregate GLB.
|
|
900
999
|
// Staging and output may be different volumes, so checking only the cache volume is insufficient.
|
|
901
1000
|
const minimum = 2 * 1024 ** 3;
|
|
902
|
-
const stagingRequired =
|
|
903
|
-
const outputRequired =
|
|
1001
|
+
const stagingRequired = neededBytes;
|
|
1002
|
+
const outputRequired = neededBytes * (request.maxTextureSize === undefined ? 2 : 1);
|
|
904
1003
|
const stagingDevice = (await stat(staging)).dev;
|
|
905
1004
|
const outputDevice = (await stat(promotionParent)).dev;
|
|
906
1005
|
const required = Math.max(stagingDevice === outputDevice ? stagingRequired + outputRequired : outputRequired, minimum);
|
|
907
|
-
const available = await freeBytes(promotionParent);
|
|
1006
|
+
const available = await freeBytes(promotionParent, request.freeSpaceBytes);
|
|
908
1007
|
if (available < required) {
|
|
909
1008
|
throw new ImportError("UNREAL_DISK_SPACE", `The import needs about ${Math.ceil(required / 1024 ** 3)} GiB on the output volume; ${Math.floor(available / 1024 ** 3)} GiB is free at ${promotionParent}. Use --max-texture-size or choose an output on a larger volume.`);
|
|
910
1009
|
}
|
|
911
1010
|
if (stagingDevice !== outputDevice) {
|
|
912
|
-
const stagingAvailable = await freeBytes(staging);
|
|
1011
|
+
const stagingAvailable = await freeBytes(staging, request.freeSpaceBytes);
|
|
913
1012
|
const separateStagingRequired = Math.max(stagingRequired, minimum);
|
|
914
1013
|
if (stagingAvailable < separateStagingRequired) {
|
|
915
1014
|
throw new ImportError("UNREAL_DISK_SPACE", `The import needs about ${Math.ceil(separateStagingRequired / 1024 ** 3)} GiB of staging space; ${Math.floor(stagingAvailable / 1024 ** 3)} GiB is free at ${staging}.`);
|
|
@@ -964,12 +1063,16 @@ export async function importUnrealDirectory(request) {
|
|
|
964
1063
|
? "static"
|
|
965
1064
|
: cooking?.meshKindHint;
|
|
966
1065
|
const isMapBuildData = classes.includes("MapBuildDataRegistry");
|
|
967
|
-
|
|
1066
|
+
// The name-table hints stand in for a listing UE Viewer could not produce. When it did list
|
|
1067
|
+
// the package, its classes win: a material instance names Texture2D and carries
|
|
1068
|
+
// AssetImportData too, and reading that as a texture drops the material entirely.
|
|
1069
|
+
const listed = run.code === 0 && classes.length > 0;
|
|
1070
|
+
const hasTexture = !isMapBuildData && (classes.includes("Texture2D") || (!listed && cooking?.textureHint === true));
|
|
968
1071
|
// MapBuildDataRegistry owns transient reflection-capture cubes that are tied to baked level
|
|
969
1072
|
// lighting. They are not standalone environment assets and cannot be meaningfully reused.
|
|
970
|
-
const hasCubemap = !isMapBuildData && (classes.includes("TextureCube") || cooking?.cubemapHint === true);
|
|
1073
|
+
const hasCubemap = !isMapBuildData && (classes.includes("TextureCube") || (!listed && cooking?.cubemapHint === true));
|
|
971
1074
|
const hasMaterial = classes.some((className) => ["Material", "Material3", "MaterialInstance", "MaterialInstanceConstant"].includes(className));
|
|
972
|
-
const hasSound = classes.includes("SoundWave") || cooking?.soundHint === true;
|
|
1075
|
+
const hasSound = classes.includes("SoundWave") || (!listed && cooking?.soundHint === true);
|
|
973
1076
|
const dataClass = classes.find((className) => DATA_ASSET_CLASSES.has(className)) ?? cooking?.dataClassHint;
|
|
974
1077
|
const textureStackClass = classes.find((className) => TEXTURE_STACK_CLASSES.has(className)) ?? cooking?.textureStackClassHint;
|
|
975
1078
|
const hasFont = classes.some((className) => className === "Font" || className === "FontFace") || cooking?.fontHint === true;
|
|
@@ -1132,64 +1235,43 @@ export async function importUnrealDirectory(request) {
|
|
|
1132
1235
|
package: entry.package,
|
|
1133
1236
|
...(await readPackageCooking(entry.file)),
|
|
1134
1237
|
}));
|
|
1135
|
-
const modernPackages = cooking.filter(({ entry, state }) => entry.needsModernConverter ||
|
|
1238
|
+
const modernPackages = cooking.filter(({ entry, state, fileVersionUE4 }) => entry.needsModernConverter ||
|
|
1239
|
+
(entry.meshKind === "skeletal" && state === "uncooked" && uncookedMeshRoute("skeletal", fileVersionUE4) === "modern"));
|
|
1136
1240
|
const modernTexturePackages = texturePackages.filter((entry) => entry.needsModernConverter);
|
|
1137
1241
|
const modernMaterialPackages = materialPackages.filter((entry) => entry.needsModernConverter);
|
|
1138
1242
|
const modernSoundPackages = soundPackages.filter((entry) => entry.needsModernConverter);
|
|
1139
1243
|
const modernFiles = new Set(modernPackages.map(({ entry }) => entry.file));
|
|
1140
1244
|
const exportableMeshPackages = assetMeshPackages.filter((entry) => !modernFiles.has(entry.file));
|
|
1141
1245
|
const uncooked = cooking.filter(({ entry, state }) => entry.meshKind === "static" && state === "uncooked" && !entry.needsModernConverter);
|
|
1142
|
-
const uncookedMeshDescription = uncooked.filter((entry) => entry.fileVersionUE4
|
|
1143
|
-
|
|
1144
|
-
entry.fileVersionUE4 <= 522);
|
|
1145
|
-
const legacyRawMesh = uncooked.filter((entry) => entry.fileVersionUE4 === 516);
|
|
1146
|
-
const unsupportedUncooked = uncooked.filter((entry) => !legacyRawMesh.includes(entry) &&
|
|
1147
|
-
(entry.fileVersionUE4 === undefined ||
|
|
1148
|
-
entry.fileVersionUE4 < 517 ||
|
|
1149
|
-
entry.fileVersionUE4 > 522));
|
|
1246
|
+
const uncookedMeshDescription = uncooked.filter((entry) => uncookedMeshRoute("static", entry.fileVersionUE4) === "mesh-description");
|
|
1247
|
+
const unsupportedUncooked = uncooked.filter((entry) => uncookedMeshRoute("static", entry.fileVersionUE4) === undefined);
|
|
1150
1248
|
if (unsupportedUncooked.length > 0) {
|
|
1151
1249
|
const versions = [...new Set(unsupportedUncooked.map((entry) => entry.fileVersionUE4 ?? "unknown"))];
|
|
1152
|
-
throw new ImportError("UNREAL_SOURCE_UNSUPPORTED", `${unsupportedUncooked.length} uncooked static-mesh package${unsupportedUncooked.length === 1 ? " uses" : "s use"} UE4 object version ${versions.join(", ")}.
|
|
1250
|
+
throw new ImportError("UNREAL_SOURCE_UNSUPPORTED", `${unsupportedUncooked.length} uncooked static-mesh package${unsupportedUncooked.length === 1 ? " uses" : "s use"} UE4 object version ${versions.join(", ")}. UE Viewer reads versions below 517 and the engine-free MeshDescription path reads 517–522 (UE4.25–4.27-era source assets); refusing to guess at a different binary layout.`);
|
|
1153
1251
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1252
|
+
/** UE Viewer's mesh export, in one place: the primary route for a mesh package, and the retry
|
|
1253
|
+
* for one the modern converter could not read. Returns the reason it failed, or undefined. */
|
|
1254
|
+
const exportMeshWithUmodel = async (entry, out) => {
|
|
1255
|
+
const run = () => runBounded(umodel.path, [`-path=${sourceDir}`, "-export", "-gltf", "-png", "-nooverwrite", `-out=${out}`, entry.selector], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
|
|
1158
1256
|
try {
|
|
1159
|
-
let run = await runBounded(umodel.path, [
|
|
1160
|
-
`-path=${sourceDir}`,
|
|
1161
|
-
"-export",
|
|
1162
|
-
"-gltf",
|
|
1163
|
-
"-png",
|
|
1164
|
-
"-nooverwrite",
|
|
1165
|
-
`-out=${raw}`,
|
|
1166
|
-
entry.selector,
|
|
1167
|
-
], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
|
|
1168
1257
|
// UE Viewer occasionally exits non-zero for one package under filesystem pressure even
|
|
1169
1258
|
// though the same read-only export succeeds immediately afterward. One bounded retry keeps
|
|
1170
1259
|
// a transient process failure from silently removing a mesh from an otherwise valid pack.
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
"-gltf",
|
|
1176
|
-
"-png",
|
|
1177
|
-
"-nooverwrite",
|
|
1178
|
-
`-out=${raw}`,
|
|
1179
|
-
entry.selector,
|
|
1180
|
-
], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
|
|
1181
|
-
}
|
|
1182
|
-
if (run.code !== 0) {
|
|
1183
|
-
return { package: entry.package, reason: `UE Viewer export exited ${run.code}.` };
|
|
1184
|
-
}
|
|
1185
|
-
return undefined;
|
|
1260
|
+
let outcome = await run();
|
|
1261
|
+
if (outcome.code !== 0)
|
|
1262
|
+
outcome = await run();
|
|
1263
|
+
return outcome.code === 0 ? undefined : `UE Viewer export exited ${outcome.code}.`;
|
|
1186
1264
|
}
|
|
1187
1265
|
catch (error) {
|
|
1188
|
-
return
|
|
1189
|
-
package: entry.package,
|
|
1190
|
-
reason: error instanceof ToolchainError ? error.message : "UE Viewer export failed.",
|
|
1191
|
-
};
|
|
1266
|
+
return error instanceof ToolchainError ? error.message : "UE Viewer export failed.";
|
|
1192
1267
|
}
|
|
1268
|
+
};
|
|
1269
|
+
// Packages share textures, so two exporters can write the same PNG at once and `-nooverwrite`
|
|
1270
|
+
// would see a half-written file as done. Listing is read-only and parallel; exporting is not.
|
|
1271
|
+
log(`Exporting ${exportableMeshPackages.length} static/skeletal mesh packages with UE Viewer…`);
|
|
1272
|
+
const exportFailures = await mapWithConcurrency(exportableMeshPackages, 1, async (entry) => {
|
|
1273
|
+
const reason = await exportMeshWithUmodel(entry, raw);
|
|
1274
|
+
return reason ? { package: entry.package, reason } : undefined;
|
|
1193
1275
|
});
|
|
1194
1276
|
for (const failure of exportFailures)
|
|
1195
1277
|
if (failure)
|
|
@@ -1366,9 +1448,14 @@ export async function importUnrealDirectory(request) {
|
|
|
1366
1448
|
`-out=${animationRaw}`,
|
|
1367
1449
|
entry.selector,
|
|
1368
1450
|
], { timeoutMs: 1_800_000, maxOutputBytes: 32 * 1024 * 1024 });
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1451
|
+
if (run.code === 0)
|
|
1452
|
+
return undefined;
|
|
1453
|
+
// UE Viewer prints its reason last; without it every failure reads the same.
|
|
1454
|
+
const diagnostic = `${run.stdout}\n${run.stderr}`.trim().split(/\r?\n/).filter((line) => line.trim()).at(-1)?.trim();
|
|
1455
|
+
return {
|
|
1456
|
+
package: entry.package,
|
|
1457
|
+
reason: `UE Viewer animation export exited ${run.code}.${diagnostic ? ` ${diagnostic.slice(0, 300)}` : ""}`,
|
|
1458
|
+
};
|
|
1372
1459
|
}
|
|
1373
1460
|
catch (error) {
|
|
1374
1461
|
return {
|
|
@@ -1409,6 +1496,8 @@ export async function importUnrealDirectory(request) {
|
|
|
1409
1496
|
let uncookedGlbs = new Map();
|
|
1410
1497
|
let modernConverter;
|
|
1411
1498
|
let modernGlbs = new Map();
|
|
1499
|
+
/** Meshes UE Viewer exported after the modern converter failed on them. */
|
|
1500
|
+
const recoveredByUmodel = new Set();
|
|
1412
1501
|
const modernSceneModelSources = [];
|
|
1413
1502
|
const sceneSources = join(staging, "scene-sources");
|
|
1414
1503
|
const sceneSourcePaths = new Map();
|
|
@@ -1463,26 +1552,41 @@ export async function importUnrealDirectory(request) {
|
|
|
1463
1552
|
maxOutputBytes: 64 * 1024 * 1024,
|
|
1464
1553
|
});
|
|
1465
1554
|
if (converted.code !== 0) {
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
const
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1555
|
+
// A static mesh old enough for UE Viewer never needed the modern converter, so one
|
|
1556
|
+
// converter crash must not take a mesh UE Viewer can read down with it. That fallback is only
|
|
1557
|
+
// honest when it covers everything: recovering part of the request would drop the rest
|
|
1558
|
+
// silently, so anything UE Viewer cannot supply reports the converter's own diagnostic.
|
|
1559
|
+
const retryable = modernPackages.filter(({ entry, fileVersionUE4 }) => entry.meshKind === "static" && uncookedMeshRoute("static", fileVersionUE4) === "umodel");
|
|
1560
|
+
const retried = await mapWithConcurrency(retryable, 1, async ({ entry }) => ({
|
|
1561
|
+
entry,
|
|
1562
|
+
reason: await exportMeshWithUmodel(entry, raw),
|
|
1563
|
+
}));
|
|
1564
|
+
const incomplete = retried.find((result) => result.reason !== undefined);
|
|
1565
|
+
if (retryable.length !== modernPackages.length || incomplete) {
|
|
1566
|
+
throw new ToolchainError("UNREAL_TOOL_FAILED", `The modern UE5 asset converter exited ${converted.code}; no partial output was promoted. ${describeModernFailure(converted.stderr, modernPackages)}`);
|
|
1567
|
+
}
|
|
1568
|
+
for (const result of retried) {
|
|
1569
|
+
recoveredByUmodel.add(basename(result.entry.package, extname(result.entry.package)));
|
|
1570
|
+
}
|
|
1571
|
+
assets = mergeExported(assets, await indexExported(raw));
|
|
1572
|
+
warnings.push(`The modern UE5 asset converter exited ${converted.code}; UE Viewer decoded ${recoveredByUmodel.size} static mesh package${recoveredByUmodel.size === 1 ? "" : "s"} it could read itself.`);
|
|
1573
|
+
}
|
|
1574
|
+
else {
|
|
1575
|
+
modernGlbs = await indexGlbs(modernRaw);
|
|
1576
|
+
assets = mergeExported(assets, await indexExported(modernRaw));
|
|
1577
|
+
for (const entry of modernTexturePackages) {
|
|
1578
|
+
const name = basename(entry.package, extname(entry.package));
|
|
1579
|
+
const source = assets.png.get(name);
|
|
1580
|
+
if (source)
|
|
1581
|
+
textureSources.set(entry.file, source);
|
|
1582
|
+
else
|
|
1583
|
+
failed.push({
|
|
1584
|
+
package: entry.package,
|
|
1585
|
+
reason: "The modern UE5 texture converter produced no PNG for this package.",
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
warnings.push(`Decoded ${modernAssetCount} requested modern UE5 asset package${modernAssetCount === 1 ? "" : "s"} without Unreal Engine.`);
|
|
1484
1589
|
}
|
|
1485
|
-
warnings.push(`Decoded ${modernAssetCount} requested modern UE5 asset package${modernAssetCount === 1 ? "" : "s"} without Unreal Engine.`);
|
|
1486
1590
|
}
|
|
1487
1591
|
if (modernMapPackages.length > 0) {
|
|
1488
1592
|
modernConverter ??= request.modernConverter ?? (await ensureModernConverter(environment, log));
|
|
@@ -1755,7 +1859,9 @@ export async function importUnrealDirectory(request) {
|
|
|
1755
1859
|
}
|
|
1756
1860
|
warnings.push(`Decoded ${paperPackages.length} requested Paper2D package${paperPackages.length === 1 ? "" : "s"} without Unreal Engine.`);
|
|
1757
1861
|
}
|
|
1758
|
-
const modernNames = new Set(modernPackages
|
|
1862
|
+
const modernNames = new Set(modernPackages
|
|
1863
|
+
.map(({ entry }) => basename(entry.package, extname(entry.package)))
|
|
1864
|
+
.filter((name) => !recoveredByUmodel.has(name)));
|
|
1759
1865
|
const promotion = await mkdtemp(join(promotionParent, ".threenative-import-"));
|
|
1760
1866
|
// Only a verified entitlement earns a copyright line. A local pack whose licence nobody
|
|
1761
1867
|
// checked stays blank so the game's asset health check keeps saying "unknown".
|