@starcried/starlite-vrm 0.1.0-alpha.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUNDLED_MOTIONS.json +63 -0
- package/BUNDLED_MOTIONS.md +41 -0
- package/README.en.md +65 -0
- package/README.md +31 -3
- package/THIRD_PARTY_NOTICES.md +2 -0
- package/dist/adjustments.d.ts +23 -0
- package/dist/adjustments.js +201 -0
- package/dist/builtin-motion-catalog.d.ts +16 -0
- package/dist/builtin-motion-catalog.js +10 -0
- package/dist/builtin-motion-data.d.ts +5 -0
- package/dist/builtin-motion-data.js +6 -0
- package/dist/builtin-motions.d.ts +3 -0
- package/dist/builtin-motions.js +10 -0
- package/dist/camera.d.ts +8 -0
- package/dist/camera.js +50 -0
- package/dist/config.cjs +71 -2
- package/dist/config.d.ts +4 -1
- package/dist/config.js +34 -3
- package/dist/environment-lighting.d.ts +12 -0
- package/dist/environment-lighting.js +82 -0
- package/dist/gaze.d.ts +2 -1
- package/dist/gaze.js +17 -6
- package/dist/index.d.ts +4 -1
- package/dist/index.js +240 -197
- package/dist/inspect.cjs +226 -74
- package/dist/inspect.d.ts +56 -9
- package/dist/inspect.js +259 -85
- package/dist/playback.js +25 -3
- package/dist/prepare.cjs +225 -77
- package/dist/prepare.js +4 -3
- package/dist/types.d.ts +22 -1
- package/package.json +6 -3
package/dist/inspect.js
CHANGED
|
@@ -1,171 +1,307 @@
|
|
|
1
|
+
import { isBuiltinAnimation } from "./builtin-motion-catalog.js";
|
|
2
|
+
/** Controlled diagnostic text only: never include input paths, URIs or JSON text. */
|
|
3
|
+
export class VrmInspectionError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
constructor(code, message, details = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "VrmInspectionError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const requiredBones = ["hips", "spine", "head", ...["left", "right"].flatMap(side => ["UpperLeg", "LowerLeg", "Foot", "UpperArm", "LowerArm", "Hand"].map(part => side + part))];
|
|
14
|
+
const optionalBones = ["chest", "upperChest", "neck", "jaw", ...["left", "right"].flatMap(side => ["Eye", "Shoulder", "Toes", ...["ThumbMetacarpal", "ThumbProximal", "ThumbIntermediate", "ThumbDistal", ...["Index", "Middle", "Ring", "Little"].flatMap(finger => ["Proximal", "Intermediate", "Distal"].map(part => finger + part))]].map(part => side + part))];
|
|
1
15
|
export const MAX_MODEL_BYTES = 64 * 1024 * 1024;
|
|
2
16
|
export const MAX_ANIMATION_BYTES = 16 * 1024 * 1024;
|
|
3
17
|
export function parseGlb(bytes, kind) {
|
|
18
|
+
try {
|
|
19
|
+
return parseGlbContents(bytes, kind);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error instanceof VrmInspectionError)
|
|
23
|
+
throw error;
|
|
24
|
+
// Type errors from malformed object shapes must not expose raw input.
|
|
25
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF structure; re-export a self-contained VRM/VRMA file");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function parseGlbContents(bytes, kind) {
|
|
4
29
|
const limit = kind === "vrm" ? MAX_MODEL_BYTES : MAX_ANIMATION_BYTES;
|
|
5
30
|
if (bytes.byteLength < 20 || bytes.byteLength > limit)
|
|
6
|
-
throw new
|
|
31
|
+
throw new VrmInspectionError("INVALID_GLB", `invalid GLB size; ${kind.toUpperCase()} must be a binary glTF 2.0 file no larger than ${limit / 1024 / 1024} MiB`);
|
|
7
32
|
const view = new DataView(bytes);
|
|
8
33
|
if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.byteLength)
|
|
9
|
-
throw new
|
|
34
|
+
throw new VrmInspectionError("INVALID_GLB", "invalid GLB header; export binary glTF 2.0 VRM/VRMA, not FBX or JSON glTF");
|
|
10
35
|
let json;
|
|
11
36
|
let binSize = 0;
|
|
12
37
|
let binStart = 0;
|
|
13
38
|
let chunkCount = 0;
|
|
14
39
|
for (let offset = 12; offset < bytes.byteLength;) {
|
|
15
40
|
if (offset + 8 > bytes.byteLength)
|
|
16
|
-
throw new
|
|
41
|
+
throw new VrmInspectionError("INVALID_GLB", "truncated GLB chunk; download or export the complete file again");
|
|
17
42
|
const size = view.getUint32(offset, true), type = view.getUint32(offset + 4, true);
|
|
18
43
|
offset += 8;
|
|
19
44
|
if (size % 4 || size > bytes.byteLength - offset)
|
|
20
|
-
throw new
|
|
45
|
+
throw new VrmInspectionError("INVALID_GLB", "invalid GLB chunk length; download or export the complete file again");
|
|
21
46
|
if (chunkCount === 0 && type === 0x4e4f534a) {
|
|
22
|
-
|
|
47
|
+
try {
|
|
48
|
+
json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes, offset, size)));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new VrmInspectionError("INVALID_GLB", "invalid GLB JSON; re-export the file with a VRM exporter");
|
|
52
|
+
}
|
|
23
53
|
}
|
|
24
54
|
else if (chunkCount === 1 && type === 0x004e4942) {
|
|
25
55
|
binSize = size;
|
|
26
56
|
binStart = offset;
|
|
27
57
|
}
|
|
28
58
|
else
|
|
29
|
-
throw new
|
|
59
|
+
throw new VrmInspectionError("INVALID_GLB", "unexpected GLB chunk; export a self-contained VRM/VRMA file");
|
|
30
60
|
offset += size;
|
|
31
61
|
chunkCount++;
|
|
32
62
|
}
|
|
33
|
-
if (!json || json.asset?.version !== "2.0")
|
|
34
|
-
throw new
|
|
63
|
+
if (!json || typeof json !== "object" || Array.isArray(json) || json.asset?.version !== "2.0")
|
|
64
|
+
throw new VrmInspectionError("UNSUPPORTED_FORMAT", "glTF 2.0 is required; use a VRM/VRMA exporter");
|
|
35
65
|
const expected = kind === "vrm" ? (json.extensions?.VRM ?? json.extensions?.VRMC_vrm) : json.extensions?.VRMC_vrm_animation;
|
|
36
66
|
if (!expected)
|
|
37
|
-
throw new
|
|
67
|
+
throw new VrmInspectionError("UNSUPPORTED_FORMAT", `missing ${kind} extension; export ${kind === "vrm" ? "VRM 0.0 or 1.0" : "VRMA 1.0"}, not a plain GLB`);
|
|
38
68
|
if (kind === "vrm" && json.extensions?.VRM && json.extensions.VRM.specVersion !== "0.0")
|
|
39
|
-
throw new
|
|
69
|
+
throw new VrmInspectionError("UNSUPPORTED_VERSION", "unsupported VRM version; export VRM 0.0 or 1.0");
|
|
40
70
|
if (kind === "vrm" && json.extensions?.VRMC_vrm && json.extensions.VRMC_vrm.specVersion !== "1.0")
|
|
41
|
-
throw new
|
|
71
|
+
throw new VrmInspectionError("UNSUPPORTED_VERSION", "unsupported VRM version; export VRM 0.0 or 1.0");
|
|
42
72
|
if (kind === "vrma" && expected.specVersion !== "1.0")
|
|
43
|
-
throw new
|
|
73
|
+
throw new VrmInspectionError("UNSUPPORTED_VERSION", "VRMA 1.0 is required");
|
|
44
74
|
const allowed = new Set(["VRM", "VRMC_vrm", "VRMC_vrm_animation", "VRMC_materials_mtoon", "VRMC_springBone", "VRMC_node_constraint", "VRMC_materials_hdr_emissiveMultiplier", "KHR_materials_unlit", "KHR_texture_transform", "KHR_materials_emissive_strength"]);
|
|
45
75
|
if ((json.extensionsRequired ?? []).some((name) => !allowed.has(name)))
|
|
46
|
-
throw new
|
|
76
|
+
throw new VrmInspectionError("UNSUPPORTED_EXTENSION", "unsupported required glTF extension; re-export without geometry or texture compression extensions");
|
|
47
77
|
// No externally resolved buffers, images or extension URIs. The loader only
|
|
48
78
|
// receives self-contained GLB; model import cannot access the network or disk.
|
|
49
79
|
const visit = (value, depth = 0) => {
|
|
50
80
|
if (depth > 64)
|
|
51
|
-
throw new
|
|
81
|
+
throw new VrmInspectionError("RESOURCE_LIMIT", "glTF nesting limit exceeded");
|
|
52
82
|
if (!value || typeof value !== "object")
|
|
53
83
|
return;
|
|
54
84
|
for (const [key, child] of Object.entries(value)) {
|
|
55
85
|
if (key === "uri")
|
|
56
|
-
throw new
|
|
86
|
+
throw new VrmInspectionError("EXTERNAL_RESOURCE", "external glTF resources are not supported; embed all textures and buffers in one VRM/VRMA file");
|
|
57
87
|
if (typeof child === "number" && !Number.isFinite(child))
|
|
58
|
-
throw new
|
|
88
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "non-finite glTF value");
|
|
59
89
|
visit(child, depth + 1);
|
|
60
90
|
}
|
|
61
91
|
};
|
|
62
92
|
visit(json);
|
|
63
93
|
if ((json.buffers?.length ?? 0) > 1 || (json.buffers?.[0]?.byteLength ?? 0) > binSize)
|
|
64
|
-
throw new
|
|
94
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid GLB buffer");
|
|
65
95
|
for (const buffer of json.bufferViews ?? []) {
|
|
66
96
|
const start = buffer.byteOffset ?? 0, length = buffer.byteLength;
|
|
67
97
|
if (buffer.buffer !== 0 || !Number.isSafeInteger(start) || !Number.isSafeInteger(length) || start < 0 || length < 0 || start + length > (json.buffers?.[0]?.byteLength ?? 0))
|
|
68
|
-
throw new
|
|
98
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF buffer view");
|
|
69
99
|
}
|
|
70
100
|
if ((json.nodes?.length ?? 0) > 4096 || (json.accessors?.length ?? 0) > 32768)
|
|
71
|
-
throw new
|
|
101
|
+
throw new VrmInspectionError("RESOURCE_LIMIT", "glTF resource count exceeded");
|
|
72
102
|
validateStructure(json, new DataView(bytes, binStart, binSize));
|
|
73
103
|
return json;
|
|
74
104
|
}
|
|
75
105
|
export function embeddedAnimationCatalog(json) {
|
|
76
106
|
const animations = json.animations ?? [];
|
|
77
107
|
if (!Array.isArray(animations) || animations.length > 64)
|
|
78
|
-
throw new
|
|
108
|
+
throw new VrmInspectionError("INVALID_ANIMATION", "at most 64 embedded animations are supported");
|
|
79
109
|
const names = new Set();
|
|
80
110
|
return animations.map((animation, index) => {
|
|
81
111
|
const name = animation.name ?? "Animation_" + (index + 1);
|
|
82
|
-
if (typeof name !== "string" || !name.trim() || new TextEncoder().encode(name).length > 128 || /[\u0000-\u001f\u007f]/.test(name) || name
|
|
83
|
-
throw new
|
|
112
|
+
if (typeof name !== "string" || !name.trim() || new TextEncoder().encode(name).length > 128 || /[\u0000-\u001f\u007f]/.test(name) || isBuiltinAnimation(name) || names.has(name))
|
|
113
|
+
throw new VrmInspectionError("INVALID_ANIMATION", "invalid or duplicate embedded animation name; use unique names of at most 128 UTF-8 bytes, excluding reserved StarLite program motion names");
|
|
84
114
|
names.add(name);
|
|
85
115
|
let duration = 0;
|
|
86
116
|
for (const sampler of animation.samplers ?? []) {
|
|
87
117
|
const accessor = json.accessors?.[sampler.input];
|
|
88
118
|
if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0]))
|
|
89
|
-
throw new
|
|
119
|
+
throw new VrmInspectionError("INVALID_ANIMATION", "embedded animation has invalid animation times");
|
|
90
120
|
duration = Math.max(duration, accessor.max[0]);
|
|
91
121
|
}
|
|
92
122
|
if (!(duration > 0) || duration > 600)
|
|
93
|
-
throw new
|
|
123
|
+
throw new VrmInspectionError("INVALID_ANIMATION", "embedded animation duration must be between 0 and 600 seconds");
|
|
94
124
|
return { name, durationMs: duration * 1000 };
|
|
95
125
|
});
|
|
96
126
|
}
|
|
97
127
|
export function inspectVrm(bytes) {
|
|
98
128
|
const json = parseGlb(bytes, "vrm");
|
|
99
129
|
const v0 = json.extensions.VRM;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
for (const name of required) {
|
|
105
|
-
const node = bones?.[name]?.node;
|
|
106
|
-
if (!Number.isSafeInteger(node) || !json.nodes?.[node] || used.has(node))
|
|
107
|
-
throw new Error("invalid required humanoid bone");
|
|
108
|
-
used.add(node);
|
|
109
|
-
}
|
|
130
|
+
const extension = v0 ?? json.extensions.VRMC_vrm;
|
|
131
|
+
if (Array.isArray(json.extensionsUsed) && !json.extensionsUsed.includes(v0 ? "VRM" : "VRMC_vrm"))
|
|
132
|
+
throw new VrmInspectionError("UNSUPPORTED_FORMAT", "missing VRM extension declaration; re-export with the VRM exporter");
|
|
133
|
+
const bones = inspectHumanBones(json, extension.humanoid?.humanBones, Boolean(v0));
|
|
110
134
|
// Blink is an optional capability. Declared bindings must still be valid;
|
|
111
135
|
// missing, empty or zero-weight presets simply provide no automatic blink.
|
|
112
136
|
const blinkNames = v0 ? ["blink", "blink_l", "blink_r"] : ["blink", "blinkLeft", "blinkRight"];
|
|
113
137
|
const groups = v0?.blendShapeMaster?.blendShapeGroups;
|
|
114
138
|
if (groups !== undefined && !Array.isArray(groups))
|
|
115
|
-
throw new
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!Array.isArray(binds))
|
|
128
|
-
throw new Error("invalid VRM blink bindings");
|
|
129
|
-
for (const bind of binds) {
|
|
130
|
-
const mesh = v0 ? bind?.mesh : json.nodes?.[bind?.node]?.mesh;
|
|
131
|
-
const primitives = json.meshes?.[mesh]?.primitives;
|
|
132
|
-
if (!bind || !Number.isSafeInteger(mesh) || mesh < 0
|
|
133
|
-
|| (!v0 && (!Number.isSafeInteger(bind.node) || bind.node < 0))
|
|
134
|
-
|| !Number.isSafeInteger(bind.index) || bind.index < 0
|
|
135
|
-
|| !Number.isFinite(bind.weight) || bind.weight < 0 || bind.weight > (v0 ? 100 : 1)
|
|
136
|
-
|| !Array.isArray(primitives) || !primitives.some((primitive) => primitive.targets?.[bind.index] !== undefined)) {
|
|
137
|
-
throw new Error("invalid VRM blink morph binding");
|
|
138
|
-
}
|
|
139
|
-
}
|
|
139
|
+
throw new VrmInspectionError("INVALID_BLINK", "invalid VRM blink expressions; re-export the blink presets");
|
|
140
|
+
const aliases = { a: "aa", e: "ee", i: "ih", o: "oh", u: "ou", joy: "happy", angry: "angry", sorrow: "sad", fun: "relaxed", neutral: "neutral", blink: "blink", blink_l: "blinkLeft", blink_r: "blinkRight", lookup: "lookUp", lookdown: "lookDown", lookleft: "lookLeft", lookright: "lookRight" };
|
|
141
|
+
// VRM0 also allows custom expressions with a canonical preset name. Match
|
|
142
|
+
// the loader's first-registration rule instead of inventing capabilities
|
|
143
|
+
// from a later duplicate preset that will be ignored.
|
|
144
|
+
const registeredName = (item) => aliases[item?.presetName] ?? item?.name;
|
|
145
|
+
const expression = (name) => v0 ? (groups ?? []).find((item) => registeredName(item) === (aliases[name] ?? name)) : extension.expressions?.preset?.[name];
|
|
146
|
+
// Validate every declared preset, even a duplicate that the loader ignores.
|
|
147
|
+
for (const preset of blinkNames) {
|
|
148
|
+
const presets = v0 ? (groups ?? []).filter((item) => registeredName(item) === aliases[preset]) : expression(preset) === undefined ? [] : [expression(preset)];
|
|
149
|
+
for (const item of presets)
|
|
150
|
+
inspectMorphExpression(json, item, Boolean(v0), "INVALID_BLINK", preset);
|
|
140
151
|
}
|
|
141
|
-
const
|
|
152
|
+
const [combined, left, right] = blinkNames.map(preset => inspectMorphExpression(json, expression(preset), Boolean(v0), "INVALID_BLINK", preset));
|
|
153
|
+
const meta = extension.meta ?? {};
|
|
142
154
|
const name = v0 ? meta.title : meta.name;
|
|
143
155
|
return {
|
|
144
156
|
version: v0 ? "0" : "1",
|
|
145
|
-
...(typeof name === "string" ? { name } : {}),
|
|
157
|
+
...(typeof name === "string" ? { name: safeLabel(name) } : {}),
|
|
146
158
|
animations: embeddedAnimationCatalog(json),
|
|
147
|
-
expressions: v0 ? (
|
|
159
|
+
expressions: (v0 ? (groups ?? []).map((group) => group?.name) : Object.keys({ ...extension.expressions?.preset, ...extension.expressions?.custom })).filter((value) => typeof value === "string").map(safeLabel),
|
|
160
|
+
capabilities: {
|
|
161
|
+
humanoid: { requiredBones: [...requiredBones], optionalBones: optionalBones.filter(name => bones.has(name)) },
|
|
162
|
+
blink: { automatic: Boolean(combined || (left && right)), combined: Boolean(combined), left: Boolean(left), right: Boolean(right) },
|
|
163
|
+
lookAt: inspectLookAt(json, extension, Boolean(v0), bones, expression),
|
|
164
|
+
},
|
|
165
|
+
metrics: inspectMetrics(json, bytes.byteLength),
|
|
148
166
|
};
|
|
149
167
|
}
|
|
168
|
+
function safeLabel(value) { return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 128); }
|
|
169
|
+
function inspectHumanBones(json, humanoid, v0) {
|
|
170
|
+
const known = new Set([...requiredBones, ...optionalBones]), result = new Map();
|
|
171
|
+
const invalid = [], duplicates = [], used = new Map();
|
|
172
|
+
const entries = v0 ? (Array.isArray(humanoid) ? humanoid.filter(item => item && known.has(item.bone)).map(item => [item.bone, item]) : [])
|
|
173
|
+
: humanoid && typeof humanoid === "object" && !Array.isArray(humanoid) ? Object.entries(humanoid).filter(([name]) => known.has(name)) : [];
|
|
174
|
+
for (const [name, value] of entries) {
|
|
175
|
+
const node = value?.node;
|
|
176
|
+
if (result.has(name))
|
|
177
|
+
duplicates.push(name);
|
|
178
|
+
if (!Number.isSafeInteger(node) || node < 0 || !json.nodes?.[node]) {
|
|
179
|
+
invalid.push(name);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const previous = used.get(node);
|
|
183
|
+
if (previous && previous !== name)
|
|
184
|
+
duplicates.push(previous, name);
|
|
185
|
+
used.set(node, name);
|
|
186
|
+
result.set(name, node);
|
|
187
|
+
}
|
|
188
|
+
const missing = requiredBones.filter(name => !result.has(name) && !invalid.includes(name));
|
|
189
|
+
if (missing.length || invalid.length || duplicates.length) {
|
|
190
|
+
const details = { missingBones: missing, invalidBones: [...new Set(invalid)], duplicateBones: [...new Set(duplicates)] };
|
|
191
|
+
const summary = [missing.length ? `missing ${missing.join(", ")}` : "", invalid.length ? `invalid node for ${details.invalidBones.join(", ")}` : "", duplicates.length ? `duplicate mapping for ${details.duplicateBones.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
192
|
+
throw new VrmInspectionError("HUMANOID_BONES", `invalid required humanoid bone: ${summary}; assign separate human bones in the VRM exporter and export again`, details);
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
function inspectMorphExpression(json, expression, v0, code, preset) {
|
|
197
|
+
if (expression === undefined)
|
|
198
|
+
return false;
|
|
199
|
+
const label = code === "INVALID_BLINK" ? "blink" : "look-at";
|
|
200
|
+
if (!expression || typeof expression !== "object" || Array.isArray(expression))
|
|
201
|
+
throw new VrmInspectionError(code, `invalid VRM ${label} expression; re-export the ${preset} preset`, { preset });
|
|
202
|
+
const binds = v0 ? expression.binds : expression.morphTargetBinds;
|
|
203
|
+
if (binds === undefined)
|
|
204
|
+
return false;
|
|
205
|
+
if (!Array.isArray(binds))
|
|
206
|
+
throw new VrmInspectionError(code, `invalid VRM ${label} bindings; re-export the ${preset} preset`, { preset });
|
|
207
|
+
let usable = false;
|
|
208
|
+
for (const bind of binds) {
|
|
209
|
+
const mesh = v0 ? bind?.mesh : json.nodes?.[bind?.node]?.mesh;
|
|
210
|
+
const primitives = json.meshes?.[mesh]?.primitives;
|
|
211
|
+
if (!bind || !Number.isSafeInteger(mesh) || mesh < 0
|
|
212
|
+
|| (!v0 && (!Number.isSafeInteger(bind.node) || bind.node < 0))
|
|
213
|
+
|| !Number.isSafeInteger(bind.index) || bind.index < 0
|
|
214
|
+
|| !Number.isFinite(bind.weight) || bind.weight < 0 || bind.weight > (v0 ? 100 : 1)
|
|
215
|
+
|| !Array.isArray(primitives) || !primitives.length || !primitives.every((primitive) => primitive?.targets?.[bind.index] !== undefined)) {
|
|
216
|
+
throw new VrmInspectionError(code, `invalid VRM ${label} morph binding: ${preset}; check its mesh/node, morph target index and weight in the VRM exporter`, { preset });
|
|
217
|
+
}
|
|
218
|
+
// The loader drops a bind if any primitive lacks morph influences. A mesh
|
|
219
|
+
// not referenced by a node also cannot provide a working eyelid.
|
|
220
|
+
const instantiated = !v0 || (json.nodes ?? []).some((node) => node?.mesh === mesh);
|
|
221
|
+
const morphs = primitives.every((primitive) => primitive.targets.some((target) => ["POSITION", "NORMAL", "COLOR_0"].some(semantic => Number.isSafeInteger(target?.[semantic]) && json.accessors?.[target[semantic]])));
|
|
222
|
+
usable ||= bind.weight > 0 && instantiated && morphs;
|
|
223
|
+
}
|
|
224
|
+
return usable;
|
|
225
|
+
}
|
|
226
|
+
function inspectLookAt(json, extension, v0, bones, expression) {
|
|
227
|
+
const lookAt = v0 ? extension.firstPerson : extension.lookAt;
|
|
228
|
+
const empty = { mode: "none", head: true, eyes: false, horizontal: false, vertical: false };
|
|
229
|
+
if (lookAt === undefined)
|
|
230
|
+
return empty;
|
|
231
|
+
if (!lookAt || typeof lookAt !== "object" || Array.isArray(lookAt))
|
|
232
|
+
throw new VrmInspectionError("INVALID_LOOK_AT", "invalid VRM look-at configuration; re-export eye tracking");
|
|
233
|
+
// three-vrm imports lookAt only when an expression manager exists, including
|
|
234
|
+
// bone-driven lookAt. An absent expression section must not promise eye motion.
|
|
235
|
+
const expressionManager = v0 ? extension.blendShapeMaster : extension.expressions;
|
|
236
|
+
if (!expressionManager)
|
|
237
|
+
return empty;
|
|
238
|
+
const type = v0 ? lookAt.lookAtTypeName : lookAt.type;
|
|
239
|
+
if (type !== undefined && !(v0 ? ["Bone", "BlendShape"] : ["bone", "expression"]).includes(type))
|
|
240
|
+
throw new VrmInspectionError("INVALID_LOOK_AT", "invalid VRM look-at type; use bone or expression eye tracking in the VRM exporter");
|
|
241
|
+
const mode = type === (v0 ? "BlendShape" : "expression") ? "expression" : "bone";
|
|
242
|
+
const scale = (name) => {
|
|
243
|
+
const range = lookAt[name], value = range?.[v0 ? "yRange" : "outputScale"] ?? (mode === "expression" ? 1 : 10);
|
|
244
|
+
const input = range?.[v0 ? "xRange" : "inputMaxValue"] ?? 90;
|
|
245
|
+
if ((range !== undefined && (!range || typeof range !== "object" || Array.isArray(range))) || !Number.isFinite(value) || value < 0 || !Number.isFinite(input) || input < 0)
|
|
246
|
+
throw new VrmInspectionError("INVALID_LOOK_AT", "invalid VRM look-at range; use finite, non-negative input and output ranges");
|
|
247
|
+
return value;
|
|
248
|
+
};
|
|
249
|
+
const horizontalRange = scale(v0 ? "lookAtHorizontalOuter" : "rangeMapHorizontalOuter");
|
|
250
|
+
const innerRange = scale(v0 ? "lookAtHorizontalInner" : "rangeMapHorizontalInner");
|
|
251
|
+
const upRange = scale(v0 ? "lookAtVerticalUp" : "rangeMapVerticalUp"), downRange = scale(v0 ? "lookAtVerticalDown" : "rangeMapVerticalDown");
|
|
252
|
+
let horizontal, vertical;
|
|
253
|
+
if (mode === "bone") {
|
|
254
|
+
const eyes = bones.has("leftEye") || bones.has("rightEye");
|
|
255
|
+
horizontal = eyes && (horizontalRange > 0 || innerRange > 0);
|
|
256
|
+
vertical = eyes && (upRange > 0 || downRange > 0);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const usable = (name) => inspectMorphExpression(json, expression(name), v0, "INVALID_LOOK_AT", name);
|
|
260
|
+
const left = usable(v0 ? "lookleft" : "lookLeft"), right = usable(v0 ? "lookright" : "lookRight"), up = usable(v0 ? "lookup" : "lookUp"), down = usable(v0 ? "lookdown" : "lookDown");
|
|
261
|
+
horizontal = horizontalRange > 0 && left && right;
|
|
262
|
+
vertical = upRange > 0 && downRange > 0 && up && down;
|
|
263
|
+
}
|
|
264
|
+
return { mode, head: true, eyes: horizontal || vertical, horizontal, vertical };
|
|
265
|
+
}
|
|
266
|
+
function inspectMetrics(json, fileBytes) {
|
|
267
|
+
const primitives = (json.meshes ?? []).flatMap((mesh) => Array.isArray(mesh?.primitives) ? mesh.primitives : []);
|
|
268
|
+
let vertexCount = 0, triangleCount = 0, morphTargetCount = 0;
|
|
269
|
+
for (const primitive of primitives) {
|
|
270
|
+
const vertices = json.accessors?.[primitive?.attributes?.POSITION]?.count ?? 0;
|
|
271
|
+
const indices = primitive?.indices === undefined ? vertices : json.accessors?.[primitive.indices]?.count ?? 0;
|
|
272
|
+
vertexCount += vertices;
|
|
273
|
+
if (primitive?.mode === undefined || primitive.mode === 4)
|
|
274
|
+
triangleCount += Math.floor(indices / 3);
|
|
275
|
+
else if (primitive.mode === 5 || primitive.mode === 6)
|
|
276
|
+
triangleCount += Math.max(0, indices - 2);
|
|
277
|
+
morphTargetCount += Array.isArray(primitive?.targets) ? primitive.targets.length : 0;
|
|
278
|
+
}
|
|
279
|
+
const widths = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }, components = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
|
|
280
|
+
const decodedAccessorBytes = (json.accessors ?? []).reduce((sum, accessor) => {
|
|
281
|
+
const width = widths[accessor.componentType], dimension = accessor.type === "MAT2" ? 2 : accessor.type === "MAT3" ? 3 : accessor.type === "MAT4" ? 4 : 0;
|
|
282
|
+
return sum + accessor.count * (dimension ? Math.ceil(dimension * width / 4) * 4 * dimension : width * components[accessor.type]);
|
|
283
|
+
}, 0);
|
|
284
|
+
return { fileBytes, nodeCount: json.nodes?.length ?? 0, meshCount: json.meshes?.length ?? 0, primitiveCount: primitives.length, materialCount: json.materials?.length ?? 0, textureCount: json.textures?.length ?? 0, imageCount: json.images?.length ?? 0, vertexCount, triangleCount, morphTargetCount, decodedAccessorBytes };
|
|
285
|
+
}
|
|
150
286
|
export function inspectVrma(bytes) {
|
|
151
287
|
const json = parseGlb(bytes, "vrma");
|
|
152
288
|
if (json.animations?.length !== 1)
|
|
153
|
-
throw new
|
|
289
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "each VRMA file must contain exactly one animation");
|
|
154
290
|
let duration = 0;
|
|
155
291
|
for (const sampler of json.animations[0].samplers ?? []) {
|
|
156
292
|
const accessor = json.accessors?.[sampler.input];
|
|
157
293
|
if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0]))
|
|
158
|
-
throw new
|
|
294
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "VRMA has invalid animation times");
|
|
159
295
|
duration = Math.max(duration, accessor.max[0]);
|
|
160
296
|
}
|
|
161
297
|
if (!(duration > 0) || duration > 600)
|
|
162
|
-
throw new
|
|
298
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "VRMA duration must be between 0 and 600 seconds");
|
|
163
299
|
return { durationMs: duration * 1000 };
|
|
164
300
|
}
|
|
165
301
|
function validateStructure(json, binary) {
|
|
166
|
-
for (const key of ["nodes", "accessors", "bufferViews", "buffers", "meshes", "images", "animations", "skins", "scenes"]) {
|
|
302
|
+
for (const key of ["nodes", "accessors", "bufferViews", "buffers", "meshes", "materials", "textures", "images", "animations", "skins", "scenes", "extensionsUsed", "extensionsRequired"]) {
|
|
167
303
|
if (json[key] !== undefined && !Array.isArray(json[key]))
|
|
168
|
-
throw new
|
|
304
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", `invalid glTF ${key}`);
|
|
169
305
|
}
|
|
170
306
|
const views = json.bufferViews ?? [], nodes = json.nodes ?? [], accessors = json.accessors ?? [];
|
|
171
307
|
const integer = (n, min, max) => Number.isSafeInteger(n) && n >= min && n <= max;
|
|
@@ -173,19 +309,19 @@ function validateStructure(json, binary) {
|
|
|
173
309
|
const seen = new Set(), active = new Set(), parents = new Set();
|
|
174
310
|
const walk = (i, depth) => {
|
|
175
311
|
if (!index(i, nodes) || active.has(i) || depth > 128)
|
|
176
|
-
throw new
|
|
312
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF node hierarchy");
|
|
177
313
|
if (seen.has(i))
|
|
178
314
|
return;
|
|
179
315
|
const node = nodes[i];
|
|
180
316
|
if (!node || typeof node !== "object")
|
|
181
|
-
throw new
|
|
317
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF node");
|
|
182
318
|
seen.add(i);
|
|
183
319
|
active.add(i);
|
|
184
320
|
if (node.children !== undefined && !Array.isArray(node.children))
|
|
185
|
-
throw new
|
|
321
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid node children");
|
|
186
322
|
for (const child of node.children ?? []) {
|
|
187
323
|
if (parents.has(child))
|
|
188
|
-
throw new
|
|
324
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "node has multiple parents");
|
|
189
325
|
parents.add(child);
|
|
190
326
|
walk(child, depth + 1);
|
|
191
327
|
}
|
|
@@ -198,28 +334,28 @@ function validateStructure(json, binary) {
|
|
|
198
334
|
let decoded = 0;
|
|
199
335
|
const span = (viewIndex, offset, count, element, alignment, strided = false) => {
|
|
200
336
|
if (!index(viewIndex, views))
|
|
201
|
-
throw new
|
|
337
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid accessor buffer view");
|
|
202
338
|
const v = views[viewIndex], stride = strided ? (v.byteStride ?? element) : element;
|
|
203
339
|
if (!integer(offset, 0, v.byteLength) || offset % alignment || !integer(stride, element, 256) || stride % alignment || offset + (count - 1) * stride + element > v.byteLength)
|
|
204
|
-
throw new
|
|
340
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "accessor exceeds buffer view");
|
|
205
341
|
return (v.byteOffset ?? 0) + offset;
|
|
206
342
|
};
|
|
207
343
|
for (const a of accessors) {
|
|
208
344
|
const width = sizes[a?.componentType], count = a?.count, componentsCount = components[a?.type];
|
|
209
345
|
if (!width || !componentsCount || !integer(count, 1, 2000000))
|
|
210
|
-
throw new
|
|
346
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid accessor allocation");
|
|
211
347
|
const dimension = a.type === "MAT2" ? 2 : a.type === "MAT3" ? 3 : a.type === "MAT4" ? 4 : 0;
|
|
212
348
|
const element = dimension ? Math.ceil(dimension * width / 4) * 4 * dimension : width * componentsCount;
|
|
213
349
|
decoded += element * count;
|
|
214
350
|
if (decoded > 256 * 1024 * 1024)
|
|
215
|
-
throw new
|
|
351
|
+
throw new VrmInspectionError("RESOURCE_LIMIT", "decoded geometry exceeds 256 MiB");
|
|
216
352
|
const checkFloats = (at, n, stride) => {
|
|
217
353
|
if (a.componentType !== 5126)
|
|
218
354
|
return;
|
|
219
355
|
for (let k = 0; k < n; k++)
|
|
220
356
|
for (let c = 0; c < componentsCount; c++)
|
|
221
357
|
if (!Number.isFinite(binary.getFloat32(at + k * stride + c * 4, true)))
|
|
222
|
-
throw new
|
|
358
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "non-finite accessor value");
|
|
223
359
|
};
|
|
224
360
|
if (a.bufferView !== undefined) {
|
|
225
361
|
const at = span(a.bufferView, a.byteOffset ?? 0, count, element, width, true);
|
|
@@ -228,7 +364,7 @@ function validateStructure(json, binary) {
|
|
|
228
364
|
if (a.sparse) {
|
|
229
365
|
const sparse = a.sparse, n = sparse.count, iw = sizes[sparse.indices?.componentType];
|
|
230
366
|
if (!integer(n, 1, count) || ![5121, 5123, 5125].includes(sparse.indices?.componentType))
|
|
231
|
-
throw new
|
|
367
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid sparse accessor");
|
|
232
368
|
const at = span(sparse.indices.bufferView, sparse.indices.byteOffset ?? 0, n, iw, iw);
|
|
233
369
|
const valuesAt = span(sparse.values?.bufferView, sparse.values?.byteOffset ?? 0, n, element, width);
|
|
234
370
|
checkFloats(valuesAt, n, element);
|
|
@@ -237,36 +373,74 @@ function validateStructure(json, binary) {
|
|
|
237
373
|
const offset = at + k * iw;
|
|
238
374
|
const next = iw === 1 ? binary.getUint8(offset) : iw === 2 ? binary.getUint16(offset, true) : binary.getUint32(offset, true);
|
|
239
375
|
if (next <= previous || next >= count)
|
|
240
|
-
throw new
|
|
376
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid sparse index");
|
|
241
377
|
previous = next;
|
|
242
378
|
}
|
|
243
379
|
}
|
|
244
380
|
}
|
|
381
|
+
// Validate the references used by capability and size reporting as well as
|
|
382
|
+
// the loader. Never infer a working morph from an arbitrary target object.
|
|
383
|
+
let primitiveCount = 0;
|
|
384
|
+
for (const mesh of json.meshes ?? []) {
|
|
385
|
+
if (!mesh || !Array.isArray(mesh.primitives) || !mesh.primitives.length)
|
|
386
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF mesh primitives");
|
|
387
|
+
primitiveCount += mesh.primitives.length;
|
|
388
|
+
if (primitiveCount > 32768)
|
|
389
|
+
throw new VrmInspectionError("RESOURCE_LIMIT", "glTF primitive count exceeded");
|
|
390
|
+
for (const primitive of mesh.primitives) {
|
|
391
|
+
const position = accessors[primitive?.attributes?.POSITION];
|
|
392
|
+
if (!primitive || !position || position.type !== "VEC3" || position.componentType !== 5126)
|
|
393
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF primitive POSITION accessor");
|
|
394
|
+
if (primitive.indices !== undefined) {
|
|
395
|
+
const indices = accessors[primitive.indices];
|
|
396
|
+
if (!index(primitive.indices, accessors) || indices.type !== "SCALAR" || ![5121, 5123, 5125].includes(indices.componentType))
|
|
397
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF primitive index accessor");
|
|
398
|
+
}
|
|
399
|
+
if (primitive.mode !== undefined && !integer(primitive.mode, 0, 6))
|
|
400
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF primitive mode");
|
|
401
|
+
if (primitive.targets !== undefined && (!Array.isArray(primitive.targets) || primitive.targets.length > 1024))
|
|
402
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF morph target list");
|
|
403
|
+
for (const target of primitive.targets ?? []) {
|
|
404
|
+
if (!target || typeof target !== "object" || Array.isArray(target))
|
|
405
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF morph target");
|
|
406
|
+
for (const semantic of ["POSITION", "NORMAL", "COLOR_0"]) {
|
|
407
|
+
if (target[semantic] === undefined)
|
|
408
|
+
continue;
|
|
409
|
+
const accessor = accessors[target[semantic]];
|
|
410
|
+
if (!index(target[semantic], accessors) || accessor.componentType !== 5126 || !(semantic === "COLOR_0" ? ["VEC3", "VEC4"] : ["VEC3"]).includes(accessor.type) || accessor.count !== position.count)
|
|
411
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF morph target accessor");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (const node of nodes)
|
|
417
|
+
if (node.mesh !== undefined && !index(node.mesh, json.meshes ?? []))
|
|
418
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF node mesh reference");
|
|
245
419
|
for (const animation of json.animations ?? []) {
|
|
246
420
|
if (!Array.isArray(animation.samplers) || !Array.isArray(animation.channels))
|
|
247
|
-
throw new
|
|
421
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation");
|
|
248
422
|
for (const channel of animation.channels) {
|
|
249
423
|
if (!index(channel.sampler, animation.samplers) || !index(channel.target?.node, nodes) || !["translation", "rotation", "scale", "weights"].includes(channel.target?.path))
|
|
250
|
-
throw new
|
|
424
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation channel");
|
|
251
425
|
}
|
|
252
426
|
for (const sampler of animation.samplers) {
|
|
253
427
|
if (!index(sampler.input, accessors) || !index(sampler.output, accessors))
|
|
254
|
-
throw new
|
|
428
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation accessor");
|
|
255
429
|
const a = accessors[sampler.input];
|
|
256
430
|
if (a.type !== "SCALAR" || a.componentType !== 5126 || a.sparse || a.bufferView === undefined)
|
|
257
|
-
throw new
|
|
431
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation timeline");
|
|
258
432
|
const start = span(a.bufferView, a.byteOffset ?? 0, a.count, 4, 4);
|
|
259
433
|
let previous = -1;
|
|
260
434
|
for (let i = 0; i < a.count; i++) {
|
|
261
435
|
const time = binary.getFloat32(start + i * 4, true);
|
|
262
436
|
if (!Number.isFinite(time) || time <= previous || time < 0 || time > 600)
|
|
263
|
-
throw new
|
|
437
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation times");
|
|
264
438
|
previous = time;
|
|
265
439
|
}
|
|
266
440
|
if (!Array.isArray(a.max) || Math.abs(a.max[0] - previous) > .001)
|
|
267
|
-
throw new
|
|
441
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "animation duration does not match timeline");
|
|
268
442
|
if (sampler.interpolation !== undefined && !["LINEAR", "STEP", "CUBICSPLINE"].includes(sampler.interpolation))
|
|
269
|
-
throw new
|
|
443
|
+
throw new VrmInspectionError("INVALID_STRUCTURE", "unsupported animation interpolation");
|
|
270
444
|
}
|
|
271
445
|
}
|
|
272
446
|
}
|
package/dist/playback.js
CHANGED
|
@@ -6,6 +6,8 @@ export class VrmAnimationPlayer {
|
|
|
6
6
|
#clips;
|
|
7
7
|
#restore;
|
|
8
8
|
#returnTo;
|
|
9
|
+
#lookAtClips = new Set();
|
|
10
|
+
#blinkClips = new Set();
|
|
9
11
|
#active;
|
|
10
12
|
#finished = false;
|
|
11
13
|
#transition;
|
|
@@ -14,16 +16,36 @@ export class VrmAnimationPlayer {
|
|
|
14
16
|
if (returnTo && !clips.has(returnTo))
|
|
15
17
|
throw new Error(`return animation does not exist: ${returnTo}`);
|
|
16
18
|
this.mixer = new AnimationMixer(root);
|
|
17
|
-
this.#clips = clips;
|
|
18
19
|
this.#restore = restore;
|
|
19
20
|
this.#returnTo = returnTo;
|
|
21
|
+
this.#clips = new Map([...clips].map(([name, clip]) => {
|
|
22
|
+
if (clip.tracks.some(track => track.name.includes("LookAt")))
|
|
23
|
+
this.#lookAtClips.add(name);
|
|
24
|
+
if (clip.tracks.some(track => /VRMExpression_blink(?:Left|Right)?\.weight$/.test(track.name)))
|
|
25
|
+
this.#blinkClips.add(name);
|
|
26
|
+
// Embedded glTF uses UUIDs while VRMA uses names for the same rig nodes.
|
|
27
|
+
// Both actions and snapshots must share a binding key; otherwise two
|
|
28
|
+
// PropertyMixers overwrite each other and restore a stale pose on exit.
|
|
29
|
+
const tracks = clip.tracks.map(track => {
|
|
30
|
+
const parsed = PropertyBinding.parseTrackName(track.name);
|
|
31
|
+
const node = PropertyBinding.findNode(root, parsed.nodeName);
|
|
32
|
+
if (!node)
|
|
33
|
+
return track;
|
|
34
|
+
const object = parsed.objectName ? "." + parsed.objectName + (parsed.objectIndex !== undefined ? "[" + parsed.objectIndex + "]" : "") : "";
|
|
35
|
+
const canonical = node.uuid + object + "." + parsed.propertyName + (parsed.propertyIndex !== undefined ? "[" + parsed.propertyIndex + "]" : "");
|
|
36
|
+
// A shallow track wrapper retains cubic interpolants and shares the
|
|
37
|
+
// potentially large immutable time/value arrays with the source clip.
|
|
38
|
+
return canonical === track.name ? track : Object.assign(Object.create(Object.getPrototypeOf(track)), track, { name: canonical });
|
|
39
|
+
});
|
|
40
|
+
return [name, new AnimationClip(clip.name, clip.duration, tracks, clip.blendMode)];
|
|
41
|
+
}));
|
|
20
42
|
this.mixer.addEventListener("finished", (event) => { if (event.action === this.#active?.action)
|
|
21
43
|
this.#finished = true; });
|
|
22
44
|
}
|
|
23
45
|
get activeName() { return this.#active?.name; }
|
|
24
46
|
get isOneShot() { return this.#active?.action.loop === LoopOnce; }
|
|
25
|
-
get hasLookAtTrack() { return this.#
|
|
26
|
-
get hasBlinkTrack() { return this.#
|
|
47
|
+
get hasLookAtTrack() { return this.#lookAtClips.has(this.#active?.name ?? ""); }
|
|
48
|
+
get hasBlinkTrack() { return this.#blinkClips.has(this.#active?.name ?? ""); }
|
|
27
49
|
play(name, options = {}) {
|
|
28
50
|
const clip = this.#clips.get(name);
|
|
29
51
|
if (!clip)
|