@myned-ai/gsplat-flame-avatar-renderer 1.0.6 → 1.0.7
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/README.md +30 -0
- package/dist/gsplat-flame-avatar-renderer.cjs.js +38 -33
- package/dist/gsplat-flame-avatar-renderer.cjs.min.js +1 -1
- package/dist/gsplat-flame-avatar-renderer.cjs.min.js.map +1 -1
- package/dist/gsplat-flame-avatar-renderer.esm.js +38 -33
- package/dist/gsplat-flame-avatar-renderer.esm.min.js +1 -1
- package/dist/gsplat-flame-avatar-renderer.esm.min.js.map +1 -1
- package/package.json +5 -2
- package/src/api/index.js +0 -7
- package/src/buffers/SplatBuffer.js +0 -1394
- package/src/buffers/SplatBufferGenerator.js +0 -41
- package/src/buffers/SplatPartitioner.js +0 -110
- package/src/buffers/UncompressedSplatArray.js +0 -106
- package/src/buffers/index.js +0 -11
- package/src/core/SplatGeometry.js +0 -48
- package/src/core/SplatMesh.js +0 -2627
- package/src/core/SplatScene.js +0 -43
- package/src/core/SplatTree.js +0 -200
- package/src/core/Viewer.js +0 -2746
- package/src/core/index.js +0 -13
- package/src/enums/EngineConstants.js +0 -58
- package/src/enums/LogLevel.js +0 -13
- package/src/enums/RenderMode.js +0 -11
- package/src/enums/SceneFormat.js +0 -21
- package/src/enums/SceneRevealMode.js +0 -11
- package/src/enums/SplatRenderMode.js +0 -10
- package/src/enums/index.js +0 -13
- package/src/errors/ApplicationError.js +0 -185
- package/src/errors/index.js +0 -17
- package/src/flame/FlameAnimator.js +0 -496
- package/src/flame/FlameConstants.js +0 -21
- package/src/flame/FlameTextureManager.js +0 -293
- package/src/flame/index.js +0 -22
- package/src/flame/utils.js +0 -50
- package/src/index.js +0 -39
- package/src/loaders/DirectLoadError.js +0 -14
- package/src/loaders/INRIAV1PlyParser.js +0 -223
- package/src/loaders/PlyLoader.js +0 -519
- package/src/loaders/PlyParser.js +0 -19
- package/src/loaders/PlyParserUtils.js +0 -311
- package/src/loaders/index.js +0 -13
- package/src/materials/SplatMaterial.js +0 -1068
- package/src/materials/SplatMaterial2D.js +0 -358
- package/src/materials/SplatMaterial3D.js +0 -323
- package/src/materials/index.js +0 -11
- package/src/raycaster/Hit.js +0 -37
- package/src/raycaster/Ray.js +0 -123
- package/src/raycaster/Raycaster.js +0 -175
- package/src/raycaster/index.js +0 -10
- package/src/renderer/AnimationManager.js +0 -577
- package/src/renderer/AppConstants.js +0 -101
- package/src/renderer/GaussianSplatRenderer.js +0 -1146
- package/src/renderer/index.js +0 -24
- package/src/utils/BlobUrlManager.js +0 -294
- package/src/utils/EventEmitter.js +0 -349
- package/src/utils/LoaderUtils.js +0 -66
- package/src/utils/Logger.js +0 -171
- package/src/utils/ObjectPool.js +0 -248
- package/src/utils/RenderLoop.js +0 -306
- package/src/utils/Util.js +0 -416
- package/src/utils/ValidationUtils.js +0 -331
- package/src/utils/index.js +0 -18
- package/src/worker/SortWorker.js +0 -284
- package/src/worker/index.js +0 -8
|
@@ -1,1146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GaussianSplatRenderer
|
|
3
|
-
*
|
|
4
|
-
* Derived from gaussian-splat-renderer-for-lam
|
|
5
|
-
*
|
|
6
|
-
* High-level orchestration class that:
|
|
7
|
-
* - Loads ZIP assets via fetch
|
|
8
|
-
* - Unpacks with JSZip
|
|
9
|
-
* - Creates Viewer instance
|
|
10
|
-
* - Loads FLAME/skin models
|
|
11
|
-
* - Runs the render loop
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
/* global NProgress */
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
Vector3,
|
|
18
|
-
Bone,
|
|
19
|
-
Clock,
|
|
20
|
-
AnimationMixer
|
|
21
|
-
} from 'three';
|
|
22
|
-
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
23
|
-
import JSZip from 'jszip';
|
|
24
|
-
|
|
25
|
-
// Import internal modules
|
|
26
|
-
import { TYVoiceChatState } from './AppConstants.js';
|
|
27
|
-
import { AnimationManager } from './AnimationManager.js';
|
|
28
|
-
import { Viewer } from '../core/Viewer.js';
|
|
29
|
-
import { SceneFormat } from '../enums/SceneFormat.js';
|
|
30
|
-
|
|
31
|
-
// Import new utilities and error classes
|
|
32
|
-
import { getLogger } from '../utils/Logger.js';
|
|
33
|
-
import {
|
|
34
|
-
ValidationError,
|
|
35
|
-
NetworkError,
|
|
36
|
-
AssetLoadError,
|
|
37
|
-
InitializationError,
|
|
38
|
-
ResourceDisposedError
|
|
39
|
-
} from '../errors/index.js';
|
|
40
|
-
import {
|
|
41
|
-
validateUrl,
|
|
42
|
-
validateDOMElement,
|
|
43
|
-
validateHexColor,
|
|
44
|
-
validateCallback
|
|
45
|
-
} from '../utils/ValidationUtils.js';
|
|
46
|
-
import { BlobUrlManager } from '../utils/BlobUrlManager.js';
|
|
47
|
-
import { tempVector3A } from '../utils/ObjectPool.js';
|
|
48
|
-
|
|
49
|
-
// Create logger for this module
|
|
50
|
-
const logger = getLogger('GaussianSplatRenderer');
|
|
51
|
-
|
|
52
|
-
// Configuration objects - these would normally be loaded from the ZIP
|
|
53
|
-
const charactorConfig = {
|
|
54
|
-
camPos: { x: 0, y: 1.8, z: 1 },
|
|
55
|
-
camRot: { x: -10, y: 0, z: 0 },
|
|
56
|
-
backgroundColor: 'ffffff'
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const motionConfig = {
|
|
60
|
-
offset: {},
|
|
61
|
-
scale: {}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
// Animation configuration - defines how animation clips are distributed to states
|
|
65
|
-
// The animation.glb contains clips in order: hello(2), idle(1), listen(0), speak(6), think(3)
|
|
66
|
-
const animationConfig = {
|
|
67
|
-
hello: { size: 2, isGroup: false },
|
|
68
|
-
idle: { size: 1, isGroup: false },
|
|
69
|
-
listen: { size: 0, isGroup: false },
|
|
70
|
-
speak: { size: 6, isGroup: false },
|
|
71
|
-
think: { size: 3, isGroup: true },
|
|
72
|
-
other: []
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* GaussianSplatRenderer - Main rendering class
|
|
77
|
-
*/
|
|
78
|
-
export class GaussianSplatRenderer {
|
|
79
|
-
/**
|
|
80
|
-
* Factory method to create a new renderer instance
|
|
81
|
-
*
|
|
82
|
-
* @param {HTMLElement} container - DOM container for canvas
|
|
83
|
-
* @param {string} assetPath - URL to character ZIP file
|
|
84
|
-
* @param {object} [options={}] - Configuration options
|
|
85
|
-
* @param {Function} [options.downloadProgress] - Download progress callback (0-1)
|
|
86
|
-
* @param {Function} [options.loadProgress] - Load progress callback (0-1)
|
|
87
|
-
* @param {Function} [options.getChatState] - Chat state provider function
|
|
88
|
-
* @param {Function} [options.getExpressionData] - Expression data provider function
|
|
89
|
-
* @param {string} [options.backgroundColor] - Background color (hex string)
|
|
90
|
-
* @returns {Promise<GaussianSplatRenderer>} Renderer instance
|
|
91
|
-
* @throws {ValidationError} If parameters are invalid
|
|
92
|
-
* @throws {NetworkError} If asset download fails
|
|
93
|
-
* @throws {AssetLoadError} If asset loading/parsing fails
|
|
94
|
-
* @throws {InitializationError} If renderer initialization fails
|
|
95
|
-
*/
|
|
96
|
-
static async create(container, assetPath, options = {}) {
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
// Validate required parameters
|
|
100
|
-
validateDOMElement(container, 'container');
|
|
101
|
-
validateUrl(assetPath);
|
|
102
|
-
|
|
103
|
-
// Validate optional callbacks
|
|
104
|
-
if (options.downloadProgress) {
|
|
105
|
-
validateCallback(options.downloadProgress, 'options.downloadProgress', false);
|
|
106
|
-
}
|
|
107
|
-
if (options.loadProgress) {
|
|
108
|
-
validateCallback(options.loadProgress, 'options.loadProgress', false);
|
|
109
|
-
}
|
|
110
|
-
if (options.getChatState) {
|
|
111
|
-
validateCallback(options.getChatState, 'options.getChatState', false);
|
|
112
|
-
}
|
|
113
|
-
if (options.getExpressionData) {
|
|
114
|
-
validateCallback(options.getExpressionData, 'options.getExpressionData', false);
|
|
115
|
-
}
|
|
116
|
-
if (options.backgroundColor) {
|
|
117
|
-
validateHexColor(options.backgroundColor, 'options.backgroundColor');
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
logger.info('Initializing GaussianSplatRenderer', { assetPath });
|
|
121
|
-
|
|
122
|
-
const characterPath = assetPath;
|
|
123
|
-
|
|
124
|
-
// Parse character name from path
|
|
125
|
-
let characterName;
|
|
126
|
-
try {
|
|
127
|
-
const url = new URL(characterPath, typeof window !== 'undefined' ? window.location.href : undefined);
|
|
128
|
-
const pathname = url.pathname;
|
|
129
|
-
const matches = pathname.match(/\/([^/]+?)\.zip/);
|
|
130
|
-
characterName = matches?.[1];
|
|
131
|
-
|
|
132
|
-
if (!characterName) {
|
|
133
|
-
throw new ValidationError(
|
|
134
|
-
'Character model name could not be extracted from path. Expected format: /path/name.zip',
|
|
135
|
-
'assetPath'
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
} catch (error) {
|
|
139
|
-
if (error instanceof ValidationError) {
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
|
-
throw new ValidationError(
|
|
143
|
-
`Invalid asset path format: ${error.message}`,
|
|
144
|
-
'assetPath',
|
|
145
|
-
error
|
|
146
|
-
);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Show progress
|
|
150
|
-
if (typeof NProgress !== 'undefined') {
|
|
151
|
-
NProgress.start();
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Download ZIP file
|
|
155
|
-
logger.info('Downloading asset ZIP', { path: characterPath });
|
|
156
|
-
let characterZipResponse;
|
|
157
|
-
try {
|
|
158
|
-
characterZipResponse = await fetch(characterPath);
|
|
159
|
-
if (!characterZipResponse.ok) {
|
|
160
|
-
throw new NetworkError(
|
|
161
|
-
`Failed to download asset: ${characterZipResponse.statusText}`,
|
|
162
|
-
characterZipResponse.status
|
|
163
|
-
);
|
|
164
|
-
}
|
|
165
|
-
} catch (error) {
|
|
166
|
-
if (error instanceof NetworkError) {
|
|
167
|
-
throw error;
|
|
168
|
-
}
|
|
169
|
-
throw new NetworkError(
|
|
170
|
-
`Network error downloading asset: ${error.message}`,
|
|
171
|
-
0,
|
|
172
|
-
error
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Report download progress
|
|
177
|
-
if (options.downloadProgress) {
|
|
178
|
-
try {
|
|
179
|
-
options.downloadProgress(1.0);
|
|
180
|
-
} catch (error) {
|
|
181
|
-
logger.warn('Error in downloadProgress callback', error);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (options.loadProgress) {
|
|
186
|
-
try {
|
|
187
|
-
options.loadProgress(0.1);
|
|
188
|
-
} catch (error) {
|
|
189
|
-
logger.warn('Error in loadProgress callback', error);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (typeof NProgress !== 'undefined') {
|
|
194
|
-
NProgress.done();
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Parse array buffer
|
|
198
|
-
let arrayBuffer;
|
|
199
|
-
try {
|
|
200
|
-
arrayBuffer = await characterZipResponse.arrayBuffer();
|
|
201
|
-
} catch (error) {
|
|
202
|
-
throw new NetworkError(
|
|
203
|
-
`Failed to read response data: ${error.message}`,
|
|
204
|
-
0,
|
|
205
|
-
error
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Load ZIP with imported JSZip
|
|
210
|
-
logger.debug('Unpacking ZIP archive');
|
|
211
|
-
let zipData;
|
|
212
|
-
try {
|
|
213
|
-
zipData = await JSZip.loadAsync(arrayBuffer);
|
|
214
|
-
} catch (error) {
|
|
215
|
-
throw new AssetLoadError(
|
|
216
|
-
`Failed to unpack ZIP archive: ${error.message}`,
|
|
217
|
-
characterPath,
|
|
218
|
-
error
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Find folder name in ZIP
|
|
223
|
-
let fileName = '';
|
|
224
|
-
Object.values(zipData.files).forEach(file => {
|
|
225
|
-
if (file.dir) {
|
|
226
|
-
fileName = file.name?.slice(0, file.name?.length - 1); // Remove trailing '/'
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
if (!fileName) {
|
|
231
|
-
throw new AssetLoadError(
|
|
232
|
-
'No folder found in ZIP archive. Expected ZIP to contain a folder with model files.',
|
|
233
|
-
characterPath
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
logger.debug('Found model folder in ZIP', { fileName });
|
|
238
|
-
|
|
239
|
-
// Create renderer instance
|
|
240
|
-
logger.debug('Creating GaussianSplatRenderer instance');
|
|
241
|
-
const renderer = new GaussianSplatRenderer(container, zipData);
|
|
242
|
-
|
|
243
|
-
// Setup camera position (use object pool for temp vectors)
|
|
244
|
-
const cameraPos = tempVector3A.set(
|
|
245
|
-
charactorConfig.camPos?.x ?? 0,
|
|
246
|
-
charactorConfig.camPos?.y ?? 0,
|
|
247
|
-
charactorConfig.camPos?.z ?? 1
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
const cameraRotation = new Vector3(
|
|
251
|
-
charactorConfig.camRot?.x ?? 0,
|
|
252
|
-
charactorConfig.camRot?.y ?? 0,
|
|
253
|
-
charactorConfig.camRot?.z ?? 0
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
logger.debug('Camera setup', {
|
|
257
|
-
position: { x: cameraPos.x, y: cameraPos.y, z: cameraPos.z },
|
|
258
|
-
rotation: { x: cameraRotation.x, y: cameraRotation.y, z: cameraRotation.z }
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// Background color with validation
|
|
262
|
-
let backgroundColor = 0xffffff;
|
|
263
|
-
try {
|
|
264
|
-
if (charactorConfig.backgroundColor) {
|
|
265
|
-
const parsed = parseInt(charactorConfig.backgroundColor, 16);
|
|
266
|
-
if (!isNaN(parsed)) {
|
|
267
|
-
backgroundColor = parsed;
|
|
268
|
-
} else {
|
|
269
|
-
logger.warn('Invalid backgroundColor in config, using default', {
|
|
270
|
-
value: charactorConfig.backgroundColor
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
if (options?.backgroundColor) {
|
|
275
|
-
if (renderer.isHexColorStrict(options.backgroundColor)) {
|
|
276
|
-
backgroundColor = parseInt(options.backgroundColor, 16);
|
|
277
|
-
} else {
|
|
278
|
-
logger.warn('Invalid backgroundColor option, using config value', {
|
|
279
|
-
value: options.backgroundColor
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
} catch (error) {
|
|
284
|
-
logger.warn('Error parsing backgroundColor, using default', error);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
logger.debug('Background color set', { backgroundColor: backgroundColor.toString(16) });
|
|
288
|
-
|
|
289
|
-
// Store callbacks
|
|
290
|
-
renderer.getChatState = options?.getChatState;
|
|
291
|
-
renderer.getExpressionData = options?.getExpressionData;
|
|
292
|
-
|
|
293
|
-
// Create Viewer with proper error handling
|
|
294
|
-
logger.debug('Creating Viewer instance');
|
|
295
|
-
try {
|
|
296
|
-
renderer.viewer = new Viewer({
|
|
297
|
-
rootElement: container,
|
|
298
|
-
threejsCanvas: renderer._canvas,
|
|
299
|
-
cameraUp: [0, 1, 0],
|
|
300
|
-
initialCameraPosition: [cameraPos.x, cameraPos.y, cameraPos.z],
|
|
301
|
-
initialCameraRotation: [cameraRotation.x, cameraRotation.y, cameraRotation.z],
|
|
302
|
-
sphericalHarmonicsDegree: 0,
|
|
303
|
-
backgroundColor: backgroundColor
|
|
304
|
-
});
|
|
305
|
-
} catch (error) {
|
|
306
|
-
throw new InitializationError(
|
|
307
|
-
`Failed to create Viewer instance: ${error.message}`,
|
|
308
|
-
error
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Load model (non-FLAME mode only)
|
|
313
|
-
logger.info('Loading model', { fileName });
|
|
314
|
-
try {
|
|
315
|
-
await renderer.loadModel(fileName, animationConfig, motionConfig);
|
|
316
|
-
} catch (error) {
|
|
317
|
-
throw new AssetLoadError(
|
|
318
|
-
`Failed to load model: ${error.message}`,
|
|
319
|
-
fileName,
|
|
320
|
-
error
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Progress callback with error isolation
|
|
325
|
-
if (options.loadProgress) {
|
|
326
|
-
try {
|
|
327
|
-
options.loadProgress(0.2);
|
|
328
|
-
} catch (error) {
|
|
329
|
-
logger.warn('Error in loadProgress callback', error);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Load offset PLY
|
|
334
|
-
logger.debug('Loading offset PLY file');
|
|
335
|
-
let offsetFileUrl;
|
|
336
|
-
try {
|
|
337
|
-
offsetFileUrl = await renderer.unpackFileAsBlob(fileName + '/offset.ply');
|
|
338
|
-
} catch (error) {
|
|
339
|
-
throw new AssetLoadError(
|
|
340
|
-
`Failed to load offset.ply: ${error.message}`,
|
|
341
|
-
fileName + '/offset.ply',
|
|
342
|
-
error
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Load iris occlusion configuration (optional)
|
|
347
|
-
logger.debug('Checking for iris_occlusion.json');
|
|
348
|
-
let irisOcclusionConfig = null;
|
|
349
|
-
try {
|
|
350
|
-
irisOcclusionConfig = await renderer._loadJsonFromZip(fileName + '/iris_occlusion.json');
|
|
351
|
-
if (irisOcclusionConfig) {
|
|
352
|
-
logger.info('Iris occlusion configuration loaded', {
|
|
353
|
-
rightIrisRanges: irisOcclusionConfig.right_iris?.length ?? 0,
|
|
354
|
-
leftIrisRanges: irisOcclusionConfig.left_iris?.length ?? 0
|
|
355
|
-
});
|
|
356
|
-
renderer.irisOcclusionConfig = irisOcclusionConfig;
|
|
357
|
-
// Pass to viewer for material generation
|
|
358
|
-
renderer.viewer.irisOcclusionConfig = irisOcclusionConfig;
|
|
359
|
-
} else {
|
|
360
|
-
logger.debug('No iris_occlusion.json found, iris occlusion will be disabled');
|
|
361
|
-
}
|
|
362
|
-
} catch (error) {
|
|
363
|
-
// Log but don't fail - iris occlusion is optional
|
|
364
|
-
logger.warn('Failed to load iris_occlusion.json, continuing without it', { error: error.message });
|
|
365
|
-
renderer.irisOcclusionConfig = null;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Progress callback with error isolation
|
|
369
|
-
if (options.loadProgress) {
|
|
370
|
-
try {
|
|
371
|
-
options.loadProgress(0.3);
|
|
372
|
-
} catch (error) {
|
|
373
|
-
logger.warn('Error in loadProgress callback', error);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Add splat scene
|
|
378
|
-
logger.debug('Adding splat scene');
|
|
379
|
-
try {
|
|
380
|
-
await renderer.viewer.addSplatScene(offsetFileUrl, {
|
|
381
|
-
progressiveLoad: true,
|
|
382
|
-
sharedMemoryForWorkers: false,
|
|
383
|
-
showLoadingUI: false,
|
|
384
|
-
format: SceneFormat.Ply
|
|
385
|
-
});
|
|
386
|
-
} catch (error) {
|
|
387
|
-
throw new InitializationError(
|
|
388
|
-
`Failed to add splat scene: ${error.message}`,
|
|
389
|
-
error
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Initial render
|
|
394
|
-
try {
|
|
395
|
-
renderer.render();
|
|
396
|
-
} catch (error) {
|
|
397
|
-
logger.error('Error in initial render', error);
|
|
398
|
-
// Don't throw - render errors are non-fatal
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Progress callback with error isolation
|
|
402
|
-
if (options.loadProgress) {
|
|
403
|
-
try {
|
|
404
|
-
options.loadProgress(1);
|
|
405
|
-
} catch (error) {
|
|
406
|
-
logger.warn('Error in loadProgress callback', error);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
logger.info('GaussianSplatRenderer initialized successfully');
|
|
411
|
-
return renderer;
|
|
412
|
-
|
|
413
|
-
} catch (error) {
|
|
414
|
-
// Re-throw custom errors as-is
|
|
415
|
-
if (error instanceof ValidationError ||
|
|
416
|
-
error instanceof NetworkError ||
|
|
417
|
-
error instanceof AssetLoadError ||
|
|
418
|
-
error instanceof InitializationError) {
|
|
419
|
-
logger.error('Initialization failed', { errorCode: error.code, message: error.message });
|
|
420
|
-
throw error;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Wrap unexpected errors
|
|
424
|
-
logger.error('Unexpected error during initialization', error);
|
|
425
|
-
throw new InitializationError(
|
|
426
|
-
`Unexpected error initializing GaussianSplatRenderer: ${error.message}`,
|
|
427
|
-
error
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* @deprecated Use create() instead. This method is kept for backwards compatibility.
|
|
434
|
-
* @param {HTMLElement} container - DOM container for canvas
|
|
435
|
-
* @param {string} assetPath - URL to character ZIP file
|
|
436
|
-
* @param {object} [options={}] - Configuration options
|
|
437
|
-
* @returns {Promise<GaussianSplatRenderer>} Renderer instance
|
|
438
|
-
*/
|
|
439
|
-
static async getInstance(container, assetPath, options = {}) {
|
|
440
|
-
logger.warn('getInstance() is deprecated. Use create() instead. Each call creates a new instance.');
|
|
441
|
-
return this.create(container, assetPath, options);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Constructor - Creates a new GaussianSplatRenderer instance
|
|
446
|
-
*
|
|
447
|
-
* @param {HTMLElement} _container - DOM container element for the renderer
|
|
448
|
-
* @param {JSZip} zipData - Loaded ZIP archive containing model data
|
|
449
|
-
* @private - Use create() factory method instead
|
|
450
|
-
*/
|
|
451
|
-
constructor(_container, zipData) {
|
|
452
|
-
logger.debug('GaussianSplatRenderer constructor called');
|
|
453
|
-
|
|
454
|
-
// Disposal tracking
|
|
455
|
-
this._disposed = false;
|
|
456
|
-
|
|
457
|
-
// BlobUrlManager for tracking blob URLs
|
|
458
|
-
this._blobUrlManager = new BlobUrlManager();
|
|
459
|
-
|
|
460
|
-
// ZIP file cache
|
|
461
|
-
this.zipUrls = {
|
|
462
|
-
urls: new Map(),
|
|
463
|
-
zip: zipData
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
// State
|
|
467
|
-
this.lastTime = 0;
|
|
468
|
-
this.startTime = 0;
|
|
469
|
-
this.expressionData = {};
|
|
470
|
-
this.chatState = TYVoiceChatState.Idle;
|
|
471
|
-
|
|
472
|
-
// Create instance-specific canvas
|
|
473
|
-
this._canvas = null;
|
|
474
|
-
if (typeof document !== 'undefined' && _container) {
|
|
475
|
-
this._canvas = document.createElement('canvas');
|
|
476
|
-
const { width, height } = _container.getBoundingClientRect();
|
|
477
|
-
this._canvas.style.visibility = 'visible';
|
|
478
|
-
this._canvas.width = width;
|
|
479
|
-
this._canvas.height = height;
|
|
480
|
-
_container.appendChild(this._canvas);
|
|
481
|
-
logger.debug('Canvas setup', { width, height });
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// Animation timing
|
|
485
|
-
this.clock = new Clock();
|
|
486
|
-
this.startTime = performance.now() / 1000.0;
|
|
487
|
-
|
|
488
|
-
// These will be set during loading
|
|
489
|
-
this.viewer = null;
|
|
490
|
-
this.mixer = null;
|
|
491
|
-
this.animManager = null;
|
|
492
|
-
this.model = null;
|
|
493
|
-
this.irisOcclusionConfig = null;
|
|
494
|
-
this.motioncfg = null;
|
|
495
|
-
this.getChatState = null;
|
|
496
|
-
this.getExpressionData = null;
|
|
497
|
-
|
|
498
|
-
logger.debug('GaussianSplatRenderer instance created');
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/**
|
|
502
|
-
* Assert renderer is not disposed
|
|
503
|
-
* @private
|
|
504
|
-
* @throws {ResourceDisposedError} If renderer has been disposed
|
|
505
|
-
*/
|
|
506
|
-
_assertNotDisposed() {
|
|
507
|
-
if (this._disposed) {
|
|
508
|
-
throw new ResourceDisposedError('GaussianSplatRenderer has been disposed');
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Dispose renderer and free all resources
|
|
514
|
-
*
|
|
515
|
-
* Properly cleans up:
|
|
516
|
-
* - Model resources (mesh, animations, textures)
|
|
517
|
-
* - Blob URLs to prevent memory leaks
|
|
518
|
-
* - Viewer instance
|
|
519
|
-
* - Canvas visibility
|
|
520
|
-
*
|
|
521
|
-
* @returns {void}
|
|
522
|
-
*/
|
|
523
|
-
dispose() {
|
|
524
|
-
if (this._disposed) {
|
|
525
|
-
logger.warn('GaussianSplatRenderer.dispose() called on already disposed instance');
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
logger.info('Disposing GaussianSplatRenderer');
|
|
530
|
-
|
|
531
|
-
// Hide and remove canvas
|
|
532
|
-
if (this._canvas) {
|
|
533
|
-
this._canvas.style.visibility = 'hidden';
|
|
534
|
-
if (this._canvas.parentNode) {
|
|
535
|
-
this._canvas.parentNode.removeChild(this._canvas);
|
|
536
|
-
}
|
|
537
|
-
this._canvas = null;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// Dispose model resources
|
|
541
|
-
this.disposeModel();
|
|
542
|
-
|
|
543
|
-
// Revoke all blob URLs using BlobUrlManager
|
|
544
|
-
try {
|
|
545
|
-
this._blobUrlManager?.dispose();
|
|
546
|
-
} catch (error) {
|
|
547
|
-
logger.error('Error disposing BlobUrlManager', error);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Legacy blob URL cleanup (for URLs created before BlobUrlManager)
|
|
551
|
-
if (this.zipUrls?.urls) {
|
|
552
|
-
this.zipUrls.urls.forEach((value) => {
|
|
553
|
-
try {
|
|
554
|
-
URL.revokeObjectURL(value);
|
|
555
|
-
} catch (error) {
|
|
556
|
-
logger.warn('Error revoking blob URL', { url: value, error });
|
|
557
|
-
}
|
|
558
|
-
});
|
|
559
|
-
this.zipUrls.urls.clear();
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Nullify references to aid GC
|
|
563
|
-
this.viewer = null;
|
|
564
|
-
this.mixer = null;
|
|
565
|
-
this.animManager = null;
|
|
566
|
-
this.model = null;
|
|
567
|
-
this.motioncfg = null;
|
|
568
|
-
this.getChatState = null;
|
|
569
|
-
this.getExpressionData = null;
|
|
570
|
-
this.zipUrls = null;
|
|
571
|
-
|
|
572
|
-
// Mark as disposed
|
|
573
|
-
this._disposed = true;
|
|
574
|
-
|
|
575
|
-
// Clear singleton instance
|
|
576
|
-
GaussianSplatRenderer.instance = undefined;
|
|
577
|
-
|
|
578
|
-
logger.debug('GaussianSplatRenderer disposed successfully');
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Dispose model-specific resources
|
|
583
|
-
*
|
|
584
|
-
* Cleans up:
|
|
585
|
-
* - Animation mixer and cached actions
|
|
586
|
-
* - Animation manager
|
|
587
|
-
* - Viewer instance
|
|
588
|
-
*
|
|
589
|
-
* @returns {void}
|
|
590
|
-
*/
|
|
591
|
-
disposeModel() {
|
|
592
|
-
logger.debug('Disposing model resources');
|
|
593
|
-
|
|
594
|
-
// Dispose animation mixer
|
|
595
|
-
if (this.mixer) {
|
|
596
|
-
try {
|
|
597
|
-
this.mixer.stopAllAction();
|
|
598
|
-
if (this.viewer?.avatarMesh) {
|
|
599
|
-
this.mixer.uncacheRoot(this.viewer.avatarMesh);
|
|
600
|
-
}
|
|
601
|
-
} catch (error) {
|
|
602
|
-
logger.error('Error disposing animation mixer', error);
|
|
603
|
-
}
|
|
604
|
-
this.mixer = null;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Dispose animation manager
|
|
608
|
-
if (this.animManager) {
|
|
609
|
-
try {
|
|
610
|
-
this.animManager.dispose();
|
|
611
|
-
} catch (error) {
|
|
612
|
-
logger.error('Error disposing animation manager', error);
|
|
613
|
-
}
|
|
614
|
-
this.animManager = null;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Dispose viewer
|
|
618
|
-
if (this.viewer) {
|
|
619
|
-
try {
|
|
620
|
-
this.viewer.dispose();
|
|
621
|
-
} catch (error) {
|
|
622
|
-
logger.error('Error disposing viewer', error);
|
|
623
|
-
}
|
|
624
|
-
this.viewer = null;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
logger.debug('Model resources disposed');
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Get the Three.js camera
|
|
632
|
-
* @returns {THREE.Camera}
|
|
633
|
-
*/
|
|
634
|
-
getCamera() {
|
|
635
|
-
return this.viewer?.camera;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* Update blendshape weights from action data
|
|
640
|
-
* @param {object} actionData - Blendshape weights
|
|
641
|
-
* @returns {object} Processed influence values
|
|
642
|
-
*/
|
|
643
|
-
updateBS(actionData) {
|
|
644
|
-
// Default influence values - all 52 ARKit blendshapes
|
|
645
|
-
let influence = {
|
|
646
|
-
browDownLeft: 0.0,
|
|
647
|
-
browDownRight: 0.0,
|
|
648
|
-
browInnerUp: 0.0,
|
|
649
|
-
browOuterUpLeft: 0.0,
|
|
650
|
-
browOuterUpRight: 0.0,
|
|
651
|
-
mouthCheekPuff: 0.0,
|
|
652
|
-
cheekSquintLeft: 0.0,
|
|
653
|
-
cheekSquintRight: 0.0,
|
|
654
|
-
eyeBlinkLeft: 0.0,
|
|
655
|
-
eyeBlinkRight: 0.0,
|
|
656
|
-
eyeLookDownLeft: 0.0,
|
|
657
|
-
eyeLookDownRight: 0.0,
|
|
658
|
-
eyeLookInLeft: 0.0,
|
|
659
|
-
eyeLookInRight: 0.0,
|
|
660
|
-
eyeLookOutLeft: 0.0,
|
|
661
|
-
eyeLookOutRight: 0.0,
|
|
662
|
-
eyeLookUpLeft: 0.0,
|
|
663
|
-
eyeLookUpRight: 0.0,
|
|
664
|
-
eyeSquintLeft: 0.0,
|
|
665
|
-
eyeSquintRight: 0.0,
|
|
666
|
-
eyeWideLeft: 0.0,
|
|
667
|
-
eyeWideRight: 0.0,
|
|
668
|
-
jawForward: 0.0,
|
|
669
|
-
jawLeft: 0.0,
|
|
670
|
-
jawOpen: 0.0,
|
|
671
|
-
jawRight: 0.0,
|
|
672
|
-
mouthClose: 0.0,
|
|
673
|
-
mouthDimpleLeft: 0.0,
|
|
674
|
-
mouthDimpleRight: 0.0,
|
|
675
|
-
mouthFrownLeft: 0.0,
|
|
676
|
-
mouthFrownRight: 0.0,
|
|
677
|
-
mouthFunnel: 0.0,
|
|
678
|
-
mouthLeft: 0.0,
|
|
679
|
-
mouthLowerDownLeft: 0.0,
|
|
680
|
-
mouthLowerDownRight: 0.0,
|
|
681
|
-
mouthPressLeft: 0.0,
|
|
682
|
-
mouthPressRight: 0.0,
|
|
683
|
-
mouthPucker: 0.0,
|
|
684
|
-
mouthRight: 0.0,
|
|
685
|
-
mouthRollLower: 0.0,
|
|
686
|
-
mouthRollUpper: 0.0,
|
|
687
|
-
mouthShrugLower: 0.0,
|
|
688
|
-
mouthShrugUpper: 0.0,
|
|
689
|
-
mouthSmileLeft: 0.0,
|
|
690
|
-
mouthSmileRight: 0.0,
|
|
691
|
-
mouthStretchLeft: 0.0,
|
|
692
|
-
mouthStretchRight: 0.0,
|
|
693
|
-
mouthUpperUpLeft: 0.0,
|
|
694
|
-
mouthUpperUpRight: 0.0,
|
|
695
|
-
noseSneerLeft: 0.0,
|
|
696
|
-
noseSneerRight: 0.0,
|
|
697
|
-
tongueOut: 0.0
|
|
698
|
-
};
|
|
699
|
-
|
|
700
|
-
if (actionData != null) {
|
|
701
|
-
influence = actionData;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
return influence;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
/**
|
|
708
|
-
* Main render loop
|
|
709
|
-
*/
|
|
710
|
-
render() {
|
|
711
|
-
if (this.viewer && this.viewer.selfDrivenMode) {
|
|
712
|
-
this.viewer.requestFrameId = requestAnimationFrame(() => this.render());
|
|
713
|
-
|
|
714
|
-
const frameInfoInternal = 1.0 / 30.0;
|
|
715
|
-
const currentTime = performance.now() / 1000;
|
|
716
|
-
|
|
717
|
-
// Prevent division by zero if totalFrames is 0 or not set
|
|
718
|
-
const totalFrames = this.viewer.totalFrames || 1;
|
|
719
|
-
const calcDelta = (currentTime - this.startTime) % (totalFrames * frameInfoInternal);
|
|
720
|
-
const frameIndex = Math.floor(calcDelta / frameInfoInternal);
|
|
721
|
-
this.viewer.frame = frameIndex;
|
|
722
|
-
|
|
723
|
-
// Update chat state
|
|
724
|
-
if (this.getChatState) {
|
|
725
|
-
this.chatState = this.getChatState();
|
|
726
|
-
// DEBUG: Log state transitions
|
|
727
|
-
if (!this._lastLoggedState || this._lastLoggedState !== this.chatState) {
|
|
728
|
-
logger.debug('Chat state changed', {
|
|
729
|
-
newState: this.chatState,
|
|
730
|
-
hasAnimManager: !!this.animManager
|
|
731
|
-
});
|
|
732
|
-
this._lastLoggedState = this.chatState;
|
|
733
|
-
}
|
|
734
|
-
this.animManager?.update(this.chatState);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Update expression data
|
|
738
|
-
if (this.getExpressionData) {
|
|
739
|
-
this.expressionData = this.updateBS(this.getExpressionData());
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// Animation mixer update
|
|
743
|
-
if (!this.mixer || !this.animManager) {
|
|
744
|
-
if (!this._warnedOnce) {
|
|
745
|
-
logger.warn('Mixer or animManager not initialized, skipping animation update', {
|
|
746
|
-
hasMixer: !!this.mixer,
|
|
747
|
-
hasAnimManager: !!this.animManager
|
|
748
|
-
});
|
|
749
|
-
this._warnedOnce = true;
|
|
750
|
-
}
|
|
751
|
-
// Still update expressions even without mixer/animManager
|
|
752
|
-
this.setExpression();
|
|
753
|
-
} else {
|
|
754
|
-
const mixerUpdateDelta = this.clock.getDelta();
|
|
755
|
-
this.mixer.update(mixerUpdateDelta);
|
|
756
|
-
|
|
757
|
-
// Apply motion config offsets/scales
|
|
758
|
-
if (this.motioncfg) {
|
|
759
|
-
for (const morphTarget in this.expressionData) {
|
|
760
|
-
const offset = this.motioncfg.offset?.[morphTarget];
|
|
761
|
-
const scale = this.motioncfg.scale?.[morphTarget];
|
|
762
|
-
if (offset !== undefined && scale !== undefined) {
|
|
763
|
-
this.expressionData[morphTarget] =
|
|
764
|
-
this.expressionData[morphTarget] * scale + offset;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
this.setExpression();
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Update viewer
|
|
773
|
-
this.viewer.update(this.viewer.renderer, this.viewer.camera);
|
|
774
|
-
|
|
775
|
-
// Render if needed
|
|
776
|
-
const shouldRender = this.viewer.shouldRender();
|
|
777
|
-
if (this._renderLogCount <= 3) {
|
|
778
|
-
logger.debug('shouldRender check', { shouldRender });
|
|
779
|
-
}
|
|
780
|
-
if (shouldRender) {
|
|
781
|
-
this.viewer.render();
|
|
782
|
-
this.viewer.consecutiveRenderFrames++;
|
|
783
|
-
} else {
|
|
784
|
-
this.viewer.consecutiveRenderFrames = 0;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
this.viewer.renderNextFrame = false;
|
|
788
|
-
this.viewer.selfDrivenModeRunning = true;
|
|
789
|
-
} else {
|
|
790
|
-
throw new Error('Cannot start viewer unless it is in self driven mode.');
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* Validate hex color string
|
|
796
|
-
* @param {string} value - Color string to validate
|
|
797
|
-
* @returns {boolean}
|
|
798
|
-
*/
|
|
799
|
-
isHexColorStrict(value) {
|
|
800
|
-
if (typeof value !== 'string') return false;
|
|
801
|
-
const hexColorRegex = /^(#|0x)[0-9A-Fa-f]{6}$/i;
|
|
802
|
-
return hexColorRegex.test(value);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* Apply expression data to mesh
|
|
807
|
-
*/
|
|
808
|
-
setExpression() {
|
|
809
|
-
// Update splat mesh blendshapes
|
|
810
|
-
if (this.viewer?.splatMesh) {
|
|
811
|
-
this.viewer.splatMesh.bsWeight = this.expressionData;
|
|
812
|
-
|
|
813
|
-
// Update eye blink uniforms for smooth iris fade
|
|
814
|
-
const material = this.viewer.splatMesh.material;
|
|
815
|
-
if (material?.uniforms) {
|
|
816
|
-
const eyeBlinkLeft = this.expressionData.eyeBlinkLeft || 0.0;
|
|
817
|
-
const eyeBlinkRight = this.expressionData.eyeBlinkRight || 0.0;
|
|
818
|
-
if (material.uniforms.eyeBlinkLeft) {
|
|
819
|
-
material.uniforms.eyeBlinkLeft.value = eyeBlinkLeft;
|
|
820
|
-
}
|
|
821
|
-
if (material.uniforms.eyeBlinkRight) {
|
|
822
|
-
material.uniforms.eyeBlinkRight.value = eyeBlinkRight;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// Update morph targets on avatar model
|
|
828
|
-
if (this.model) {
|
|
829
|
-
this.model.traverse((object) => {
|
|
830
|
-
if (object.isMesh || object.isSkinnedMesh) {
|
|
831
|
-
const morphAttributes = object.geometry?.morphAttributes;
|
|
832
|
-
const hasMorphTargets = morphAttributes && Object.keys(morphAttributes).length > 0;
|
|
833
|
-
|
|
834
|
-
if (hasMorphTargets) {
|
|
835
|
-
const morphTargetDictionary = object.morphTargetDictionary;
|
|
836
|
-
for (const morphTarget in morphTargetDictionary) {
|
|
837
|
-
const target = morphTargetDictionary[morphTarget];
|
|
838
|
-
const data = this.expressionData[morphTarget];
|
|
839
|
-
if (data !== undefined) {
|
|
840
|
-
object.morphTargetInfluences[target] = Math.max(0.0, Math.min(1.0, data));
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
/**
|
|
850
|
-
* Load model with animation
|
|
851
|
-
* @param {string} pathName - Path within ZIP
|
|
852
|
-
* @param {object} animationConfig - Animation configuration
|
|
853
|
-
* @param {object} motionConfig - Motion configuration
|
|
854
|
-
*/
|
|
855
|
-
async loadModel(pathName, animationConfig, motionConfig) {
|
|
856
|
-
const [skinModel, aniclip, indexes] = await Promise.all([
|
|
857
|
-
this.unpackAndLoadGlb(pathName + '/skin.glb'),
|
|
858
|
-
this.unpackAndLoadGlb(pathName + '/animation.glb'),
|
|
859
|
-
this.unpackAndLoadJson(pathName + '/vertex_order.json')
|
|
860
|
-
]);
|
|
861
|
-
|
|
862
|
-
if (!this.viewer) {
|
|
863
|
-
throw new Error('render viewer is not initialized');
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
let skinModelSkinnedMesh;
|
|
867
|
-
let boneRoot;
|
|
868
|
-
|
|
869
|
-
skinModel.traverse((object) => {
|
|
870
|
-
if (object.isSkinnedMesh) {
|
|
871
|
-
skinModelSkinnedMesh = object;
|
|
872
|
-
}
|
|
873
|
-
if (object instanceof Bone && object.name === 'hip') {
|
|
874
|
-
boneRoot = object;
|
|
875
|
-
}
|
|
876
|
-
});
|
|
877
|
-
|
|
878
|
-
this.viewer.sortedIndexes = indexes;
|
|
879
|
-
|
|
880
|
-
if (skinModelSkinnedMesh) {
|
|
881
|
-
this.viewer.gaussianSplatCount = skinModelSkinnedMesh.geometry.attributes.position.count;
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
this.viewer.avatarMesh = skinModel;
|
|
885
|
-
this.viewer.skinModel = skinModelSkinnedMesh;
|
|
886
|
-
this.viewer.boneRoot = boneRoot;
|
|
887
|
-
|
|
888
|
-
// Setup animation
|
|
889
|
-
this.mixer = new AnimationMixer(skinModel);
|
|
890
|
-
this.animManager = new AnimationManager(this.mixer, aniclip, animationConfig);
|
|
891
|
-
this.motioncfg = motionConfig;
|
|
892
|
-
|
|
893
|
-
// Set totalFrames from animation clips or default to 1
|
|
894
|
-
if (Array.isArray(aniclip) && aniclip.length > 0 && aniclip[0].duration) {
|
|
895
|
-
this.viewer.totalFrames = Math.floor(aniclip[0].duration * 30); // 30 fps
|
|
896
|
-
} else {
|
|
897
|
-
this.viewer.totalFrames = 1;
|
|
898
|
-
}
|
|
899
|
-
logger.debug('Total frames calculated', { totalFrames: this.viewer.totalFrames });
|
|
900
|
-
|
|
901
|
-
if (skinModelSkinnedMesh) {
|
|
902
|
-
this.viewer.updateMorphTarget(skinModelSkinnedMesh);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
this.viewer.threeScene.add(skinModel);
|
|
906
|
-
skinModel.visible = false;
|
|
907
|
-
|
|
908
|
-
if (skinModelSkinnedMesh) {
|
|
909
|
-
skinModelSkinnedMesh.skeleton.computeBoneTexture();
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/**
|
|
914
|
-
* Unpack file from ZIP and create a blob URL
|
|
915
|
-
*
|
|
916
|
-
* Uses BlobUrlManager to track blob URLs for automatic cleanup.
|
|
917
|
-
* Caches URLs for repeated access to the same file.
|
|
918
|
-
*
|
|
919
|
-
* @param {string} path - Path to file within ZIP archive
|
|
920
|
-
* @returns {Promise<string>} Blob URL to the file
|
|
921
|
-
* @throws {AssetLoadError} If file cannot be unpacked
|
|
922
|
-
*/
|
|
923
|
-
async unpackFileAsBlob(path) {
|
|
924
|
-
this._assertNotDisposed();
|
|
925
|
-
|
|
926
|
-
// Return cached URL if available
|
|
927
|
-
if (this.zipUrls.urls.has(path)) {
|
|
928
|
-
logger.debug('Returning cached blob URL', { path });
|
|
929
|
-
return this.zipUrls.urls.get(path);
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
logger.debug('Unpacking file from ZIP', { path });
|
|
933
|
-
|
|
934
|
-
// Extract file from ZIP
|
|
935
|
-
const fileEntry = this.zipUrls.zip?.file(path);
|
|
936
|
-
if (!fileEntry) {
|
|
937
|
-
throw new AssetLoadError(
|
|
938
|
-
`File not found in ZIP archive: ${path}`,
|
|
939
|
-
path
|
|
940
|
-
);
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
let modelFile;
|
|
944
|
-
try {
|
|
945
|
-
modelFile = await fileEntry.async('blob');
|
|
946
|
-
} catch (error) {
|
|
947
|
-
throw new AssetLoadError(
|
|
948
|
-
`Failed to extract file from ZIP: ${error.message}`,
|
|
949
|
-
path,
|
|
950
|
-
error
|
|
951
|
-
);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
if (!modelFile) {
|
|
955
|
-
throw new AssetLoadError(
|
|
956
|
-
`File extracted but blob is empty: ${path}`,
|
|
957
|
-
path
|
|
958
|
-
);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// Create blob URL using BlobUrlManager for tracking
|
|
962
|
-
const mimeType = this._getMimeType(path);
|
|
963
|
-
const modelUrl = this._blobUrlManager.createBlobUrl(
|
|
964
|
-
modelFile,
|
|
965
|
-
mimeType,
|
|
966
|
-
`zip:${path}`
|
|
967
|
-
);
|
|
968
|
-
|
|
969
|
-
// Cache for future access
|
|
970
|
-
this.zipUrls.urls.set(path, modelUrl);
|
|
971
|
-
logger.debug('Blob URL created and cached', { path, url: modelUrl.substring(0, 50) });
|
|
972
|
-
|
|
973
|
-
return modelUrl;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* Load JSON file from ZIP archive
|
|
978
|
-
*
|
|
979
|
-
* @param {string} path - Path to JSON file within ZIP archive
|
|
980
|
-
* @returns {Promise<Object|null>} Parsed JSON object, or null if file doesn't exist
|
|
981
|
-
* @throws {ParseError} If JSON parsing fails
|
|
982
|
-
* @private
|
|
983
|
-
*/
|
|
984
|
-
async _loadJsonFromZip(path) {
|
|
985
|
-
this._assertNotDisposed();
|
|
986
|
-
|
|
987
|
-
logger.debug('Attempting to load JSON from ZIP', { path });
|
|
988
|
-
|
|
989
|
-
// Check if file exists in ZIP
|
|
990
|
-
const fileEntry = this.zipUrls.zip?.file(path);
|
|
991
|
-
if (!fileEntry) {
|
|
992
|
-
logger.debug('JSON file not found in ZIP, returning null', { path });
|
|
993
|
-
return null;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// Extract file as text
|
|
997
|
-
let jsonText;
|
|
998
|
-
try {
|
|
999
|
-
jsonText = await fileEntry.async('text');
|
|
1000
|
-
} catch (error) {
|
|
1001
|
-
throw new ParseError(
|
|
1002
|
-
`Failed to extract JSON file from ZIP: ${error.message}`,
|
|
1003
|
-
path,
|
|
1004
|
-
error
|
|
1005
|
-
);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
// Parse JSON
|
|
1009
|
-
try {
|
|
1010
|
-
const jsonData = JSON.parse(jsonText);
|
|
1011
|
-
logger.debug('JSON file loaded successfully', { path });
|
|
1012
|
-
return jsonData;
|
|
1013
|
-
} catch (error) {
|
|
1014
|
-
throw new ParseError(
|
|
1015
|
-
`Failed to parse JSON file: ${error.message}`,
|
|
1016
|
-
path,
|
|
1017
|
-
error
|
|
1018
|
-
);
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
/**
|
|
1023
|
-
* Unpack GLB file from ZIP and load it
|
|
1024
|
-
*
|
|
1025
|
-
* @param {string} path - Path to GLB file within ZIP archive
|
|
1026
|
-
* @returns {Promise<THREE.Group|THREE.AnimationClip[]>} Loaded GLTF model
|
|
1027
|
-
* @throws {AssetLoadError} If file cannot be unpacked or loaded
|
|
1028
|
-
*/
|
|
1029
|
-
async unpackAndLoadGlb(path) {
|
|
1030
|
-
this._assertNotDisposed();
|
|
1031
|
-
|
|
1032
|
-
// Return cached URL if available
|
|
1033
|
-
if (this.zipUrls.urls.has(path)) {
|
|
1034
|
-
logger.debug('Using cached GLB URL', { path });
|
|
1035
|
-
return this.LoadGLTF(this.zipUrls.urls.get(path));
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
logger.debug('Unpacking GLB from ZIP', { path });
|
|
1039
|
-
|
|
1040
|
-
// Extract file from ZIP as ArrayBuffer
|
|
1041
|
-
const fileEntry = this.zipUrls.zip?.file(path);
|
|
1042
|
-
if (!fileEntry) {
|
|
1043
|
-
throw new AssetLoadError(
|
|
1044
|
-
`GLB file not found in ZIP archive: ${path}`,
|
|
1045
|
-
path
|
|
1046
|
-
);
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
let modelFile;
|
|
1050
|
-
try {
|
|
1051
|
-
modelFile = await fileEntry.async('arraybuffer');
|
|
1052
|
-
} catch (error) {
|
|
1053
|
-
throw new AssetLoadError(
|
|
1054
|
-
`Failed to extract GLB from ZIP: ${error.message}`,
|
|
1055
|
-
path,
|
|
1056
|
-
error
|
|
1057
|
-
);
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
if (!modelFile) {
|
|
1061
|
-
throw new AssetLoadError(
|
|
1062
|
-
`GLB extracted but ArrayBuffer is empty: ${path}`,
|
|
1063
|
-
path
|
|
1064
|
-
);
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// Create blob URL using BlobUrlManager
|
|
1068
|
-
const blob = new Blob([modelFile], { type: 'model/gltf-binary' });
|
|
1069
|
-
const modelUrl = this._blobUrlManager.createBlobUrl(
|
|
1070
|
-
blob,
|
|
1071
|
-
'model/gltf-binary',
|
|
1072
|
-
`zip:${path}`
|
|
1073
|
-
);
|
|
1074
|
-
|
|
1075
|
-
// Cache for future access
|
|
1076
|
-
this.zipUrls.urls.set(path, modelUrl);
|
|
1077
|
-
logger.debug('GLB blob URL created and cached', { path });
|
|
1078
|
-
|
|
1079
|
-
return this.LoadGLTF(modelUrl);
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
/**
|
|
1083
|
-
* Get MIME type from file extension
|
|
1084
|
-
* @private
|
|
1085
|
-
* @param {string} path - File path
|
|
1086
|
-
* @returns {string} MIME type
|
|
1087
|
-
*/
|
|
1088
|
-
_getMimeType(path) {
|
|
1089
|
-
const extension = path.split('.').pop()?.toLowerCase();
|
|
1090
|
-
const mimeTypes = {
|
|
1091
|
-
'ply': 'model/ply',
|
|
1092
|
-
'glb': 'model/gltf-binary',
|
|
1093
|
-
'gltf': 'model/gltf+json',
|
|
1094
|
-
'json': 'application/json',
|
|
1095
|
-
'bin': 'application/octet-stream',
|
|
1096
|
-
'png': 'image/png',
|
|
1097
|
-
'jpg': 'image/jpeg',
|
|
1098
|
-
'jpeg': 'image/jpeg'
|
|
1099
|
-
};
|
|
1100
|
-
return mimeTypes[extension] || 'application/octet-stream';
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* Unpack and parse JSON file
|
|
1105
|
-
* @param {string} path - Path within ZIP
|
|
1106
|
-
* @returns {Promise<object>}
|
|
1107
|
-
*/
|
|
1108
|
-
async unpackAndLoadJson(path) {
|
|
1109
|
-
const file = this.zipUrls.zip?.file(path);
|
|
1110
|
-
if (!file) {
|
|
1111
|
-
throw new Error(`File not found in ZIP: ${path}`);
|
|
1112
|
-
}
|
|
1113
|
-
const jsonFile = await file.async('string');
|
|
1114
|
-
if (!jsonFile) {
|
|
1115
|
-
throw new Error(`Failed to read file from ZIP: ${path}`);
|
|
1116
|
-
}
|
|
1117
|
-
return JSON.parse(jsonFile);
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
/**
|
|
1121
|
-
* Load GLTF file
|
|
1122
|
-
* @param {string} url - URL to GLTF/GLB file
|
|
1123
|
-
* @returns {Promise<THREE.Group|THREE.AnimationClip[]>}
|
|
1124
|
-
*/
|
|
1125
|
-
async LoadGLTF(url) {
|
|
1126
|
-
return new Promise((resolve, reject) => {
|
|
1127
|
-
const loader = new GLTFLoader();
|
|
1128
|
-
loader.load(
|
|
1129
|
-
url,
|
|
1130
|
-
(gltf) => {
|
|
1131
|
-
if (gltf.animations.length > 0) {
|
|
1132
|
-
resolve(gltf.animations);
|
|
1133
|
-
} else {
|
|
1134
|
-
resolve(gltf.scene);
|
|
1135
|
-
}
|
|
1136
|
-
},
|
|
1137
|
-
undefined,
|
|
1138
|
-
(error) => {
|
|
1139
|
-
reject(error);
|
|
1140
|
-
}
|
|
1141
|
-
);
|
|
1142
|
-
});
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
export default GaussianSplatRenderer;
|