@forgeax-extension/character-3d 0.1.2
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/SOURCE_PROVENANCE.md +8 -0
- package/dist/assets/index-CIx3aXo1.js +4336 -0
- package/dist/assets/index-CIx3aXo1.js.map +1 -0
- package/dist/assets/index-CmhOHLEB.css +1 -0
- package/dist/hdr/README.md +28 -0
- package/dist/hdr/presets.json +8 -0
- package/dist/index.html +13 -0
- package/forgeax-extension.json +428 -0
- package/marketplace-card.json +56 -0
- package/package.json +59 -0
- package/packages/external-asset-meta/package.json +24 -0
- package/packages/external-asset-meta/src/cook.test.ts +211 -0
- package/packages/external-asset-meta/src/cook.ts +188 -0
- package/packages/external-asset-meta/src/draco3dgltf.d.ts +11 -0
- package/packages/external-asset-meta/src/index.ts +18 -0
- package/packages/external-asset-meta/src/normalize.test.ts +36 -0
- package/packages/external-asset-meta/src/normalize.ts +101 -0
- package/packages/external-asset-meta/src/types.ts +82 -0
- package/schemas/adopt-playable-character.args.json +26 -0
- package/schemas/adopt-playable-character.returns.json +31 -0
- package/schemas/apply-motion.args.json +47 -0
- package/schemas/apply-motion.returns.json +23 -0
- package/schemas/auto-rig.args.json +35 -0
- package/schemas/auto-rig.returns.json +23 -0
- package/schemas/delete-asset.args.json +18 -0
- package/schemas/delete-asset.returns.json +18 -0
- package/schemas/engine-import-status.args.json +18 -0
- package/schemas/engine-import-status.returns.json +50 -0
- package/schemas/export-playable-character.args.json +21 -0
- package/schemas/export-playable-character.returns.json +47 -0
- package/schemas/gen3d-asset-manifest.json +281 -0
- package/schemas/generate-meshy-text-mock.args.json +42 -0
- package/schemas/generate-meshy-text-mock.returns.json +301 -0
- package/schemas/get-credentials.args.json +7 -0
- package/schemas/get-credentials.returns.json +45 -0
- package/schemas/get-playable-profile.args.json +11 -0
- package/schemas/get-playable-profile.returns.json +188 -0
- package/schemas/image-to-3d.args.json +47 -0
- package/schemas/image-to-3d.returns.json +306 -0
- package/schemas/import-to-engine.args.json +18 -0
- package/schemas/import-to-engine.returns.json +35 -0
- package/schemas/list-assets.args.json +22 -0
- package/schemas/list-assets.returns.json +296 -0
- package/schemas/list-motions.args.json +29 -0
- package/schemas/list-motions.returns.json +41 -0
- package/schemas/pose-standardization.args.json +28 -0
- package/schemas/pose-standardization.returns.json +29 -0
- package/schemas/provider-status.args.json +7 -0
- package/schemas/provider-status.returns.json +70 -0
- package/schemas/refine-mesh.args.json +33 -0
- package/schemas/refine-mesh.returns.json +306 -0
- package/schemas/rename-asset.args.json +22 -0
- package/schemas/rename-asset.returns.json +14 -0
- package/schemas/retopo-lowpoly.args.json +34 -0
- package/schemas/retopo-lowpoly.returns.json +24 -0
- package/schemas/score-quality.args.json +36 -0
- package/schemas/score-quality.returns.json +12 -0
- package/schemas/set-credentials.args.json +32 -0
- package/schemas/set-credentials.returns.json +45 -0
- package/schemas/set-playable-motion-mapping.args.json +25 -0
- package/schemas/set-playable-motion-mapping.returns.json +33 -0
- package/schemas/set-playable-profile.args.json +32 -0
- package/schemas/set-playable-profile.returns.json +67 -0
- package/schemas/text-to-3d.args.json +51 -0
- package/schemas/text-to-3d.returns.json +306 -0
- package/schemas/upload-image.args.json +19 -0
- package/schemas/upload-image.returns.json +21 -0
- package/schemas/upload-video.args.json +14 -0
- package/schemas/upload-video.returns.json +14 -0
- package/schemas/views-to-3d.args.json +58 -0
- package/schemas/views-to-3d.returns.json +306 -0
- package/server/adopt-playable-character.ts +291 -0
- package/server/asset-storage.ts +136 -0
- package/server/asset-upload.ts +123 -0
- package/server/audit.ts +58 -0
- package/server/cache.ts +75 -0
- package/server/cos-uploader.ts +141 -0
- package/server/credentials-store.ts +186 -0
- package/server/engine-import.ts +238 -0
- package/server/env.ts +287 -0
- package/server/export-playable-character.ts +364 -0
- package/server/generate.ts +89 -0
- package/server/merge-playable-character.ts +259 -0
- package/server/motion-catalog.ts +137 -0
- package/server/per-game-store.ts +1016 -0
- package/server/providers/gateway-client.ts +171 -0
- package/server/providers/gateway-data.ts +182 -0
- package/server/providers/gateway-models.ts +49 -0
- package/server/providers/hunyuan-rest.ts +317 -0
- package/server/providers/hunyuan-workflow.ts +225 -0
- package/server/providers/meshy-direct-client.ts +223 -0
- package/server/providers/meshy.ts +548 -0
- package/server/providers/rodin-gateway.ts +193 -0
- package/server/providers/rodin.ts +253 -0
- package/server/providers/visvise.ts +329 -0
- package/server/rate-guard.ts +29 -0
- package/server/tool-handlers.ts +2193 -0
- package/shared/catalog.ts +307 -0
- package/shared/manifest.ts +378 -0
- package/shared/meshy-actions.ts +690 -0
- package/shared/playable-preview-url.test.ts +14 -0
- package/shared/playable-preview-url.ts +13 -0
- package/shared/playable-profile.test.ts +127 -0
- package/shared/playable-profile.ts +171 -0
- package/shared/provider-params.test.ts +172 -0
- package/shared/provider-params.ts +389 -0
- package/shared/quality/heuristics.test.ts +84 -0
- package/shared/quality/heuristics.ts +100 -0
- package/shared/rodin-image.test.ts +51 -0
- package/shared/rodin-image.ts +125 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"summary": {
|
|
3
|
+
"zh": "3D 角色生成工作台,接 Hunyuan3D、Meshy 等 provider 的一站式 3D 资产生成入口。支持文本生成 3D、图片生成 3D、多视图生成 3D,再做网格精修与 retopo 低模、自动绑骨与套用动作、质量评分,最后产出可持久化、游戏可直接用的 3D 资产 manifest。选 provider、给输入,剩下的从生成到绑骨一条龙。",
|
|
4
|
+
"en": "The 3D character generation editor — a one-stop entry to providers like Hunyuan3D and Meshy. It supports text-to-3D, image-to-3D and multi-view-to-3D, then mesh refine + lowpoly retopo, auto-rig + motion application, and quality scoring, finally producing durable, game-ready 3D asset manifests. Pick a provider, give input, and it carries through from generation to rigging."
|
|
5
|
+
},
|
|
6
|
+
"sections": [
|
|
7
|
+
{
|
|
8
|
+
"title": {
|
|
9
|
+
"zh": "生成管线",
|
|
10
|
+
"en": "Generation pipeline"
|
|
11
|
+
},
|
|
12
|
+
"body": {
|
|
13
|
+
"zh": "文本 / 图片 / 多视图 → 3D\n网格精修 + retopo 低模\n自动绑骨 + 套用动作 + 质量评分",
|
|
14
|
+
"en": "text / image / multi-view → 3D\nmesh refine + lowpoly retopo\nauto-rig + motion + quality scoring"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"title": {
|
|
19
|
+
"zh": "产出",
|
|
20
|
+
"en": "Outputs"
|
|
21
|
+
},
|
|
22
|
+
"body": {
|
|
23
|
+
"zh": "可持久化、游戏可用的 3D 资产 manifest",
|
|
24
|
+
"en": "Durable, game-ready 3D asset manifests"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"title": {
|
|
29
|
+
"zh": "怎么用",
|
|
30
|
+
"en": "How to use"
|
|
31
|
+
},
|
|
32
|
+
"body": {
|
|
33
|
+
"zh": "面板管理启用 → 选 provider + 输入开始生成",
|
|
34
|
+
"en": "Enable in Admin → pick a provider + input to generate"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"history": [
|
|
39
|
+
{
|
|
40
|
+
"version": "0.1.0",
|
|
41
|
+
"date": "2026-06-10",
|
|
42
|
+
"notes": {
|
|
43
|
+
"zh": "首版:3D 角色生成入口",
|
|
44
|
+
"en": "First release: 3D character generation entry"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"version": null,
|
|
49
|
+
"date": "2026-06-24",
|
|
50
|
+
"notes": {
|
|
51
|
+
"zh": "3D turnaround 与流程完善",
|
|
52
|
+
"en": "3D turnaround + pipeline improvements"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"packageManager": "bun@1.3.14",
|
|
3
|
+
"name": "@forgeax-extension/character-3d",
|
|
4
|
+
"version": "0.1.2",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"description": "ForgeaX extension for 3D character generation, rigging, motion, quality inspection, and playable export.",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ForgeaXGame/forgeax-ex-character-3d.git"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"forgeax-extension.json",
|
|
18
|
+
"marketplace-card.json",
|
|
19
|
+
"schemas",
|
|
20
|
+
"server",
|
|
21
|
+
"shared",
|
|
22
|
+
"packages/external-asset-meta/src",
|
|
23
|
+
"packages/external-asset-meta/package.json",
|
|
24
|
+
"README.md",
|
|
25
|
+
"SOURCE_PROVENANCE.md",
|
|
26
|
+
"!server/**/*.test.ts"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "vite --host",
|
|
30
|
+
"build": "tsc && vite build && node scripts/release-scan-compat.mjs",
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"preview": "vite preview --port 15176",
|
|
33
|
+
"test": "bun test src server shared",
|
|
34
|
+
"smoke:real-gateway": "bun scripts/real-gateway-smoke.ts",
|
|
35
|
+
"check:release": "node scripts/check-release.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"cos-nodejs-sdk-v5": "^2.15.4",
|
|
39
|
+
"lucide-react": "^0.460.0",
|
|
40
|
+
"react": "^19.0.0",
|
|
41
|
+
"react-dom": "^19.0.0",
|
|
42
|
+
"three": "^0.184.0",
|
|
43
|
+
"@forgeax-extension/external-asset-meta": "file:packages/external-asset-meta",
|
|
44
|
+
"@gltf-transform/core": "^4.4.0",
|
|
45
|
+
"@gltf-transform/extensions": "^4.4.0",
|
|
46
|
+
"@gltf-transform/functions": "^4.4.0",
|
|
47
|
+
"draco3dgltf": "^1.5.7"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^25.6.0",
|
|
51
|
+
"@types/react": "^19.0.0",
|
|
52
|
+
"@types/react-dom": "^19.0.0",
|
|
53
|
+
"@types/three": "^0.184.1",
|
|
54
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
55
|
+
"puppeteer-core": "24.9.0",
|
|
56
|
+
"typescript": "^5.7.0",
|
|
57
|
+
"vite": "^6.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax-extension/external-asset-meta",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@gltf-transform/core": "^4.4.1",
|
|
15
|
+
"@gltf-transform/extensions": "^4.4.1",
|
|
16
|
+
"@gltf-transform/functions": "^4.4.1",
|
|
17
|
+
"draco3dgltf": "^1.5.7"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "^1.3.14",
|
|
21
|
+
"@types/node": "^26.1.1",
|
|
22
|
+
"typescript": "^7.0.2"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { crc32, deflateSync } from 'node:zlib';
|
|
4
|
+
import { Document, WebIO } from '@gltf-transform/core';
|
|
5
|
+
import { cookExternalAssetFields, cookExternalAssetMeta } from './cook.ts';
|
|
6
|
+
import type { ExternalAssetMeta } from './types.ts';
|
|
7
|
+
|
|
8
|
+
async function buildGlb(meshCount: number): Promise<Uint8Array> {
|
|
9
|
+
const doc = new Document();
|
|
10
|
+
const buffer = doc.createBuffer();
|
|
11
|
+
const scene = doc.createScene();
|
|
12
|
+
for (let i = 0; i < meshCount; i += 1) {
|
|
13
|
+
const position = doc
|
|
14
|
+
.createAccessor()
|
|
15
|
+
.setType('VEC3')
|
|
16
|
+
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
|
|
17
|
+
.setBuffer(buffer);
|
|
18
|
+
const prim = doc.createPrimitive().setAttribute('POSITION', position);
|
|
19
|
+
const mesh = doc.createMesh(`mesh-${i}`).addPrimitive(prim);
|
|
20
|
+
scene.addChild(doc.createNode(`node-${i}`).setMesh(mesh));
|
|
21
|
+
}
|
|
22
|
+
return new WebIO().writeBinary(doc);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function oneByOnePng(): Uint8Array {
|
|
26
|
+
const chunk = (type: string, data: Buffer): Buffer => {
|
|
27
|
+
const len = Buffer.alloc(4);
|
|
28
|
+
len.writeUInt32BE(data.length, 0);
|
|
29
|
+
const typeBuf = Buffer.from(type, 'ascii');
|
|
30
|
+
const crc = Buffer.alloc(4);
|
|
31
|
+
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])) >>> 0, 0);
|
|
32
|
+
return Buffer.concat([len, typeBuf, data, crc]);
|
|
33
|
+
};
|
|
34
|
+
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
35
|
+
const ihdr = chunk('IHDR', Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]));
|
|
36
|
+
const idat = chunk('IDAT', deflateSync(Buffer.from([0, 255, 0, 0, 255])));
|
|
37
|
+
const iend = chunk('IEND', Buffer.alloc(0));
|
|
38
|
+
return Buffer.concat([sig, ihdr, idat, iend]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function buildTexturedGlb(): Promise<Uint8Array> {
|
|
42
|
+
const doc = new Document();
|
|
43
|
+
const buffer = doc.createBuffer();
|
|
44
|
+
const scene = doc.createScene();
|
|
45
|
+
const position = doc
|
|
46
|
+
.createAccessor()
|
|
47
|
+
.setType('VEC3')
|
|
48
|
+
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
|
|
49
|
+
.setBuffer(buffer);
|
|
50
|
+
const tex = doc.createTexture('base-color').setImage(oneByOnePng()).setMimeType('image/png');
|
|
51
|
+
const material = doc.createMaterial('mat-0').setBaseColorTexture(tex);
|
|
52
|
+
const prim = doc.createPrimitive().setAttribute('POSITION', position).setMaterial(material);
|
|
53
|
+
const mesh = doc.createMesh('mesh-0').addPrimitive(prim);
|
|
54
|
+
scene.addChild(doc.createNode('node-0').setMesh(mesh));
|
|
55
|
+
return new WebIO().writeBinary(doc);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function buildSkinnedAnimatedGlb(): Promise<Uint8Array> {
|
|
59
|
+
const doc = new Document();
|
|
60
|
+
const buffer = doc.createBuffer();
|
|
61
|
+
const scene = doc.createScene();
|
|
62
|
+
const position = doc
|
|
63
|
+
.createAccessor()
|
|
64
|
+
.setType('VEC3')
|
|
65
|
+
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
|
|
66
|
+
.setBuffer(buffer);
|
|
67
|
+
const joints = doc
|
|
68
|
+
.createAccessor()
|
|
69
|
+
.setType('VEC4')
|
|
70
|
+
.setArray(new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))
|
|
71
|
+
.setBuffer(buffer);
|
|
72
|
+
const weights = doc
|
|
73
|
+
.createAccessor()
|
|
74
|
+
.setType('VEC4')
|
|
75
|
+
.setArray(new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]))
|
|
76
|
+
.setBuffer(buffer);
|
|
77
|
+
const ibm = doc
|
|
78
|
+
.createAccessor()
|
|
79
|
+
.setType('MAT4')
|
|
80
|
+
.setArray(new Float32Array(16).map((_, i) => (i % 5 === 0 ? 1 : 0)))
|
|
81
|
+
.setBuffer(buffer);
|
|
82
|
+
const prim = doc
|
|
83
|
+
.createPrimitive()
|
|
84
|
+
.setAttribute('POSITION', position)
|
|
85
|
+
.setAttribute('JOINTS_0', joints)
|
|
86
|
+
.setAttribute('WEIGHTS_0', weights);
|
|
87
|
+
const mesh = doc.createMesh('skinned-mesh').addPrimitive(prim);
|
|
88
|
+
const joint = doc.createNode('Hips');
|
|
89
|
+
const skin = doc.createSkin('skin-0').addJoint(joint).setInverseBindMatrices(ibm);
|
|
90
|
+
const node = doc.createNode('skinned').setMesh(mesh).setSkin(skin);
|
|
91
|
+
scene.addChild(joint);
|
|
92
|
+
scene.addChild(node);
|
|
93
|
+
|
|
94
|
+
const input = doc
|
|
95
|
+
.createAccessor()
|
|
96
|
+
.setType('SCALAR')
|
|
97
|
+
.setArray(new Float32Array([0, 1]))
|
|
98
|
+
.setBuffer(buffer);
|
|
99
|
+
const output = doc
|
|
100
|
+
.createAccessor()
|
|
101
|
+
.setType('VEC3')
|
|
102
|
+
.setArray(new Float32Array([0, 0, 0, 0, 1, 0]))
|
|
103
|
+
.setBuffer(buffer);
|
|
104
|
+
const sampler = doc.createAnimationSampler().setInput(input).setOutput(output).setInterpolation('LINEAR');
|
|
105
|
+
const channel = doc.createAnimationChannel().setTargetNode(joint).setTargetPath('translation').setSampler(sampler);
|
|
106
|
+
doc.createAnimation('idle').addSampler(sampler).addChannel(channel);
|
|
107
|
+
const input2 = doc
|
|
108
|
+
.createAccessor()
|
|
109
|
+
.setType('SCALAR')
|
|
110
|
+
.setArray(new Float32Array([0, 1]))
|
|
111
|
+
.setBuffer(buffer);
|
|
112
|
+
const output2 = doc
|
|
113
|
+
.createAccessor()
|
|
114
|
+
.setType('VEC3')
|
|
115
|
+
.setArray(new Float32Array([0, 0, 0, 1, 0, 0]))
|
|
116
|
+
.setBuffer(buffer);
|
|
117
|
+
const sampler2 = doc.createAnimationSampler().setInput(input2).setOutput(output2).setInterpolation('LINEAR');
|
|
118
|
+
const channel2 = doc.createAnimationChannel().setTargetNode(joint).setTargetPath('translation').setSampler(sampler2);
|
|
119
|
+
doc.createAnimation('move').addSampler(sampler2).addChannel(channel2);
|
|
120
|
+
|
|
121
|
+
return new WebIO().writeBinary(doc);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
125
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test('single-mesh GLB → mesh + scene subAssets', async () => {
|
|
129
|
+
const glb = await buildGlb(1);
|
|
130
|
+
const contentHash = `sha256:${sha256Hex(glb)}`;
|
|
131
|
+
const meta = await cookExternalAssetFields(glb, contentHash, 'prop-test.glb');
|
|
132
|
+
expect(meta).not.toBeNull();
|
|
133
|
+
expect(meta!.kind).toBe('external-asset-package');
|
|
134
|
+
const byKind = Object.fromEntries(meta!.subAssets.map((s) => [s.kind, s]));
|
|
135
|
+
expect(Object.keys(byKind).sort()).toEqual(['mesh', 'scene']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('textured GLB → mesh + material + scene + texture', async () => {
|
|
139
|
+
const glb = await buildTexturedGlb();
|
|
140
|
+
const meta = await cookExternalAssetFields(glb, `sha256:${sha256Hex(glb)}`, 'prop-tex.glb');
|
|
141
|
+
const kinds = new Set(meta!.subAssets.map((s) => s.kind));
|
|
142
|
+
expect([...kinds].sort()).toEqual(['material', 'mesh', 'scene', 'texture']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('skinned+animated GLB → skeleton/skin/animation-clip', async () => {
|
|
146
|
+
const glb = await buildSkinnedAnimatedGlb();
|
|
147
|
+
const result = await cookExternalAssetMeta(glb, `sha256:${sha256Hex(glb)}`, 'hero.glb', {
|
|
148
|
+
animationSlotKeys: ['idle', 'move'],
|
|
149
|
+
slotGuidRegistry: { idle: '11111111-1111-1111-1111-111111111111' },
|
|
150
|
+
});
|
|
151
|
+
expect(result.ok).toBe(true);
|
|
152
|
+
if (!result.ok) return;
|
|
153
|
+
const kinds = result.meta.subAssets.map((s) => s.kind);
|
|
154
|
+
expect(kinds).toContain('skeleton');
|
|
155
|
+
expect(kinds).toContain('skin');
|
|
156
|
+
const clips = result.meta.subAssets.filter((s) => s.kind === 'animation-clip');
|
|
157
|
+
expect(clips.length).toBe(2);
|
|
158
|
+
expect(clips[0]!.guid).toBe('11111111-1111-1111-1111-111111111111');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('existingMeta reuses GUID by (kind, sourceIndex)', async () => {
|
|
162
|
+
const glb = await buildGlb(1);
|
|
163
|
+
const hash = `sha256:${sha256Hex(glb)}`;
|
|
164
|
+
const first = await cookExternalAssetFields(glb, hash, 'prop.glb');
|
|
165
|
+
const existing: ExternalAssetMeta = {
|
|
166
|
+
...first!,
|
|
167
|
+
subAssets: first!.subAssets.map((s) =>
|
|
168
|
+
s.kind === 'mesh' ? { ...s, guid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } : s,
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
const second = await cookExternalAssetMeta(glb, hash, 'prop.glb', { existingMeta: existing });
|
|
172
|
+
expect(second.ok).toBe(true);
|
|
173
|
+
if (!second.ok) return;
|
|
174
|
+
const mesh = second.meta.subAssets.find((s) => s.kind === 'mesh')!;
|
|
175
|
+
expect(mesh.guid).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('required Draco → engine_unsupported_extension', async () => {
|
|
179
|
+
const glb = await buildGlb(1);
|
|
180
|
+
// Patch JSON chunk to claim required Draco (without real compression).
|
|
181
|
+
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
|
|
182
|
+
const jsonLen = view.getUint32(12, true);
|
|
183
|
+
const json = JSON.parse(new TextDecoder().decode(glb.subarray(20, 20 + jsonLen))) as Record<string, unknown>;
|
|
184
|
+
json.extensionsRequired = ['KHR_draco_mesh_compression'];
|
|
185
|
+
const jsonBytes = new TextEncoder().encode(JSON.stringify(json));
|
|
186
|
+
// Rebuild a minimal invalid-but-parseable GLB for extension check (cook fails before WebIO if required).
|
|
187
|
+
const out = new Uint8Array(20 + jsonBytes.length + 4);
|
|
188
|
+
out.set(glb.subarray(0, 12), 0);
|
|
189
|
+
new DataView(out.buffer).setUint32(12, jsonBytes.length, true);
|
|
190
|
+
new DataView(out.buffer).setUint32(16, 0x4e4f534a, true); // JSON
|
|
191
|
+
out.set(jsonBytes, 20);
|
|
192
|
+
const result = await cookExternalAssetMeta(out, 'sha256:dead', 'draco.glb');
|
|
193
|
+
expect(result.ok).toBe(false);
|
|
194
|
+
if (result.ok) return;
|
|
195
|
+
expect(result.code).toBe('engine_unsupported_extension');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('bad bytes → null via compat wrapper', async () => {
|
|
199
|
+
const meta = await cookExternalAssetFields(new Uint8Array([1, 2, 3]), 'sha256:bad', 'bad.glb');
|
|
200
|
+
expect(meta).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('mesh GUID stays sha256(hash:sourceIndex)', async () => {
|
|
204
|
+
const glb = await buildGlb(1);
|
|
205
|
+
const bareHash = sha256Hex(glb);
|
|
206
|
+
const meta = await cookExternalAssetFields(glb, `sha256:${bareHash}`, 'prop-test.glb');
|
|
207
|
+
const mesh = meta!.subAssets.find((s) => s.kind === 'mesh')!;
|
|
208
|
+
const hex = createHash('sha256').update(`${bareHash}:0`).digest('hex');
|
|
209
|
+
const expected = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
210
|
+
expect(mesh.guid).toBe(expected);
|
|
211
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Shared engine external-asset-package cook (PLAN S1 / §5.1).
|
|
2
|
+
// Mirrors forgeax-engine toAssetPack sub-asset kinds without depending on
|
|
3
|
+
// @forgeax/engine-gltf (marketplace is outside the npm workspace).
|
|
4
|
+
|
|
5
|
+
import { WebIO } from '@gltf-transform/core';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import {
|
|
8
|
+
ENGINE_REQUIRED_EXTENSION_ALLOWLIST,
|
|
9
|
+
type CookOptions,
|
|
10
|
+
type CookResult,
|
|
11
|
+
type ExternalAssetMeta,
|
|
12
|
+
type ExternalAssetSubAsset,
|
|
13
|
+
} from './types.ts';
|
|
14
|
+
|
|
15
|
+
function hexToUuid(hex: string): string {
|
|
16
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function meshGuid(contentHash: string, sourceIndex: number): string {
|
|
20
|
+
const h = createHash('sha256').update(`${contentHash}:${sourceIndex}`).digest('hex');
|
|
21
|
+
return hexToUuid(h);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function subGuid(contentHash: string, kind: string, sourceIndex: number): string {
|
|
25
|
+
const h = createHash('sha256').update(`${contentHash}:${kind}:${sourceIndex}`).digest('hex');
|
|
26
|
+
return hexToUuid(h);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface GlTFJson {
|
|
30
|
+
readonly meshes?: ReadonlyArray<{ readonly name?: string }>;
|
|
31
|
+
readonly materials?: ReadonlyArray<{ readonly name?: string }>;
|
|
32
|
+
readonly scenes?: ReadonlyArray<{ readonly name?: string }>;
|
|
33
|
+
readonly images?: ReadonlyArray<{ readonly name?: string }>;
|
|
34
|
+
readonly skins?: ReadonlyArray<{ readonly name?: string }>;
|
|
35
|
+
readonly animations?: ReadonlyArray<{ readonly name?: string }>;
|
|
36
|
+
readonly scene?: number;
|
|
37
|
+
readonly extensionsRequired?: ReadonlyArray<string>;
|
|
38
|
+
readonly extensionsUsed?: ReadonlyArray<string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseGlbJson(glbBytes: Uint8Array): GlTFJson | null {
|
|
42
|
+
if (glbBytes.length < 20) return null;
|
|
43
|
+
const view = new DataView(glbBytes.buffer, glbBytes.byteOffset, glbBytes.byteLength);
|
|
44
|
+
if (view.getUint32(0, true) !== 0x46546c67) return null;
|
|
45
|
+
const jsonLen = view.getUint32(12, true);
|
|
46
|
+
if (jsonLen <= 0 || 20 + jsonLen > glbBytes.length) return null;
|
|
47
|
+
const jsonText = new TextDecoder().decode(glbBytes.subarray(20, 20 + jsonLen));
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(jsonText) as GlTFJson;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function existingGuidMap(
|
|
56
|
+
existingMeta: ExternalAssetMeta | null | undefined,
|
|
57
|
+
): Map<string, string> {
|
|
58
|
+
const map = new Map<string, string>();
|
|
59
|
+
if (!existingMeta || existingMeta.kind !== 'external-asset-package') return map;
|
|
60
|
+
for (const entry of existingMeta.subAssets) {
|
|
61
|
+
map.set(`${entry.kind}\0${entry.sourceIndex}`, entry.guid);
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveGuid(args: {
|
|
67
|
+
kind: string;
|
|
68
|
+
sourceIndex: number;
|
|
69
|
+
bareHash: string;
|
|
70
|
+
reuse: Map<string, string>;
|
|
71
|
+
slotKey?: string;
|
|
72
|
+
slotGuidRegistry?: Readonly<Record<string, string>>;
|
|
73
|
+
}): string {
|
|
74
|
+
if (args.kind === 'animation-clip' && args.slotKey) {
|
|
75
|
+
const fromRegistry = args.slotGuidRegistry?.[args.slotKey];
|
|
76
|
+
if (fromRegistry) return fromRegistry;
|
|
77
|
+
}
|
|
78
|
+
const reused = args.reuse.get(`${args.kind}\0${args.sourceIndex}`);
|
|
79
|
+
if (reused) return reused;
|
|
80
|
+
if (args.kind === 'mesh') return meshGuid(args.bareHash, args.sourceIndex);
|
|
81
|
+
return subGuid(args.bareHash, args.kind, args.sourceIndex);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cook a clean engine `external-asset-package` meta from GLB bytes.
|
|
86
|
+
* Returns structured success/failure (never throws on bad input).
|
|
87
|
+
*/
|
|
88
|
+
export async function cookExternalAssetMeta(
|
|
89
|
+
glbBytes: Uint8Array,
|
|
90
|
+
contentHash: string,
|
|
91
|
+
source: string,
|
|
92
|
+
options: CookOptions = {},
|
|
93
|
+
): Promise<CookResult> {
|
|
94
|
+
const json = parseGlbJson(glbBytes);
|
|
95
|
+
if (json === null) {
|
|
96
|
+
return { ok: false, code: 'corrupt_glb', message: 'GLB header/JSON chunk is corrupt or not a GLB.' };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const required = json.extensionsRequired ?? [];
|
|
100
|
+
const allow = new Set<string>(ENGINE_REQUIRED_EXTENSION_ALLOWLIST);
|
|
101
|
+
const unsupported = required.filter((ext) => !allow.has(ext));
|
|
102
|
+
if (unsupported.length > 0) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
code: 'engine_unsupported_extension',
|
|
106
|
+
message: `GLB requires unsupported extensions: ${unsupported.join(', ')}. Normalize/decode before cooking.`,
|
|
107
|
+
unsupportedExtensions: unsupported,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Decode guard: structurally broken GLBs (and any remaining undecodable
|
|
112
|
+
// accessors) must not emit a fake-success meta.
|
|
113
|
+
try {
|
|
114
|
+
await new WebIO().readBinary(glbBytes);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
code: 'read_failed',
|
|
119
|
+
message: err instanceof Error ? err.message : 'WebIO.readBinary failed',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const meshes = json.meshes ?? [];
|
|
124
|
+
if (meshes.length === 0) {
|
|
125
|
+
return { ok: false, code: 'no_meshes', message: 'GLB has no meshes; engine external packages require ≥1 mesh.' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const bareHash = contentHash.replace(/^sha256:/, '');
|
|
129
|
+
const reuse = existingGuidMap(options.existingMeta);
|
|
130
|
+
const subAssets: ExternalAssetSubAsset[] = [];
|
|
131
|
+
|
|
132
|
+
const push = (kind: string, sourceIndex: number, name: string | undefined, slotKey?: string) => {
|
|
133
|
+
subAssets.push({
|
|
134
|
+
guid: resolveGuid({
|
|
135
|
+
kind,
|
|
136
|
+
sourceIndex,
|
|
137
|
+
bareHash,
|
|
138
|
+
reuse,
|
|
139
|
+
slotKey,
|
|
140
|
+
slotGuidRegistry: options.slotGuidRegistry,
|
|
141
|
+
}),
|
|
142
|
+
sourceIndex,
|
|
143
|
+
kind,
|
|
144
|
+
...(name ? { name } : {}),
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
meshes.forEach((mesh, i) => push('mesh', i, mesh.name));
|
|
149
|
+
(json.materials ?? []).forEach((m, i) => push('material', i, m.name));
|
|
150
|
+
(json.scenes ?? []).forEach((s, i) => push('scene', i, s.name));
|
|
151
|
+
// TEX1: textures only from images[], never sidecar PNG/JPG.
|
|
152
|
+
(json.images ?? []).forEach((img, i) => push('texture', i, img.name));
|
|
153
|
+
// Engine emits skeleton + skin 1:1 from each glTF skin (parse-gltf.ts).
|
|
154
|
+
(json.skins ?? []).forEach((skin, i) => {
|
|
155
|
+
push('skeleton', i, skin.name);
|
|
156
|
+
push('skin', i, skin.name);
|
|
157
|
+
});
|
|
158
|
+
(json.animations ?? []).forEach((anim, i) => {
|
|
159
|
+
const slotKey = options.animationSlotKeys?.[i];
|
|
160
|
+
push('animation-clip', i, anim.name ?? slotKey, slotKey);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
meta: {
|
|
166
|
+
schemaVersion: 1,
|
|
167
|
+
kind: 'external-asset-package',
|
|
168
|
+
importer: 'gltf',
|
|
169
|
+
source,
|
|
170
|
+
importSettings: { defaultSceneIndex: json.scene ?? 0 },
|
|
171
|
+
subAssets,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Backward-compatible wrapper used by existing ai-asset call sites.
|
|
178
|
+
* Maps structured failure → null (callers skip writing engine meta).
|
|
179
|
+
*/
|
|
180
|
+
export async function cookExternalAssetFields(
|
|
181
|
+
glbBytes: Uint8Array,
|
|
182
|
+
contentHash: string,
|
|
183
|
+
source: string,
|
|
184
|
+
options: CookOptions = {},
|
|
185
|
+
): Promise<ExternalAssetMeta | null> {
|
|
186
|
+
const result = await cookExternalAssetMeta(glbBytes, contentHash, source, options);
|
|
187
|
+
return result.ok ? result.meta : null;
|
|
188
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare module 'draco3dgltf' {
|
|
2
|
+
interface DracoModule {
|
|
3
|
+
// Opaque WASM module handle consumed by @gltf-transform/extensions.
|
|
4
|
+
}
|
|
5
|
+
interface Draco3dGltf {
|
|
6
|
+
createDecoderModule(): Promise<DracoModule>;
|
|
7
|
+
createEncoderModule(): Promise<DracoModule>;
|
|
8
|
+
}
|
|
9
|
+
const draco3d: Draco3dGltf;
|
|
10
|
+
export default draco3d;
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference path="./draco3dgltf.d.ts" />
|
|
2
|
+
export {
|
|
3
|
+
ENGINE_REQUIRED_EXTENSION_ALLOWLIST,
|
|
4
|
+
type CookErrorCode,
|
|
5
|
+
type CookFailure,
|
|
6
|
+
type CookOptions,
|
|
7
|
+
type CookResult,
|
|
8
|
+
type CookSuccess,
|
|
9
|
+
type ExternalAssetMeta,
|
|
10
|
+
type ExternalAssetSubAsset,
|
|
11
|
+
type NormalizeErrorCode,
|
|
12
|
+
type NormalizeFailure,
|
|
13
|
+
type NormalizeResult,
|
|
14
|
+
type NormalizeSuccess,
|
|
15
|
+
} from './types.ts';
|
|
16
|
+
|
|
17
|
+
export { cookExternalAssetFields, cookExternalAssetMeta } from './cook.ts';
|
|
18
|
+
export { normalizeGlbForEngine } from './normalize.ts';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { Document, WebIO } from '@gltf-transform/core';
|
|
3
|
+
import { normalizeGlbForEngine } from './normalize.ts';
|
|
4
|
+
import { cookExternalAssetMeta } from './cook.ts';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
async function buildPlainGlb(): Promise<Uint8Array> {
|
|
8
|
+
const doc = new Document();
|
|
9
|
+
const buffer = doc.createBuffer();
|
|
10
|
+
const scene = doc.createScene();
|
|
11
|
+
const position = doc
|
|
12
|
+
.createAccessor()
|
|
13
|
+
.setType('VEC3')
|
|
14
|
+
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]))
|
|
15
|
+
.setBuffer(buffer);
|
|
16
|
+
const prim = doc.createPrimitive().setAttribute('POSITION', position);
|
|
17
|
+
scene.addChild(doc.createNode('n').setMesh(doc.createMesh('m').addPrimitive(prim)));
|
|
18
|
+
return new WebIO().writeBinary(doc);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('normalize plain GLB succeeds and remains cookable', async () => {
|
|
22
|
+
const glb = await buildPlainGlb();
|
|
23
|
+
const result = await normalizeGlbForEngine(glb);
|
|
24
|
+
expect(result.ok).toBe(true);
|
|
25
|
+
if (!result.ok) return;
|
|
26
|
+
const hash = `sha256:${createHash('sha256').update(result.bytes).digest('hex')}`;
|
|
27
|
+
const cooked = await cookExternalAssetMeta(result.bytes, hash, 'plain.glb');
|
|
28
|
+
expect(cooked.ok).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('normalize corrupt bytes fails structured', async () => {
|
|
32
|
+
const result = await normalizeGlbForEngine(new Uint8Array([0, 1, 2, 3]));
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
if (result.ok) return;
|
|
35
|
+
expect(result.code).toBe('corrupt_glb');
|
|
36
|
+
});
|