@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/dist/inspect.cjs CHANGED
@@ -21,153 +21,279 @@ var inspect_exports = {};
21
21
  __export(inspect_exports, {
22
22
  MAX_ANIMATION_BYTES: () => MAX_ANIMATION_BYTES,
23
23
  MAX_MODEL_BYTES: () => MAX_MODEL_BYTES,
24
+ VrmInspectionError: () => VrmInspectionError,
24
25
  embeddedAnimationCatalog: () => embeddedAnimationCatalog,
25
26
  inspectVrm: () => inspectVrm,
26
27
  inspectVrma: () => inspectVrma,
27
28
  parseGlb: () => parseGlb
28
29
  });
29
30
  module.exports = __toCommonJS(inspect_exports);
31
+
32
+ // src/builtin-motion-catalog.ts
33
+ var BUILTIN_ANIMATIONS = [
34
+ { name: "StarLiteIdle", durationMs: 6e3 },
35
+ { name: "StarLiteCuriousPeek", durationMs: 2100 },
36
+ { name: "StarLiteHappyWave", durationMs: 4600 },
37
+ { name: "StarLiteSleepyStartle", durationMs: 5500 }
38
+ ];
39
+ function isBuiltinAnimation(name) {
40
+ return BUILTIN_ANIMATIONS.some((animation) => animation.name === name);
41
+ }
42
+
43
+ // src/inspect.ts
44
+ var VrmInspectionError = class extends Error {
45
+ code;
46
+ details;
47
+ constructor(code, message, details = {}) {
48
+ super(message);
49
+ this.name = "VrmInspectionError";
50
+ this.code = code;
51
+ this.details = details;
52
+ }
53
+ };
54
+ var requiredBones = ["hips", "spine", "head", ...["left", "right"].flatMap((side) => ["UpperLeg", "LowerLeg", "Foot", "UpperArm", "LowerArm", "Hand"].map((part) => side + part))];
55
+ var 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))];
30
56
  var MAX_MODEL_BYTES = 64 * 1024 * 1024;
31
57
  var MAX_ANIMATION_BYTES = 16 * 1024 * 1024;
