@starcried/starlite-vrm 0.1.0-alpha.2 → 0.1.0-alpha.3

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