@starcried/starlite-vrm 0.0.0-stage → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEFAULT_IDLE.md +21 -0
- package/LICENSE +25 -0
- package/README.md +45 -2
- package/THIRD_PARTY_LICENSES.txt +80 -0
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/dist/camera.d.ts +17 -0
- package/dist/camera.js +112 -0
- package/dist/character.d.ts +10 -0
- package/dist/character.js +145 -0
- package/dist/config.cjs +152 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +114 -0
- package/dist/default-idle-data.d.ts +1 -0
- package/dist/default-idle-data.js +2 -0
- package/dist/default-idle.d.ts +2 -0
- package/dist/default-idle.js +5 -0
- package/dist/embedded.d.ts +11 -0
- package/dist/embedded.js +154 -0
- package/dist/gaze.d.ts +28 -0
- package/dist/gaze.js +102 -0
- package/dist/host-motion.d.ts +14 -0
- package/dist/host-motion.js +97 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5243 -0
- package/dist/inspect.cjs +245 -0
- package/dist/inspect.d.ts +21 -0
- package/dist/inspect.js +272 -0
- package/dist/playback.d.ts +17 -0
- package/dist/playback.js +122 -0
- package/dist/pose-bounds.d.ts +8 -0
- package/dist/pose-bounds.js +80 -0
- package/dist/prepare.cjs +387 -0
- package/dist/prepare.d.ts +42 -0
- package/dist/prepare.js +134 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +1 -0
- package/dist/widget.css +69 -0
- package/package.json +56 -4
package/dist/inspect.cjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// src/inspect.ts
|
|
20
|
+
var inspect_exports = {};
|
|
21
|
+
__export(inspect_exports, {
|
|
22
|
+
MAX_ANIMATION_BYTES: () => MAX_ANIMATION_BYTES,
|
|
23
|
+
MAX_MODEL_BYTES: () => MAX_MODEL_BYTES,
|
|
24
|
+
embeddedAnimationCatalog: () => embeddedAnimationCatalog,
|
|
25
|
+
inspectVrm: () => inspectVrm,
|
|
26
|
+
inspectVrma: () => inspectVrma,
|
|
27
|
+
parseGlb: () => parseGlb
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(inspect_exports);
|
|
30
|
+
var MAX_MODEL_BYTES = 64 * 1024 * 1024;
|
|
31
|
+
var MAX_ANIMATION_BYTES = 16 * 1024 * 1024;
|
|
32
|
+
function parseGlb(bytes, kind) {
|
|
33
|
+
const limit = kind === "vrm" ? MAX_MODEL_BYTES : MAX_ANIMATION_BYTES;
|
|
34
|
+
if (bytes.byteLength < 20 || bytes.byteLength > limit) throw new Error("invalid GLB size");
|
|
35
|
+
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");
|
|
37
|
+
let json;
|
|
38
|
+
let binSize = 0;
|
|
39
|
+
let binStart = 0;
|
|
40
|
+
let chunkCount = 0;
|
|
41
|
+
for (let offset = 12; offset < bytes.byteLength; ) {
|
|
42
|
+
if (offset + 8 > bytes.byteLength) throw new Error("truncated GLB chunk");
|
|
43
|
+
const size = view.getUint32(offset, true), type = view.getUint32(offset + 4, true);
|
|
44
|
+
offset += 8;
|
|
45
|
+
if (size % 4 || size > bytes.byteLength - offset) throw new Error("invalid GLB chunk length");
|
|
46
|
+
if (chunkCount === 0 && type === 1313821514) {
|
|
47
|
+
json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes, offset, size)));
|
|
48
|
+
} else if (chunkCount === 1 && type === 5130562) {
|
|
49
|
+
binSize = size;
|
|
50
|
+
binStart = offset;
|
|
51
|
+
} else throw new Error("unexpected GLB chunk");
|
|
52
|
+
offset += size;
|
|
53
|
+
chunkCount++;
|
|
54
|
+
}
|
|
55
|
+
if (!json || json.asset?.version !== "2.0") throw new Error("glTF 2.0 is required");
|
|
56
|
+
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");
|
|
61
|
+
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");
|
|
63
|
+
const visit = (value, depth = 0) => {
|
|
64
|
+
if (depth > 64) throw new Error("glTF nesting limit exceeded");
|
|
65
|
+
if (!value || typeof value !== "object") return;
|
|
66
|
+
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");
|
|
69
|
+
visit(child, depth + 1);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
visit(json);
|
|
73
|
+
if ((json.buffers?.length ?? 0) > 1 || (json.buffers?.[0]?.byteLength ?? 0) > binSize) throw new Error("invalid GLB buffer");
|
|
74
|
+
for (const buffer of json.bufferViews ?? []) {
|
|
75
|
+
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");
|
|
77
|
+
}
|
|
78
|
+
if ((json.nodes?.length ?? 0) > 4096 || (json.accessors?.length ?? 0) > 32768) throw new Error("glTF resource count exceeded");
|
|
79
|
+
validateStructure(json, new DataView(bytes, binStart, binSize));
|
|
80
|
+
return json;
|
|
81
|
+
}
|
|
82
|
+
function embeddedAnimationCatalog(json) {
|
|
83
|
+
const animations = json.animations ?? [];
|
|
84
|
+
if (!Array.isArray(animations) || animations.length > 64) throw new Error("at most 64 embedded animations are supported");
|
|
85
|
+
const names = /* @__PURE__ */ new Set();
|
|
86
|
+
return animations.map((animation, index) => {
|
|
87
|
+
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");
|
|
89
|
+
names.add(name);
|
|
90
|
+
let duration = 0;
|
|
91
|
+
for (const sampler of animation.samplers ?? []) {
|
|
92
|
+
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");
|
|
94
|
+
duration = Math.max(duration, accessor.max[0]);
|
|
95
|
+
}
|
|
96
|
+
if (!(duration > 0) || duration > 600) throw new Error("embedded animation duration must be between 0 and 600 seconds");
|
|
97
|
+
return { name, durationMs: duration * 1e3 };
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function inspectVrm(bytes) {
|
|
101
|
+
const json = parseGlb(bytes, "vrm");
|
|
102
|
+
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
|
+
}
|
|
112
|
+
const blinkNames = v0 ? ["blink", "blink_l", "blink_r"] : ["blink", "blinkLeft", "blinkRight"];
|
|
113
|
+
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
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const meta = (v0 ?? json.extensions.VRMC_vrm).meta ?? {};
|
|
133
|
+
const name = v0 ? meta.title : meta.name;
|
|
134
|
+
return {
|
|
135
|
+
version: v0 ? "0" : "1",
|
|
136
|
+
...typeof name === "string" ? { name } : {},
|
|
137
|
+
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 })
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function inspectVrma(bytes) {
|
|
142
|
+
const json = parseGlb(bytes, "vrma");
|
|
143
|
+
if (json.animations?.length !== 1) throw new Error("each VRMA file must contain exactly one animation");
|
|
144
|
+
let duration = 0;
|
|
145
|
+
for (const sampler of json.animations[0].samplers ?? []) {
|
|
146
|
+
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");
|
|
148
|
+
duration = Math.max(duration, accessor.max[0]);
|
|
149
|
+
}
|
|
150
|
+
if (!(duration > 0) || duration > 600) throw new Error("VRMA duration must be between 0 and 600 seconds");
|
|
151
|
+
return { durationMs: duration * 1e3 };
|
|
152
|
+
}
|
|
153
|
+
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}`);
|
|
156
|
+
}
|
|
157
|
+
const views = json.bufferViews ?? [], nodes = json.nodes ?? [], accessors = json.accessors ?? [];
|
|
158
|
+
const integer = (n, min, max) => Number.isSafeInteger(n) && n >= min && n <= max;
|
|
159
|
+
const index = (n, array) => integer(n, 0, array.length - 1);
|
|
160
|
+
const seen = /* @__PURE__ */ new Set(), active = /* @__PURE__ */ new Set(), parents = /* @__PURE__ */ new Set();
|
|
161
|
+
const walk = (i, depth) => {
|
|
162
|
+
if (!index(i, nodes) || active.has(i) || depth > 128) throw new Error("invalid glTF node hierarchy");
|
|
163
|
+
if (seen.has(i)) return;
|
|
164
|
+
const node = nodes[i];
|
|
165
|
+
if (!node || typeof node !== "object") throw new Error("invalid glTF node");
|
|
166
|
+
seen.add(i);
|
|
167
|
+
active.add(i);
|
|
168
|
+
if (node.children !== void 0 && !Array.isArray(node.children)) throw new Error("invalid node children");
|
|
169
|
+
for (const child of node.children ?? []) {
|
|
170
|
+
if (parents.has(child)) throw new Error("node has multiple parents");
|
|
171
|
+
parents.add(child);
|
|
172
|
+
walk(child, depth + 1);
|
|
173
|
+
}
|
|
174
|
+
active.delete(i);
|
|
175
|
+
};
|
|
176
|
+
for (let i = 0; i < nodes.length; i++) walk(i, 0);
|
|
177
|
+
const components = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
|
|
178
|
+
const sizes = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
|
179
|
+
let decoded = 0;
|
|
180
|
+
const span = (viewIndex, offset, count, element, alignment, strided = false) => {
|
|
181
|
+
if (!index(viewIndex, views)) throw new Error("invalid accessor buffer view");
|
|
182
|
+
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");
|
|
184
|
+
return (v.byteOffset ?? 0) + offset;
|
|
185
|
+
};
|
|
186
|
+
for (const a of accessors) {
|
|
187
|
+
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");
|
|
189
|
+
const dimension = a.type === "MAT2" ? 2 : a.type === "MAT3" ? 3 : a.type === "MAT4" ? 4 : 0;
|
|
190
|
+
const element = dimension ? Math.ceil(dimension * width / 4) * 4 * dimension : width * componentsCount;
|
|
191
|
+
decoded += element * count;
|
|
192
|
+
if (decoded > 256 * 1024 * 1024) throw new Error("decoded geometry exceeds 256 MiB");
|
|
193
|
+
const checkFloats = (at, n, stride) => {
|
|
194
|
+
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");
|
|
196
|
+
};
|
|
197
|
+
if (a.bufferView !== void 0) {
|
|
198
|
+
const at = span(a.bufferView, a.byteOffset ?? 0, count, element, width, true);
|
|
199
|
+
checkFloats(at, count, views[a.bufferView].byteStride ?? element);
|
|
200
|
+
}
|
|
201
|
+
if (a.sparse) {
|
|
202
|
+
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");
|
|
204
|
+
const at = span(sparse.indices.bufferView, sparse.indices.byteOffset ?? 0, n, iw, iw);
|
|
205
|
+
const valuesAt = span(sparse.values?.bufferView, sparse.values?.byteOffset ?? 0, n, element, width);
|
|
206
|
+
checkFloats(valuesAt, n, element);
|
|
207
|
+
let previous = -1;
|
|
208
|
+
for (let k = 0; k < n; k++) {
|
|
209
|
+
const offset = at + k * iw;
|
|
210
|
+
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");
|
|
212
|
+
previous = next;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const animation of json.animations ?? []) {
|
|
217
|
+
if (!Array.isArray(animation.samplers) || !Array.isArray(animation.channels)) throw new Error("invalid animation");
|
|
218
|
+
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");
|
|
220
|
+
}
|
|
221
|
+
for (const sampler of animation.samplers) {
|
|
222
|
+
if (!index(sampler.input, accessors) || !index(sampler.output, accessors)) throw new Error("invalid animation accessor");
|
|
223
|
+
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");
|
|
225
|
+
const start = span(a.bufferView, a.byteOffset ?? 0, a.count, 4, 4);
|
|
226
|
+
let previous = -1;
|
|
227
|
+
for (let i = 0; i < a.count; i++) {
|
|
228
|
+
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");
|
|
230
|
+
previous = time;
|
|
231
|
+
}
|
|
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");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
238
|
+
0 && (module.exports = {
|
|
239
|
+
MAX_ANIMATION_BYTES,
|
|
240
|
+
MAX_MODEL_BYTES,
|
|
241
|
+
embeddedAnimationCatalog,
|
|
242
|
+
inspectVrm,
|
|
243
|
+
inspectVrma,
|
|
244
|
+
parseGlb
|
|
245
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
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): {
|
|
10
|
+
version: "0" | "1";
|
|
11
|
+
name?: string;
|
|
12
|
+
expressions: string[];
|
|
13
|
+
animations: {
|
|
14
|
+
name: string;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
}[];
|
|
17
|
+
};
|
|
18
|
+
export declare function inspectVrma(bytes: ArrayBuffer): {
|
|
19
|
+
durationMs: number;
|
|
20
|
+
};
|
|
21
|
+
export {};
|
package/dist/inspect.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
export const MAX_MODEL_BYTES = 64 * 1024 * 1024;
|
|
2
|
+
export const MAX_ANIMATION_BYTES = 16 * 1024 * 1024;
|
|
3
|
+
export function parseGlb(bytes, kind) {
|
|
4
|
+
const limit = kind === "vrm" ? MAX_MODEL_BYTES : MAX_ANIMATION_BYTES;
|
|
5
|
+
if (bytes.byteLength < 20 || bytes.byteLength > limit)
|
|
6
|
+
throw new Error("invalid GLB size");
|
|
7
|
+
const view = new DataView(bytes);
|
|
8
|
+
if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.byteLength)
|
|
9
|
+
throw new Error("invalid GLB header");
|
|
10
|
+
let json;
|
|
11
|
+
let binSize = 0;
|
|
12
|
+
let binStart = 0;
|
|
13
|
+
let chunkCount = 0;
|
|
14
|
+
for (let offset = 12; offset < bytes.byteLength;) {
|
|
15
|
+
if (offset + 8 > bytes.byteLength)
|
|
16
|
+
throw new Error("truncated GLB chunk");
|
|
17
|
+
const size = view.getUint32(offset, true), type = view.getUint32(offset + 4, true);
|
|
18
|
+
offset += 8;
|
|
19
|
+
if (size % 4 || size > bytes.byteLength - offset)
|
|
20
|
+
throw new Error("invalid GLB chunk length");
|
|
21
|
+
if (chunkCount === 0 && type === 0x4e4f534a) {
|
|
22
|
+
json = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes, offset, size)));
|
|
23
|
+
}
|
|
24
|
+
else if (chunkCount === 1 && type === 0x004e4942) {
|
|
25
|
+
binSize = size;
|
|
26
|
+
binStart = offset;
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
throw new Error("unexpected GLB chunk");
|
|
30
|
+
offset += size;
|
|
31
|
+
chunkCount++;
|
|
32
|
+
}
|
|
33
|
+
if (!json || json.asset?.version !== "2.0")
|
|
34
|
+
throw new Error("glTF 2.0 is required");
|
|
35
|
+
const expected = kind === "vrm" ? (json.extensions?.VRM ?? json.extensions?.VRMC_vrm) : json.extensions?.VRMC_vrm_animation;
|
|
36
|
+
if (!expected)
|
|
37
|
+
throw new Error(`missing ${kind} extension`);
|
|
38
|
+
if (kind === "vrm" && json.extensions?.VRM && json.extensions.VRM.specVersion !== "0.0")
|
|
39
|
+
throw new Error("unsupported VRM version");
|
|
40
|
+
if (kind === "vrm" && json.extensions?.VRMC_vrm && json.extensions.VRMC_vrm.specVersion !== "1.0")
|
|
41
|
+
throw new Error("unsupported VRM version");
|
|
42
|
+
if (kind === "vrma" && expected.specVersion !== "1.0")
|
|
43
|
+
throw new Error("VRMA 1.0 is required");
|
|
44
|
+
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
|
+
if ((json.extensionsRequired ?? []).some((name) => !allowed.has(name)))
|
|
46
|
+
throw new Error("unsupported required glTF extension");
|
|
47
|
+
// No externally resolved buffers, images or extension URIs. The loader only
|
|
48
|
+
// receives self-contained GLB; model import cannot access the network or disk.
|
|
49
|
+
const visit = (value, depth = 0) => {
|
|
50
|
+
if (depth > 64)
|
|
51
|
+
throw new Error("glTF nesting limit exceeded");
|
|
52
|
+
if (!value || typeof value !== "object")
|
|
53
|
+
return;
|
|
54
|
+
for (const [key, child] of Object.entries(value)) {
|
|
55
|
+
if (key === "uri")
|
|
56
|
+
throw new Error("external glTF resources are not supported");
|
|
57
|
+
if (typeof child === "number" && !Number.isFinite(child))
|
|
58
|
+
throw new Error("non-finite glTF value");
|
|
59
|
+
visit(child, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
visit(json);
|
|
63
|
+
if ((json.buffers?.length ?? 0) > 1 || (json.buffers?.[0]?.byteLength ?? 0) > binSize)
|
|
64
|
+
throw new Error("invalid GLB buffer");
|
|
65
|
+
for (const buffer of json.bufferViews ?? []) {
|
|
66
|
+
const start = buffer.byteOffset ?? 0, length = buffer.byteLength;
|
|
67
|
+
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");
|
|
69
|
+
}
|
|
70
|
+
if ((json.nodes?.length ?? 0) > 4096 || (json.accessors?.length ?? 0) > 32768)
|
|
71
|
+
throw new Error("glTF resource count exceeded");
|
|
72
|
+
validateStructure(json, new DataView(bytes, binStart, binSize));
|
|
73
|
+
return json;
|
|
74
|
+
}
|
|
75
|
+
export function embeddedAnimationCatalog(json) {
|
|
76
|
+
const animations = json.animations ?? [];
|
|
77
|
+
if (!Array.isArray(animations) || animations.length > 64)
|
|
78
|
+
throw new Error("at most 64 embedded animations are supported");
|
|
79
|
+
const names = new Set();
|
|
80
|
+
return animations.map((animation, index) => {
|
|
81
|
+
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 === "StarLiteIdle" || names.has(name))
|
|
83
|
+
throw new Error("invalid or duplicate embedded animation name");
|
|
84
|
+
names.add(name);
|
|
85
|
+
let duration = 0;
|
|
86
|
+
for (const sampler of animation.samplers ?? []) {
|
|
87
|
+
const accessor = json.accessors?.[sampler.input];
|
|
88
|
+
if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0]))
|
|
89
|
+
throw new Error("embedded animation has invalid animation times");
|
|
90
|
+
duration = Math.max(duration, accessor.max[0]);
|
|
91
|
+
}
|
|
92
|
+
if (!(duration > 0) || duration > 600)
|
|
93
|
+
throw new Error("embedded animation duration must be between 0 and 600 seconds");
|
|
94
|
+
return { name, durationMs: duration * 1000 };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
export function inspectVrm(bytes) {
|
|
98
|
+
const json = parseGlb(bytes, "vrm");
|
|
99
|
+
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
|
+
}
|
|
110
|
+
// Blink is an optional capability. Declared bindings must still be valid;
|
|
111
|
+
// missing, empty or zero-weight presets simply provide no automatic blink.
|
|
112
|
+
const blinkNames = v0 ? ["blink", "blink_l", "blink_r"] : ["blink", "blinkLeft", "blinkRight"];
|
|
113
|
+
const groups = v0?.blendShapeMaster?.blendShapeGroups;
|
|
114
|
+
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
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const meta = (v0 ?? json.extensions.VRMC_vrm).meta ?? {};
|
|
142
|
+
const name = v0 ? meta.title : meta.name;
|
|
143
|
+
return {
|
|
144
|
+
version: v0 ? "0" : "1",
|
|
145
|
+
...(typeof name === "string" ? { name } : {}),
|
|
146
|
+
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 }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function inspectVrma(bytes) {
|
|
151
|
+
const json = parseGlb(bytes, "vrma");
|
|
152
|
+
if (json.animations?.length !== 1)
|
|
153
|
+
throw new Error("each VRMA file must contain exactly one animation");
|
|
154
|
+
let duration = 0;
|
|
155
|
+
for (const sampler of json.animations[0].samplers ?? []) {
|
|
156
|
+
const accessor = json.accessors?.[sampler.input];
|
|
157
|
+
if (!accessor || accessor.type !== "SCALAR" || accessor.componentType !== 5126 || !Number.isFinite(accessor.max?.[0]))
|
|
158
|
+
throw new Error("VRMA has invalid animation times");
|
|
159
|
+
duration = Math.max(duration, accessor.max[0]);
|
|
160
|
+
}
|
|
161
|
+
if (!(duration > 0) || duration > 600)
|
|
162
|
+
throw new Error("VRMA duration must be between 0 and 600 seconds");
|
|
163
|
+
return { durationMs: duration * 1000 };
|
|
164
|
+
}
|
|
165
|
+
function validateStructure(json, binary) {
|
|
166
|
+
for (const key of ["nodes", "accessors", "bufferViews", "buffers", "meshes", "images", "animations", "skins", "scenes"]) {
|
|
167
|
+
if (json[key] !== undefined && !Array.isArray(json[key]))
|
|
168
|
+
throw new Error(`invalid glTF ${key}`);
|
|
169
|
+
}
|
|
170
|
+
const views = json.bufferViews ?? [], nodes = json.nodes ?? [], accessors = json.accessors ?? [];
|
|
171
|
+
const integer = (n, min, max) => Number.isSafeInteger(n) && n >= min && n <= max;
|
|
172
|
+
const index = (n, array) => integer(n, 0, array.length - 1);
|
|
173
|
+
const seen = new Set(), active = new Set(), parents = new Set();
|
|
174
|
+
const walk = (i, depth) => {
|
|
175
|
+
if (!index(i, nodes) || active.has(i) || depth > 128)
|
|
176
|
+
throw new Error("invalid glTF node hierarchy");
|
|
177
|
+
if (seen.has(i))
|
|
178
|
+
return;
|
|
179
|
+
const node = nodes[i];
|
|
180
|
+
if (!node || typeof node !== "object")
|
|
181
|
+
throw new Error("invalid glTF node");
|
|
182
|
+
seen.add(i);
|
|
183
|
+
active.add(i);
|
|
184
|
+
if (node.children !== undefined && !Array.isArray(node.children))
|
|
185
|
+
throw new Error("invalid node children");
|
|
186
|
+
for (const child of node.children ?? []) {
|
|
187
|
+
if (parents.has(child))
|
|
188
|
+
throw new Error("node has multiple parents");
|
|
189
|
+
parents.add(child);
|
|
190
|
+
walk(child, depth + 1);
|
|
191
|
+
}
|
|
192
|
+
active.delete(i);
|
|
193
|
+
};
|
|
194
|
+
for (let i = 0; i < nodes.length; i++)
|
|
195
|
+
walk(i, 0);
|
|
196
|
+
const components = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 };
|
|
197
|
+
const sizes = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
|
198
|
+
let decoded = 0;
|
|
199
|
+
const span = (viewIndex, offset, count, element, alignment, strided = false) => {
|
|
200
|
+
if (!index(viewIndex, views))
|
|
201
|
+
throw new Error("invalid accessor buffer view");
|
|
202
|
+
const v = views[viewIndex], stride = strided ? (v.byteStride ?? element) : element;
|
|
203
|
+
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");
|
|
205
|
+
return (v.byteOffset ?? 0) + offset;
|
|
206
|
+
};
|
|
207
|
+
for (const a of accessors) {
|
|
208
|
+
const width = sizes[a?.componentType], count = a?.count, componentsCount = components[a?.type];
|
|
209
|
+
if (!width || !componentsCount || !integer(count, 1, 2000000))
|
|
210
|
+
throw new Error("invalid accessor allocation");
|
|
211
|
+
const dimension = a.type === "MAT2" ? 2 : a.type === "MAT3" ? 3 : a.type === "MAT4" ? 4 : 0;
|
|
212
|
+
const element = dimension ? Math.ceil(dimension * width / 4) * 4 * dimension : width * componentsCount;
|
|
213
|
+
decoded += element * count;
|
|
214
|
+
if (decoded > 256 * 1024 * 1024)
|
|
215
|
+
throw new Error("decoded geometry exceeds 256 MiB");
|
|
216
|
+
const checkFloats = (at, n, stride) => {
|
|
217
|
+
if (a.componentType !== 5126)
|
|
218
|
+
return;
|
|
219
|
+
for (let k = 0; k < n; k++)
|
|
220
|
+
for (let c = 0; c < componentsCount; c++)
|
|
221
|
+
if (!Number.isFinite(binary.getFloat32(at + k * stride + c * 4, true)))
|
|
222
|
+
throw new Error("non-finite accessor value");
|
|
223
|
+
};
|
|
224
|
+
if (a.bufferView !== undefined) {
|
|
225
|
+
const at = span(a.bufferView, a.byteOffset ?? 0, count, element, width, true);
|
|
226
|
+
checkFloats(at, count, views[a.bufferView].byteStride ?? element);
|
|
227
|
+
}
|
|
228
|
+
if (a.sparse) {
|
|
229
|
+
const sparse = a.sparse, n = sparse.count, iw = sizes[sparse.indices?.componentType];
|
|
230
|
+
if (!integer(n, 1, count) || ![5121, 5123, 5125].includes(sparse.indices?.componentType))
|
|
231
|
+
throw new Error("invalid sparse accessor");
|
|
232
|
+
const at = span(sparse.indices.bufferView, sparse.indices.byteOffset ?? 0, n, iw, iw);
|
|
233
|
+
const valuesAt = span(sparse.values?.bufferView, sparse.values?.byteOffset ?? 0, n, element, width);
|
|
234
|
+
checkFloats(valuesAt, n, element);
|
|
235
|
+
let previous = -1;
|
|
236
|
+
for (let k = 0; k < n; k++) {
|
|
237
|
+
const offset = at + k * iw;
|
|
238
|
+
const next = iw === 1 ? binary.getUint8(offset) : iw === 2 ? binary.getUint16(offset, true) : binary.getUint32(offset, true);
|
|
239
|
+
if (next <= previous || next >= count)
|
|
240
|
+
throw new Error("invalid sparse index");
|
|
241
|
+
previous = next;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const animation of json.animations ?? []) {
|
|
246
|
+
if (!Array.isArray(animation.samplers) || !Array.isArray(animation.channels))
|
|
247
|
+
throw new Error("invalid animation");
|
|
248
|
+
for (const channel of animation.channels) {
|
|
249
|
+
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");
|
|
251
|
+
}
|
|
252
|
+
for (const sampler of animation.samplers) {
|
|
253
|
+
if (!index(sampler.input, accessors) || !index(sampler.output, accessors))
|
|
254
|
+
throw new Error("invalid animation accessor");
|
|
255
|
+
const a = accessors[sampler.input];
|
|
256
|
+
if (a.type !== "SCALAR" || a.componentType !== 5126 || a.sparse || a.bufferView === undefined)
|
|
257
|
+
throw new Error("invalid animation timeline");
|
|
258
|
+
const start = span(a.bufferView, a.byteOffset ?? 0, a.count, 4, 4);
|
|
259
|
+
let previous = -1;
|
|
260
|
+
for (let i = 0; i < a.count; i++) {
|
|
261
|
+
const time = binary.getFloat32(start + i * 4, true);
|
|
262
|
+
if (!Number.isFinite(time) || time <= previous || time < 0 || time > 600)
|
|
263
|
+
throw new Error("invalid animation times");
|
|
264
|
+
previous = time;
|
|
265
|
+
}
|
|
266
|
+
if (!Array.isArray(a.max) || Math.abs(a.max[0] - previous) > .001)
|
|
267
|
+
throw new Error("animation duration does not match timeline");
|
|
268
|
+
if (sampler.interpolation !== undefined && !["LINEAR", "STEP", "CUBICSPLINE"].includes(sampler.interpolation))
|
|
269
|
+
throw new Error("unsupported animation interpolation");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AnimationMixer, AnimationClip, type Object3D } from "three";
|
|
2
|
+
import type { AnimationPlaybackOptions, AnimationPlaybackResult } from "@starcried/starlite-core";
|
|
3
|
+
/** One semantic clip, with a short captured-pose transition on interruption. */
|
|
4
|
+
export declare class VrmAnimationPlayer {
|
|
5
|
+
#private;
|
|
6
|
+
readonly mixer: AnimationMixer;
|
|
7
|
+
static readonly transitionSeconds = 0.4;
|
|
8
|
+
constructor(root: Object3D, clips: ReadonlyMap<string, AnimationClip>, restore: () => void, returnTo?: string);
|
|
9
|
+
get activeName(): string | undefined;
|
|
10
|
+
get isOneShot(): boolean;
|
|
11
|
+
get hasLookAtTrack(): boolean;
|
|
12
|
+
get hasBlinkTrack(): boolean;
|
|
13
|
+
play(name: string, options?: AnimationPlaybackOptions): Promise<AnimationPlaybackResult>;
|
|
14
|
+
update(delta: number): void;
|
|
15
|
+
stop(result?: AnimationPlaybackResult): void;
|
|
16
|
+
destroy(): void;
|
|
17
|
+
}
|