32
58
  function parseGlb(bytes, kind) {
59
+ try {
60
+ return parseGlbContents(bytes, kind);
61
+ } catch (error) {
62
+ if (error instanceof VrmInspectionError) throw error;
63
+ throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF structure; re-export a self-contained VRM/VRMA file");
64
+ }
65
+ }
66
+ function parseGlbContents(bytes, kind) {
33
67
  const limit = kind === "vrm" ? MAX_MODEL_BYTES : MAX_ANIMATION_BYTES;
34
- if (bytes.byteLength < 20 || bytes.byteLength > limit) throw new Error("invalid GLB size");
68
+ if (bytes.byteLength < 20 || bytes.byteLength > limit) 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`);
35
69
  const view = new DataView(bytes);
36
- if (view.getUint32(0, true) !== 1179937895 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.byteLength) throw new Error("invalid GLB header");
70
+ if (view.getUint32(0, true) !== 1179937895 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.byteLength) throw new VrmInspectionError("INVALID_GLB", "invalid GLB header; export binary glTF 2.0 VRM/VRMA, not FBX or JSON glTF");
37
71
  let json;
38
72
  let binSize = 0;
39
73
  let binStart = 0;
40
74
  let chunkCount = 0;
41
75
  for (let offset = 12; offset < bytes.byteLength; ) {
42
- if (offset + 8 > bytes.byteLength) throw new Error("truncated GLB chunk");
76
+ if (offset + 8 > bytes.byteLength) throw new VrmInspectionError("INVALID_GLB", "truncated GLB chunk; download or export the complete file again");
43
77
  const size = view.getUint32(offset, true), type = view.getUint32(offset + 4, true);
44
78
  offset += 8;
45
- if (size % 4 || size > bytes.byteLength - offset) throw new Error("invalid GLB chunk length");
79
+ if (size % 4 || size > bytes.byteLength - offset) throw new VrmInspectionError("INVALID_GLB", "invalid GLB chunk length; download or export the complete file again");
46
80
  if (chunkCount === 0 && type === 1313821514) {
47
- json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes, offset, size)));
81
+ try {
82
+ json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes, offset, size)));
83
+ } catch {
84
+ throw new VrmInspectionError("INVALID_GLB", "invalid GLB JSON; re-export the file with a VRM exporter");
85
+ }
48
86
  } else if (chunkCount === 1 && type === 5130562) {
49
87
  binSize = size;
50
88
  binStart = offset;
51
- } else throw new Error("unexpected GLB chunk");
89
+ } else throw new VrmInspectionError("INVALID_GLB", "unexpected GLB chunk; export a self-contained VRM/VRMA file");
52
90
  offset += size;
53
91
  chunkCount++;
54
92
  }
55
- if (!json || json.asset?.version !== "2.0") throw new Error("glTF 2.0 is required");
93
+ if (!json || typeof json !== "object" || Array.isArray(json) || json.asset?.version !== "2.0") throw new VrmInspectionError("UNSUPPORTED_FORMAT", "glTF 2.0 is required; use a VRM/VRMA exporter");
56
94
  const expected = kind === "vrm" ? json.extensions?.VRM ?? json.extensions?.VRMC_vrm : json.extensions?.VRMC_vrm_animation;
57
- if (!expected) throw new Error(`missing ${kind} extension`);
58
- if (kind === "vrm" && json.extensions?.VRM && json.extensions.VRM.specVersion !== "0.0") throw new Error("unsupported VRM version");
59
- if (kind === "vrm" && json.extensions?.VRMC_vrm && json.extensions.VRMC_vrm.specVersion !== "1.0") throw new Error("unsupported VRM version");
60
- if (kind === "vrma" && expected.specVersion !== "1.0") throw new Error("VRMA 1.0 is required");
95
+ if (!expected) throw new VrmInspectionError("UNSUPPORTED_FORMAT", `missing ${kind} extension; export ${kind === "vrm" ? "VRM 0.0 or 1.0" : "VRMA 1.0"}, not a plain GLB`);
96
+ if (kind === "vrm" && json.extensions?.VRM && json.extensions.VRM.specVersion !== "0.0") throw new VrmInspectionError("UNSUPPORTED_VERSION", "unsupported VRM version; export VRM 0.0 or 1.0");
97
+ if (kind === "vrm" && json.extensions?.VRMC_vrm && json.extensions.VRMC_vrm.specVersion !== "1.0") throw new VrmInspectionError("UNSUPPORTED_VERSION", "unsupported VRM version; export VRM 0.0 or 1.0");
98
+ if (kind === "vrma" && expected.specVersion !== "1.0") throw new VrmInspectionError("UNSUPPORTED_VERSION", "VRMA 1.0 is required");
61
99
  const allowed = /* @__PURE__ */ 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"]);
62
- if ((json.extensionsRequired ?? []).some((name) => !allowed.has(name))) throw new Error("unsupported required glTF extension");
100
+ if ((json.extensionsRequired ?? []).some((name) => !allowed.has(name))) throw new VrmInspectionError("UNSUPPORTED_EXTENSION", "unsupported required glTF extension; re-export without geometry or texture compression extensions");
63
101
  const visit = (value, depth = 0) => {
64
- if (depth > 64) throw new Error("glTF nesting limit exceeded");
102
+ if (depth > 64) throw new VrmInspectionError("RESOURCE_LIMIT", "glTF nesting limit exceeded");
65
103
  if (!value || typeof value !== "object") return;
66
104
  for (const [key, child] of Object.entries(value)) {
67
- if (key === "uri") throw new Error("external glTF resources are not supported");
68
- if (typeof child === "number" && !Number.isFinite(child)) throw new Error("non-finite glTF value");
105
+ if (key === "uri") throw new VrmInspectionError("EXTERNAL_RESOURCE", "external glTF resources are not supported; embed all textures and buffers in one VRM/VRMA file");
106
+ if (typeof child === "number" && !Number.isFinite(child)) throw new VrmInspectionError("INVALID_STRUCTURE", "non-finite glTF value");
69
107
  visit(child, depth + 1);
70
108
  }
71
109
  };
72
110
  visit(json);
73
- if ((json.buffers?.length ?? 0) > 1 || (json.buffers?.[0]?.byteLength ?? 0) > binSize) throw new Error("invalid GLB buffer");
111
+ if ((json.buffers?.length ?? 0) > 1 || (json.buffers?.[0]?.byteLength ?? 0) > binSize) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid GLB buffer");
74
112
  for (const buffer of json.bufferViews ?? []) {
75
113
  const start = buffer.byteOffset ?? 0, length = buffer.byteLength;
76
- if (buffer.buffer !== 0 || !Number.isSafeInteger(start) || !Number.isSafeInteger(length) || start < 0 || length < 0 || start + length > (json.buffers?.[0]?.byteLength ?? 0)) throw new Error("invalid glTF buffer view");
114
+ if (buffer.buffer !== 0 || !Number.isSafeInteger(start) || !Number.isSafeInteger(length) || start < 0 || length < 0 || start + length > (json.buffers?.[0]?.byteLength ?? 0)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF buffer view");
77
115
  }
78
- if ((json.nodes?.length ?? 0) > 4096 || (json.accessors?.length ?? 0) > 32768) throw new Error("glTF resource count exceeded");
116
+ if ((json.nodes?.length ?? 0) > 4096 || (json.accessors?.length ?? 0) > 32768) throw new VrmInspectionError("RESOURCE_LIMIT", "glTF resource count exceeded");
79
117
  validateStructure(json, new DataView(bytes, binStart, binSize));
80
118
  return json;
81
119
  }
82
120
  function embeddedAnimationCatalog(json) {
83
121
  const animations = json.animations ?? [];
84
- if (!Array.isArray(animations) || animations.length > 64) throw new Error("at most 64 embedded animations are supported");
122
+ if (!Array.isArray(animations) || animations.length > 64) throw new VrmInspectionError("INVALID_ANIMATION", "at most 64 embedded animations are supported");
85
123
  const names = /* @__PURE__ */ new Set();
86
124
  return animations.map((animation, index) => {
87
125
  const name = animation.name ?? "Animation_" + (index + 1);
88
- if (typeof name !== "string" || !name.trim() || new TextEncoder().encode(name).length > 128 || /[\u0000-\u001f\u007f]/.test(name) || name === "StarLiteIdle" || names.has(name)) throw new Error("invalid or duplicate embedded animation name");
126
+ if (typeof name !== "string" || !name.trim() || new TextEncoder().encode(name).length > 128 || /[\u0000-\u001f\u007f]/.test(name) || isBuiltinAnimation(name) || names.has(name)) 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");
89
127
  names.add(name);
90
128
  let duration = 0;
91
129
  for (const sampler of animation.samplers ?? []) {
92
130
  const accessor = json.accessors?.[sampler.input];
93
- if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0])) throw new Error("embedded animation has invalid animation times");
131
+ if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0])) throw new VrmInspectionError("INVALID_ANIMATION", "embedded animation has invalid animation times");
94
132
  duration = Math.max(duration, accessor.max[0]);
95
133
  }
96
- if (!(duration > 0) || duration > 600) throw new Error("embedded animation duration must be between 0 and 600 seconds");
134
+ if (!(duration > 0) || duration > 600) throw new VrmInspectionError("INVALID_ANIMATION", "embedded animation duration must be between 0 and 600 seconds");
97
135
  return { name, durationMs: duration * 1e3 };
98
136
  });
99
137
  }
100
138
  function inspectVrm(bytes) {
101
139
  const json = parseGlb(bytes, "vrm");
102
140
  const v0 = json.extensions.VRM;
103
- const humanoid = (v0 ?? json.extensions.VRMC_vrm).humanoid?.humanBones;
104
- const bones = v0 ? Object.fromEntries((humanoid ?? []).map((b) => [b.bone, b])) : humanoid;
105
- const required = ["hips", "spine", "head", ...["left", "right"].flatMap((side) => ["UpperLeg", "LowerLeg", "Foot", "UpperArm", "LowerArm", "Hand"].map((part) => side + part))];
106
- const used = /* @__PURE__ */ new Set();
107
- for (const name2 of required) {
108
- const node = bones?.[name2]?.node;
109
- if (!Number.isSafeInteger(node) || !json.nodes?.[node] || used.has(node)) throw new Error("invalid required humanoid bone");
110
- used.add(node);
111
- }
141
+ const extension = v0 ?? json.extensions.VRMC_vrm;
142
+ if (Array.isArray(json.extensionsUsed) && !json.extensionsUsed.includes(v0 ? "VRM" : "VRMC_vrm")) throw new VrmInspectionError("UNSUPPORTED_FORMAT", "missing VRM extension declaration; re-export with the VRM exporter");
143
+ const bones = inspectHumanBones(json, extension.humanoid?.humanBones, Boolean(v0));
112
144
  const blinkNames = v0 ? ["blink", "blink_l", "blink_r"] : ["blink", "blinkLeft", "blinkRight"];
113
145
  const groups = v0?.blendShapeMaster?.blendShapeGroups;
114
- if (groups !== void 0 && !Array.isArray(groups)) throw new Error("invalid VRM blink expressions");
115
- const blinkPresets = v0 ? (groups ?? []).filter((item) => blinkNames.includes(item?.presetName)) : blinkNames.flatMap((name2) => {
116
- const expression = json.extensions.VRMC_vrm.expressions?.preset?.[name2];
117
- return expression === void 0 ? [] : [expression];
118
- });
119
- for (const expression of blinkPresets) {
120
- if (!expression || typeof expression !== "object" || Array.isArray(expression)) throw new Error("invalid VRM blink expression");
121
- const binds = v0 ? expression.binds : expression.morphTargetBinds;
122
- if (binds === void 0) continue;
123
- if (!Array.isArray(binds)) throw new Error("invalid VRM blink bindings");
124
- for (const bind of binds) {
125
- const mesh = v0 ? bind?.mesh : json.nodes?.[bind?.node]?.mesh;
126
- const primitives = json.meshes?.[mesh]?.primitives;
127
- if (!bind || !Number.isSafeInteger(mesh) || mesh < 0 || !v0 && (!Number.isSafeInteger(bind.node) || bind.node < 0) || !Number.isSafeInteger(bind.index) || bind.index < 0 || !Number.isFinite(bind.weight) || bind.weight < 0 || bind.weight > (v0 ? 100 : 1) || !Array.isArray(primitives) || !primitives.some((primitive) => primitive.targets?.[bind.index] !== void 0)) {
128
- throw new Error("invalid VRM blink morph binding");
129
- }
130
- }
146
+ if (groups !== void 0 && !Array.isArray(groups)) throw new VrmInspectionError("INVALID_BLINK", "invalid VRM blink expressions; re-export the blink presets");
147
+ 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" };
148
+ const registeredName = (item) => aliases[item?.presetName] ?? item?.name;
149
+ const expression = (name2) => v0 ? (groups ?? []).find((item) => registeredName(item) === (aliases[name2] ?? name2)) : extension.expressions?.preset?.[name2];
150
+ for (const preset of blinkNames) {
151
+ const presets = v0 ? (groups ?? []).filter((item) => registeredName(item) === aliases[preset]) : expression(preset) === void 0 ? [] : [expression(preset)];
152
+ for (const item of presets) inspectMorphExpression(json, item, Boolean(v0), "INVALID_BLINK", preset);
131
153
  }
132
- const meta = (v0 ?? json.extensions.VRMC_vrm).meta ?? {};
154
+ const [combined, left, right] = blinkNames.map((preset) => inspectMorphExpression(json, expression(preset), Boolean(v0), "INVALID_BLINK", preset));
155
+ const meta = extension.meta ?? {};
133
156
  const name = v0 ? meta.title : meta.name;
134
157
  return {
135
158
  version: v0 ? "0" : "1",
136
- ...typeof name === "string" ? { name } : {},
159
+ ...typeof name === "string" ? { name: safeLabel(name) } : {},
137
160
  animations: embeddedAnimationCatalog(json),
138
- expressions: v0 ? (v0.blendShapeMaster?.blendShapeGroups ?? []).map((group) => group.name) : Object.keys({ ...json.extensions.VRMC_vrm.expressions?.preset, ...json.extensions.VRMC_vrm.expressions?.custom })
161
+ expressions: (v0 ? (groups ?? []).map((group) => group?.name) : Object.keys({ ...extension.expressions?.preset, ...extension.expressions?.custom })).filter((value) => typeof value === "string").map(safeLabel),
162
+ capabilities: {
163
+ humanoid: { requiredBones: [...requiredBones], optionalBones: optionalBones.filter((name2) => bones.has(name2)) },
164
+ blink: { automatic: Boolean(combined || left && right), combined: Boolean(combined), left: Boolean(left), right: Boolean(right) },
165
+ lookAt: inspectLookAt(json, extension, Boolean(v0), bones, expression)
166
+ },
167
+ metrics: inspectMetrics(json, bytes.byteLength)
168
+ };
169
+ }
170
+ function safeLabel(value) {
171
+ return value.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 128);
172
+ }
173
+ function inspectHumanBones(json, humanoid, v0) {
174
+ const known = /* @__PURE__ */ new Set([...requiredBones, ...optionalBones]), result = /* @__PURE__ */ new Map();
175
+ const invalid = [], duplicates = [], used = /* @__PURE__ */ new Map();
176
+ const entries = v0 ? Array.isArray(humanoid) ? humanoid.filter((item) => item && known.has(item.bone)).map((item) => [item.bone, item]) : [] : humanoid && typeof humanoid === "object" && !Array.isArray(humanoid) ? Object.entries(humanoid).filter(([name]) => known.has(name)) : [];
177
+ for (const [name, value] of entries) {
178
+ const node = value?.node;
179
+ if (result.has(name)) duplicates.push(name);
180
+ if (!Number.isSafeInteger(node) || node < 0 || !json.nodes?.[node]) {
181
+ invalid.push(name);
182
+ continue;
183
+ }
184
+ const previous = used.get(node);
185
+ if (previous && previous !== name) duplicates.push(previous, name);
186
+ used.set(node, name);
187
+ result.set(name, node);
188
+ }
189
+ const missing = requiredBones.filter((name) => !result.has(name) && !invalid.includes(name));
190
+ if (missing.length || invalid.length || duplicates.length) {
191
+ const details = { missingBones: missing, invalidBones: [...new Set(invalid)], duplicateBones: [...new Set(duplicates)] };
192
+ 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("; ");
193
+ throw new VrmInspectionError("HUMANOID_BONES", `invalid required humanoid bone: ${summary}; assign separate human bones in the VRM exporter and export again`, details);
194
+ }
195
+ return result;
196
+ }
197
+ function inspectMorphExpression(json, expression, v0, code, preset) {
198
+ if (expression === void 0) return false;
199
+ const label = code === "INVALID_BLINK" ? "blink" : "look-at";
200
+ if (!expression || typeof expression !== "object" || Array.isArray(expression)) 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 === void 0) return false;
203
+ if (!Array.isArray(binds)) throw new VrmInspectionError(code, `invalid VRM ${label} bindings; re-export the ${preset} preset`, { preset });
204
+ let usable = false;
205
+ for (const bind of binds) {
206
+ const mesh = v0 ? bind?.mesh : json.nodes?.[bind?.node]?.mesh;
207
+ const primitives = json.meshes?.[mesh]?.primitives;
208
+ if (!bind || !Number.isSafeInteger(mesh) || mesh < 0 || !v0 && (!Number.isSafeInteger(bind.node) || bind.node < 0) || !Number.isSafeInteger(bind.index) || bind.index < 0 || !Number.isFinite(bind.weight) || bind.weight < 0 || bind.weight > (v0 ? 100 : 1) || !Array.isArray(primitives) || !primitives.length || !primitives.every((primitive) => primitive?.targets?.[bind.index] !== void 0)) {
209
+ throw new VrmInspectionError(code, `invalid VRM ${label} morph binding: ${preset}; check its mesh/node, morph target index and weight in the VRM exporter`, { preset });
210
+ }
211
+ const instantiated = !v0 || (json.nodes ?? []).some((node) => node?.mesh === mesh);
212
+ const morphs = primitives.every((primitive) => primitive.targets.some((target) => ["POSITION", "NORMAL", "COLOR_0"].some((semantic) => Number.isSafeInteger(target?.[semantic]) && json.accessors?.[target[semantic]])));
213
+ usable ||= bind.weight > 0 && instantiated && morphs;
214
+ }
215
+ return usable;
216
+ }
217
+ function inspectLookAt(json, extension, v0, bones, expression) {
218
+ const lookAt = v0 ? extension.firstPerson : extension.lookAt;
219
+ const empty = { mode: "none", head: true, eyes: false, horizontal: false, vertical: false };
220
+ if (lookAt === void 0) return empty;
221
+ if (!lookAt || typeof lookAt !== "object" || Array.isArray(lookAt)) throw new VrmInspectionError("INVALID_LOOK_AT", "invalid VRM look-at configuration; re-export eye tracking");
222
+ const expressionManager = v0 ? extension.blendShapeMaster : extension.expressions;
223
+ if (!expressionManager) return empty;
224
+ const type = v0 ? lookAt.lookAtTypeName : lookAt.type;
225
+ if (type !== void 0 && !(v0 ? ["Bone", "BlendShape"] : ["bone", "expression"]).includes(type)) throw new VrmInspectionError("INVALID_LOOK_AT", "invalid VRM look-at type; use bone or expression eye tracking in the VRM exporter");
226
+ const mode = type === (v0 ? "BlendShape" : "expression") ? "expression" : "bone";
227
+ const scale = (name) => {
228
+ const range = lookAt[name], value = range?.[v0 ? "yRange" : "outputScale"] ?? (mode === "expression" ? 1 : 10);
229
+ const input = range?.[v0 ? "xRange" : "inputMaxValue"] ?? 90;
230
+ if (range !== void 0 && (!range || typeof range !== "object" || Array.isArray(range)) || !Number.isFinite(value) || value < 0 || !Number.isFinite(input) || input < 0) throw new VrmInspectionError("INVALID_LOOK_AT", "invalid VRM look-at range; use finite, non-negative input and output ranges");
231
+ return value;
139
232
  };
233
+ const horizontalRange = scale(v0 ? "lookAtHorizontalOuter" : "rangeMapHorizontalOuter");
234
+ const innerRange = scale(v0 ? "lookAtHorizontalInner" : "rangeMapHorizontalInner");
235
+ const upRange = scale(v0 ? "lookAtVerticalUp" : "rangeMapVerticalUp"), downRange = scale(v0 ? "lookAtVerticalDown" : "rangeMapVerticalDown");
236
+ let horizontal, vertical;
237
+ if (mode === "bone") {
238
+ const eyes = bones.has("leftEye") || bones.has("rightEye");
239
+ horizontal = eyes && (horizontalRange > 0 || innerRange > 0);
240
+ vertical = eyes && (upRange > 0 || downRange > 0);
241
+ } else {
242
+ const usable = (name) => inspectMorphExpression(json, expression(name), v0, "INVALID_LOOK_AT", name);
243
+ const left = usable(v0 ? "lookleft" : "lookLeft"), right = usable(v0 ? "lookright" : "lookRight"), up = usable(v0 ? "lookup" : "lookUp"), down = usable(v0 ? "lookdown" : "lookDown");
244
+ horizontal = horizontalRange > 0 && left && right;
245
+ vertical = upRange > 0 && downRange > 0 && up && down;
246
+ }
247
+ return { mode, head: true, eyes: horizontal || vertical, horizontal, vertical };
248
+ }
249
+ function inspectMetrics(json, fileBytes) {
250
+ const primitives = (json.meshes ?? []).flatMap((mesh) => Array.isArray(mesh?.primitives) ? mesh.primitives : []);
251
+ let vertexCount = 0, triangleCount = 0, morphTargetCount = 0;
252
+ for (const primitive of primitives) {
253
+ const vertices = json.accessors?.[primitive?.attributes?.POSITION]?.count ?? 0;
254
+ const indices = primitive?.indices === void 0 ? vertices : json.accessors?.[primitive.indices]?.count ?? 0;
255
+ vertexCount += vertices;
256
+ if (primitive?.mode === void 0 || primitive.mode === 4) triangleCount += Math.floor(indices / 3);
257
+ else if (primitive.mode === 5 || primitive.mode === 6) triangleCount += Math.max(0, indices - 2);
258
+ morphTargetCount += Array.isArray(primitive?.targets) ? primitive.targets.length : 0;
259
+ }
260
+ 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 };
261
+ const decodedAccessorBytes = (json.accessors ?? []).reduce((sum, accessor) => {
262
+ const width = widths[accessor.componentType], dimension = accessor.type === "MAT2" ? 2 : accessor.type === "MAT3" ? 3 : accessor.type === "MAT4" ? 4 : 0;
263
+ return sum + accessor.count * (dimension ? Math.ceil(dimension * width / 4) * 4 * dimension : width * components[accessor.type]);
264
+ }, 0);
265
+ 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 };
140
266
  }
141
267
  function inspectVrma(bytes) {
142
268
  const json = parseGlb(bytes, "vrma");
143
- if (json.animations?.length !== 1) throw new Error("each VRMA file must contain exactly one animation");
269
+ if (json.animations?.length !== 1) throw new VrmInspectionError("INVALID_STRUCTURE", "each VRMA file must contain exactly one animation");
144
270
  let duration = 0;
145
271
  for (const sampler of json.animations[0].samplers ?? []) {
146
272
  const accessor = json.accessors?.[sampler.input];
147
- if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0])) throw new Error("VRMA has invalid animation times");
273
+ if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0])) throw new VrmInspectionError("INVALID_STRUCTURE", "VRMA has invalid animation times");
148
274
  duration = Math.max(duration, accessor.max[0]);
149
275
  }
150
- if (!(duration > 0) || duration > 600) throw new Error("VRMA duration must be between 0 and 600 seconds");
276
+ if (!(duration > 0) || duration > 600) throw new VrmInspectionError("INVALID_STRUCTURE", "VRMA duration must be between 0 and 600 seconds");
151
277
  return { durationMs: duration * 1e3 };
152
278
  }
153
279
  function validateStructure(json, binary) {
154
- for (const key of ["nodes", "accessors", "bufferViews", "buffers", "meshes", "images", "animations", "skins", "scenes"]) {
155
- if (json[key] !== void 0 && !Array.isArray(json[key])) throw new Error(`invalid glTF ${key}`);
280
+ for (const key of ["nodes", "accessors", "bufferViews", "buffers", "meshes", "materials", "textures", "images", "animations", "skins", "scenes", "extensionsUsed", "extensionsRequired"]) {
281
+ if (json[key] !== void 0 && !Array.isArray(json[key])) throw new VrmInspectionError("INVALID_STRUCTURE", `invalid glTF ${key}`);
156
282
  }
157
283
  const views = json.bufferViews ?? [], nodes = json.nodes ?? [], accessors = json.accessors ?? [];
158
284
  const integer = (n, min, max) => Number.isSafeInteger(n) && n >= min && n <= max;
159
285
  const index = (n, array) => integer(n, 0, array.length - 1);
160
286
  const seen = /* @__PURE__ */ new Set(), active = /* @__PURE__ */ new Set(), parents = /* @__PURE__ */ new Set();
161
287
  const walk = (i, depth) => {
162
- if (!index(i, nodes) || active.has(i) || depth > 128) throw new Error("invalid glTF node hierarchy");
288
+ if (!index(i, nodes) || active.has(i) || depth > 128) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF node hierarchy");
163
289
  if (seen.has(i)) return;
164
290
  const node = nodes[i];
165
- if (!node || typeof node !== "object") throw new Error("invalid glTF node");
291
+ if (!node || typeof node !== "object") throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF node");
166
292
  seen.add(i);
167
293
  active.add(i);
168
- if (node.children !== void 0 && !Array.isArray(node.children)) throw new Error("invalid node children");
294
+ if (node.children !== void 0 && !Array.isArray(node.children)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid node children");
169
295
  for (const child of node.children ?? []) {
170
- if (parents.has(child)) throw new Error("node has multiple parents");
296
+ if (parents.has(child)) throw new VrmInspectionError("INVALID_STRUCTURE", "node has multiple parents");
171
297
  parents.add(child);
172
298
  walk(child, depth + 1);
173
299
  }
@@ -178,21 +304,21 @@ function validateStructure(json, binary) {
178
304
  const sizes = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
179
305
  let decoded = 0;
180
306
  const span = (viewIndex, offset, count, element, alignment, strided = false) => {
181
- if (!index(viewIndex, views)) throw new Error("invalid accessor buffer view");
307
+ if (!index(viewIndex, views)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid accessor buffer view");
182
308
  const v = views[viewIndex], stride = strided ? v.byteStride ?? element : element;
183
- if (!integer(offset, 0, v.byteLength) || offset % alignment || !integer(stride, element, 256) || stride % alignment || offset + (count - 1) * stride + element > v.byteLength) throw new Error("accessor exceeds buffer view");
309
+ if (!integer(offset, 0, v.byteLength) || offset % alignment || !integer(stride, element, 256) || stride % alignment || offset + (count - 1) * stride + element > v.byteLength) throw new VrmInspectionError("INVALID_STRUCTURE", "accessor exceeds buffer view");
184
310
  return (v.byteOffset ?? 0) + offset;
185
311
  };
186
312
  for (const a of accessors) {
187
313
  const width = sizes[a?.componentType], count = a?.count, componentsCount = components[a?.type];
188
- if (!width || !componentsCount || !integer(count, 1, 2e6)) throw new Error("invalid accessor allocation");
314
+ if (!width || !componentsCount || !integer(count, 1, 2e6)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid accessor allocation");
189
315
  const dimension = a.type === "MAT2" ? 2 : a.type === "MAT3" ? 3 : a.type === "MAT4" ? 4 : 0;
190
316
  const element = dimension ? Math.ceil(dimension * width / 4) * 4 * dimension : width * componentsCount;
191
317
  decoded += element * count;
192
- if (decoded > 256 * 1024 * 1024) throw new Error("decoded geometry exceeds 256 MiB");
318
+ if (decoded > 256 * 1024 * 1024) throw new VrmInspectionError("RESOURCE_LIMIT", "decoded geometry exceeds 256 MiB");
193
319
  const checkFloats = (at, n, stride) => {
194
320
  if (a.componentType !== 5126) return;
195
- for (let k = 0; k < n; k++) for (let c = 0; c < componentsCount; c++) if (!Number.isFinite(binary.getFloat32(at + k * stride + c * 4, true))) throw new Error("non-finite accessor value");
321
+ for (let k = 0; k < n; k++) for (let c = 0; c < componentsCount; c++) if (!Number.isFinite(binary.getFloat32(at + k * stride + c * 4, true))) throw new VrmInspectionError("INVALID_STRUCTURE", "non-finite accessor value");
196
322
  };
197
323
  if (a.bufferView !== void 0) {
198
324
  const at = span(a.bufferView, a.byteOffset ?? 0, count, element, width, true);
@@ -200,7 +326,7 @@ function validateStructure(json, binary) {
200
326
  }
201
327
  if (a.sparse) {
202
328
  const sparse = a.sparse, n = sparse.count, iw = sizes[sparse.indices?.componentType];
203
- if (!integer(n, 1, count) || ![5121, 5123, 5125].includes(sparse.indices?.componentType)) throw new Error("invalid sparse accessor");
329
+ if (!integer(n, 1, count) || ![5121, 5123, 5125].includes(sparse.indices?.componentType)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid sparse accessor");
204
330
  const at = span(sparse.indices.bufferView, sparse.indices.byteOffset ?? 0, n, iw, iw);
205
331
  const valuesAt = span(sparse.values?.bufferView, sparse.values?.byteOffset ?? 0, n, element, width);
206
332
  checkFloats(valuesAt, n, element);
@@ -208,29 +334,54 @@ function validateStructure(json, binary) {
208
334
  for (let k = 0; k < n; k++) {
209
335
  const offset = at + k * iw;
210
336
  const next = iw === 1 ? binary.getUint8(offset) : iw === 2 ? binary.getUint16(offset, true) : binary.getUint32(offset, true);
211
- if (next <= previous || next >= count) throw new Error("invalid sparse index");
337
+ if (next <= previous || next >= count) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid sparse index");
212
338
  previous = next;
213
339
  }
214
340
  }
215
341
  }
342
+ let primitiveCount = 0;
343
+ for (const mesh of json.meshes ?? []) {
344
+ if (!mesh || !Array.isArray(mesh.primitives) || !mesh.primitives.length) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF mesh primitives");
345
+ primitiveCount += mesh.primitives.length;
346
+ if (primitiveCount > 32768) throw new VrmInspectionError("RESOURCE_LIMIT", "glTF primitive count exceeded");
347
+ for (const primitive of mesh.primitives) {
348
+ const position = accessors[primitive?.attributes?.POSITION];
349
+ if (!primitive || !position || position.type !== "VEC3" || position.componentType !== 5126) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF primitive POSITION accessor");
350
+ if (primitive.indices !== void 0) {
351
+ const indices = accessors[primitive.indices];
352
+ if (!index(primitive.indices, accessors) || indices.type !== "SCALAR" || ![5121, 5123, 5125].includes(indices.componentType)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF primitive index accessor");
353
+ }
354
+ if (primitive.mode !== void 0 && !integer(primitive.mode, 0, 6)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF primitive mode");
355
+ if (primitive.targets !== void 0 && (!Array.isArray(primitive.targets) || primitive.targets.length > 1024)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF morph target list");
356
+ for (const target of primitive.targets ?? []) {
357
+ if (!target || typeof target !== "object" || Array.isArray(target)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF morph target");
358
+ for (const semantic of ["POSITION", "NORMAL", "COLOR_0"]) {
359
+ if (target[semantic] === void 0) continue;
360
+ const accessor = accessors[target[semantic]];
361
+ if (!index(target[semantic], accessors) || accessor.componentType !== 5126 || !(semantic === "COLOR_0" ? ["VEC3", "VEC4"] : ["VEC3"]).includes(accessor.type) || accessor.count !== position.count) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF morph target accessor");
362
+ }
363
+ }
364
+ }
365
+ }
366
+ for (const node of nodes) if (node.mesh !== void 0 && !index(node.mesh, json.meshes ?? [])) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid glTF node mesh reference");
216
367
  for (const animation of json.animations ?? []) {
217
- if (!Array.isArray(animation.samplers) || !Array.isArray(animation.channels)) throw new Error("invalid animation");
368
+ if (!Array.isArray(animation.samplers) || !Array.isArray(animation.channels)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation");
218
369
  for (const channel of animation.channels) {
219
- if (!index(channel.sampler, animation.samplers) || !index(channel.target?.node, nodes) || !["translation", "rotation", "scale", "weights"].includes(channel.target?.path)) throw new Error("invalid animation channel");
370
+ if (!index(channel.sampler, animation.samplers) || !index(channel.target?.node, nodes) || !["translation", "rotation", "scale", "weights"].includes(channel.target?.path)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation channel");
220
371
  }
221
372
  for (const sampler of animation.samplers) {
222
- if (!index(sampler.input, accessors) || !index(sampler.output, accessors)) throw new Error("invalid animation accessor");
373
+ if (!index(sampler.input, accessors) || !index(sampler.output, accessors)) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation accessor");
223
374
  const a = accessors[sampler.input];
224
- if (a.type !== "SCALAR" || a.componentType !== 5126 || a.sparse || a.bufferView === void 0) throw new Error("invalid animation timeline");
375
+ if (a.type !== "SCALAR" || a.componentType !== 5126 || a.sparse || a.bufferView === void 0) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation timeline");
225
376
  const start = span(a.bufferView, a.byteOffset ?? 0, a.count, 4, 4);
226
377
  let previous = -1;
227
378
  for (let i = 0; i < a.count; i++) {
228
379
  const time = binary.getFloat32(start + i * 4, true);
229
- if (!Number.isFinite(time) || time <= previous || time < 0 || time > 600) throw new Error("invalid animation times");
380
+ if (!Number.isFinite(time) || time <= previous || time < 0 || time > 600) throw new VrmInspectionError("INVALID_STRUCTURE", "invalid animation times");
230
381
  previous = time;
231
382
  }
232
- if (!Array.isArray(a.max) || Math.abs(a.max[0] - previous) > 1e-3) throw new Error("animation duration does not match timeline");
233
- if (sampler.interpolation !== void 0 && !["LINEAR", "STEP", "CUBICSPLINE"].includes(sampler.interpolation)) throw new Error("unsupported animation interpolation");
383
+ if (!Array.isArray(a.max) || Math.abs(a.max[0] - previous) > 1e-3) throw new VrmInspectionError("INVALID_STRUCTURE", "animation duration does not match timeline");
384
+ if (sampler.interpolation !== void 0 && !["LINEAR", "STEP", "CUBICSPLINE"].includes(sampler.interpolation)) throw new VrmInspectionError("INVALID_STRUCTURE", "unsupported animation interpolation");
234
385
  }
235
386
  }
236
387
  }
@@ -238,6 +389,7 @@ function validateStructure(json, binary) {
238
389
  0 && (module.exports = {
239
390
  MAX_ANIMATION_BYTES,
240
391
  MAX_MODEL_BYTES,
392
+ VrmInspectionError,
241
393
  embeddedAnimationCatalog,
242
394
  inspectVrm,
243
395
  inspectVrma,
package/dist/inspect.d.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  type Json = Record<string, any>;
2
- export declare const MAX_MODEL_BYTES: number;
3
- export declare const MAX_ANIMATION_BYTES: number;
4
- export declare function parseGlb(bytes: ArrayBuffer, kind: "vrm" | "vrma"): Json;
5
- export declare function embeddedAnimationCatalog(json: Json): {
6
- name: string;
7
- durationMs: number;
8
- }[];
9
- export declare function inspectVrm(bytes: ArrayBuffer): {
2
+ export type VrmInspectionErrorCode = "INVALID_GLB" | "UNSUPPORTED_FORMAT" | "UNSUPPORTED_VERSION" | "UNSUPPORTED_EXTENSION" | "EXTERNAL_RESOURCE" | "RESOURCE_LIMIT" | "INVALID_STRUCTURE" | "HUMANOID_BONES" | "INVALID_BLINK" | "INVALID_LOOK_AT" | "INVALID_ANIMATION";
3
+ export interface VrmInspectionErrorDetails {
4
+ missingBones?: readonly string[];
5
+ invalidBones?: readonly string[];
6
+ duplicateBones?: readonly string[];
7
+ preset?: string;
8
+ }
9
+ /** Controlled diagnostic text only: never include input paths, URIs or JSON text. */
10
+ export declare class VrmInspectionError extends Error {
11
+ readonly code: VrmInspectionErrorCode;
12
+ readonly details: Readonly<VrmInspectionErrorDetails>;
13
+ constructor(code: VrmInspectionErrorCode, message: string, details?: VrmInspectionErrorDetails);
14
+ }
15
+ export interface VrmInspection {
10
16
  version: "0" | "1";
11
17
  name?: string;
12
18
  expressions: string[];
@@ -14,7 +20,48 @@ export declare function inspectVrm(bytes: ArrayBuffer): {
14
20
  name: string;
15
21
  durationMs: number;
16
22
  }[];
17
- };
23
+ capabilities: {
24
+ humanoid: {
25
+ requiredBones: string[];
26
+ optionalBones: string[];
27
+ };
28
+ blink: {
29
+ automatic: boolean;
30
+ combined: boolean;
31
+ left: boolean;
32
+ right: boolean;
33
+ };
34
+ lookAt: {
35
+ mode: "bone" | "expression" | "none";
36
+ head: boolean;
37
+ eyes: boolean;
38
+ horizontal: boolean;
39
+ vertical: boolean;
40
+ };
41
+ };
42
+ /** Logical accessor bytes and aggregate mesh primitive counts, not GPU memory. */
43
+ metrics: {
44
+ fileBytes: number;
45
+ nodeCount: number;
46
+ meshCount: number;
47
+ primitiveCount: number;
48
+ materialCount: number;
49
+ textureCount: number;
50
+ imageCount: number;
51
+ vertexCount: number;
52
+ triangleCount: number;
53
+ morphTargetCount: number;
54
+ decodedAccessorBytes: number;
55
+ };
56
+ }
57
+ export declare const MAX_MODEL_BYTES: number;
58
+ export declare const MAX_ANIMATION_BYTES: number;
59
+ export declare function parseGlb(bytes: ArrayBuffer, kind: "vrm" | "vrma"): Json;
60
+ export declare function embeddedAnimationCatalog(json: Json): {
61
+ name: string;
62
+ durationMs: number;
63
+ }[];
64
+ export declare function inspectVrm(bytes: ArrayBuffer): VrmInspection;
18
65
  export declare function inspectVrma(bytes: ArrayBuffer): {
19
66
  durationMs: number;
20
67
  };