@myned-ai/gsplat-flame-avatar-renderer 1.0.4 → 1.0.6

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.
@@ -28,12 +28,32 @@ import { AnimationManager } from './AnimationManager.js';
28
28
  import { Viewer } from '../core/Viewer.js';
29
29
  import { SceneFormat } from '../enums/SceneFormat.js';
30
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
+
31
52
  // Configuration objects - these would normally be loaded from the ZIP
32
53
  const charactorConfig = {
33
54
  camPos: { x: 0, y: 1.8, z: 1 },
34
55
  camRot: { x: -10, y: 0, z: 0 },
35
- backgroundColor: 'ffffff',
36
- useFlame: 'false' // Match compact file default - use non-FLAME mode
56
+ backgroundColor: 'ffffff'
37
57
  };
38
58
 
39
59
  const motionConfig = {
@@ -56,35 +76,74 @@ const animationConfig = {
56
76
  * GaussianSplatRenderer - Main rendering class
57
77
  */
58
78
  export class GaussianSplatRenderer {
59
- // Static canvas element shared across instances
60
- static _canvas = (typeof document !== 'undefined') ? document.createElement('canvas') : null;
61
-
62
- // Singleton instance
63
- static instance = undefined;
64
-
65
79
  /**
66
- * Factory method to create/get renderer instance
80
+ * Factory method to create a new renderer instance
81
+ *
67
82
  * @param {HTMLElement} container - DOM container for canvas
68
83
  * @param {string} assetPath - URL to character ZIP file
69
- * @param {object} options - Configuration options
70
- * @returns {Promise<GaussianSplatRenderer>}
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
71
95
  */
72
- static async getInstance(container, assetPath, options = {}) {
73
- if (this.instance !== undefined) {
74
- return this.instance;
75
- }
96
+ static async create(container, assetPath, options = {}) {
76
97
 
77
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
+
78
122
  const characterPath = assetPath;
79
-
123
+
80
124
  // Parse character name from path
81
- const url = new URL(characterPath, window.location.href);
82
- const pathname = url.pathname;
83
- const matches = pathname.match(/\/([^/]+?)\.zip/);
84
- const characterName = matches && matches[1];
85
-
86
- if (!characterName) {
87
- throw new Error('character model is not found');
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
+ );
88
147
  }
89
148
 
90
149
  // Show progress
@@ -93,28 +152,72 @@ export class GaussianSplatRenderer {
93
152
  }
94
153
 
95
154
  // Download ZIP file
96
- const characterZipResponse = await fetch(characterPath);
97
- if (!characterZipResponse.ok) {
98
- throw new Error(`Failed to download: ${characterZipResponse.statusText}`);
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
+ );
99
174
  }
100
175
 
101
176
  // Report download progress
102
177
  if (options.downloadProgress) {
103
- options.downloadProgress(1.0);
178
+ try {
179
+ options.downloadProgress(1.0);
180
+ } catch (error) {
181
+ logger.warn('Error in downloadProgress callback', error);
182
+ }
104
183
  }
105
184
 
106
185
  if (options.loadProgress) {
107
- options.loadProgress(0.1);
186
+ try {
187
+ options.loadProgress(0.1);
188
+ } catch (error) {
189
+ logger.warn('Error in loadProgress callback', error);
190
+ }
108
191
  }
109
192
 
110
193
  if (typeof NProgress !== 'undefined') {
111
194
  NProgress.done();
112
195
  }
113
196
 
114
- const arrayBuffer = await characterZipResponse.arrayBuffer();
115
-
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
+
116
209
  // Load ZIP with imported JSZip
117
- const zipData = await JSZip.loadAsync(arrayBuffer);
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
+ }
118
221
 
119
222
  // Find folder name in ZIP
120
223
  let fileName = '';
@@ -125,100 +228,235 @@ export class GaussianSplatRenderer {
125
228
  });
126
229
 
127
230
  if (!fileName) {
128
- throw new Error('file folder is not found');
231
+ throw new AssetLoadError(
232
+ 'No folder found in ZIP archive. Expected ZIP to contain a folder with model files.',
233
+ characterPath
234
+ );
129
235
  }
130
236
 
237
+ logger.debug('Found model folder in ZIP', { fileName });
238
+
131
239
  // Create renderer instance
240
+ logger.debug('Creating GaussianSplatRenderer instance');
132
241
  const renderer = new GaussianSplatRenderer(container, zipData);
133
242
 
134
- // Setup camera position
135
- const cameraPos = new Vector3();
136
- cameraPos.x = charactorConfig.camPos?.x || 0;
137
- cameraPos.y = charactorConfig.camPos?.y || 0;
138
- cameraPos.z = charactorConfig.camPos?.z || 1;
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
+ );
139
249
 
140
- const cameraRotation = new Vector3();
141
- cameraRotation.x = charactorConfig.camRot?.x || 0;
142
- cameraRotation.y = charactorConfig.camRot?.y || 0;
143
- cameraRotation.z = charactorConfig.camRot?.z || 0;
250
+ const cameraRotation = new Vector3(
251
+ charactorConfig.camRot?.x ?? 0,
252
+ charactorConfig.camRot?.y ?? 0,
253
+ charactorConfig.camRot?.z ?? 0
254
+ );
144
255
 
145
- // Background color
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
146
262
  let backgroundColor = 0xffffff;
147
- if (charactorConfig.backgroundColor) {
148
- backgroundColor = parseInt(charactorConfig.backgroundColor, 16);
149
- }
150
- if (options?.backgroundColor && renderer.isHexColorStrict(options.backgroundColor)) {
151
- backgroundColor = parseInt(options.backgroundColor, 16);
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);
152
285
  }
153
286
 
287
+ logger.debug('Background color set', { backgroundColor: backgroundColor.toString(16) });
288
+
154
289
  // Store callbacks
155
290
  renderer.getChatState = options?.getChatState;
156
291
  renderer.getExpressionData = options?.getExpressionData;
157
292
 
158
- // FLAME mode flag
159
- if (charactorConfig.useFlame) {
160
- renderer.useFlame = (charactorConfig.useFlame === 'false') ? false : true;
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
+ );
161
310
  }
162
311
 
163
- // Create Viewer with imported class
164
- renderer.viewer = new Viewer({
165
- rootElement: container,
166
- threejsCanvas: GaussianSplatRenderer._canvas,
167
- cameraUp: [0, 1, 0],
168
- initialCameraPosition: [cameraPos.x, cameraPos.y, cameraPos.z],
169
- initialCameraRotation: [cameraRotation.x, cameraRotation.y, cameraRotation.z],
170
- sphericalHarmonicsDegree: 0,
171
- backgroundColor: backgroundColor
172
- });
173
-
174
- renderer.viewer.useFlame = renderer.useFlame;
175
-
176
- // Load model based on mode
177
- if (renderer.viewer.useFlame === true) {
178
- await renderer.loadFlameModel(fileName, motionConfig);
179
- } else {
312
+ // Load model (non-FLAME mode only)
313
+ logger.info('Loading model', { fileName });
314
+ try {
180
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
+ );
181
322
  }
182
323
 
324
+ // Progress callback with error isolation
183
325
  if (options.loadProgress) {
184
- options.loadProgress(0.2);
326
+ try {
327
+ options.loadProgress(0.2);
328
+ } catch (error) {
329
+ logger.warn('Error in loadProgress callback', error);
330
+ }
185
331
  }
186
332
 
187
333
  // Load offset PLY
188
- const offsetFileUrl = await renderer.unpackFileAsBlob(fileName + '/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
+ }
189
367
 
368
+ // Progress callback with error isolation
190
369
  if (options.loadProgress) {
191
- options.loadProgress(0.3);
370
+ try {
371
+ options.loadProgress(0.3);
372
+ } catch (error) {
373
+ logger.warn('Error in loadProgress callback', error);
374
+ }
192
375
  }
193
376
 
194
377
  // Add splat scene
195
- await renderer.viewer.addSplatScene(offsetFileUrl, {
196
- progressiveLoad: true,
197
- sharedMemoryForWorkers: false,
198
- showLoadingUI: false,
199
- format: SceneFormat.Ply
200
- });
201
- renderer.render();
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
+ }
202
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
203
402
  if (options.loadProgress) {
204
- options.loadProgress(1);
403
+ try {
404
+ options.loadProgress(1);
405
+ } catch (error) {
406
+ logger.warn('Error in loadProgress callback', error);
407
+ }
205
408
  }
206
409
 
207
- this.instance = renderer;
410
+ logger.info('GaussianSplatRenderer initialized successfully');
208
411
  return renderer;
209
412
 
210
413
  } catch (error) {
211
- console.error('GaussianSplatRenderer.getInstance error:', error);
212
- throw 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
+ );
213
429
  }
214
430
  }
215
431
 
216
432
  /**
217
- * Constructor
218
- * @param {HTMLElement} _container - DOM container
219
- * @param {JSZip} zipData - Loaded ZIP data
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
220
450
  */
221
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
+
222
460
  // ZIP file cache
223
461
  this.zipUrls = {
224
462
  urls: new Map(),
@@ -226,19 +464,21 @@ export class GaussianSplatRenderer {
226
464
  };
227
465
 
228
466
  // State
229
- this.useFlame = false;
230
467
  this.lastTime = 0;
231
468
  this.startTime = 0;
232
469
  this.expressionData = {};
233
470
  this.chatState = TYVoiceChatState.Idle;
234
471
 
235
- // Setup canvas
236
- if (GaussianSplatRenderer._canvas && _container) {
472
+ // Create instance-specific canvas
473
+ this._canvas = null;
474
+ if (typeof document !== 'undefined' && _container) {
475
+ this._canvas = document.createElement('canvas');
237
476
  const { width, height } = _container.getBoundingClientRect();
238
- GaussianSplatRenderer._canvas.style.visibility = 'visible';
239
- GaussianSplatRenderer._canvas.width = width;
240
- GaussianSplatRenderer._canvas.height = height;
241
- _container.appendChild(GaussianSplatRenderer._canvas);
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 });
242
482
  }
243
483
 
244
484
  // Animation timing
@@ -250,42 +490,141 @@ export class GaussianSplatRenderer {
250
490
  this.mixer = null;
251
491
  this.animManager = null;
252
492
  this.model = null;
493
+ this.irisOcclusionConfig = null;
253
494
  this.motioncfg = null;
254
495
  this.getChatState = null;
255
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
+ }
256
510
  }
257
511
 
258
512
  /**
259
- * Dispose renderer and free resources
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}
260
522
  */
261
523
  dispose() {
262
- if (GaussianSplatRenderer._canvas) {
263
- GaussianSplatRenderer._canvas.style.visibility = 'hidden';
524
+ if (this._disposed) {
525
+ logger.warn('GaussianSplatRenderer.dispose() called on already disposed instance');
526
+ return;
264
527
  }
265
-
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
266
541
  this.disposeModel();
267
-
268
- // Revoke all blob URLs
269
- this.zipUrls.urls.forEach((value) => {
270
- URL.revokeObjectURL(value);
271
- });
272
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
273
576
  GaussianSplatRenderer.instance = undefined;
577
+
578
+ logger.debug('GaussianSplatRenderer disposed successfully');
274
579
  }
275
580
 
276
581
  /**
277
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}
278
590
  */
279
591
  disposeModel() {
592
+ logger.debug('Disposing model resources');
593
+
594
+ // Dispose animation mixer
280
595
  if (this.mixer) {
281
- this.mixer.stopAllAction();
282
- if (this.viewer?.avatarMesh) {
283
- this.mixer.uncacheRoot(this.viewer.avatarMesh);
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);
284
623
  }
285
- this.mixer = undefined;
286
- this.animManager?.dispose();
624
+ this.viewer = null;
287
625
  }
288
- this.viewer?.dispose();
626
+
627
+ logger.debug('Model resources disposed');
289
628
  }
290
629
 
291
630
  /**
@@ -386,7 +725,10 @@ export class GaussianSplatRenderer {
386
725
  this.chatState = this.getChatState();
387
726
  // DEBUG: Log state transitions
388
727
  if (!this._lastLoggedState || this._lastLoggedState !== this.chatState) {
389
- console.log('[ANIM] Chat state changed to:', this.chatState, 'animManager exists:', !!this.animManager);
728
+ logger.debug('Chat state changed', {
729
+ newState: this.chatState,
730
+ hasAnimManager: !!this.animManager
731
+ });
390
732
  this._lastLoggedState = this.chatState;
391
733
  }
392
734
  this.animManager?.update(this.chatState);
@@ -397,32 +739,34 @@ export class GaussianSplatRenderer {
397
739
  this.expressionData = this.updateBS(this.getExpressionData());
398
740
  }
399
741
 
400
- // Non-FLAME mode: animation mixer
401
- if (this.viewer.useFlame === false) {
402
- if (!this.mixer || !this.animManager) {
403
- if (!this._warnedOnce) {
404
- console.warn('render: mixer or animManager not initialized, skipping animation update');
405
- console.log('[ANIM] useFlame:', this.viewer.useFlame, 'mixer:', !!this.mixer, 'animManager:', !!this.animManager);
406
- this._warnedOnce = true;
407
- }
408
- } else {
409
- const mixerUpdateDelta = this.clock.getDelta();
410
- this.mixer.update(mixerUpdateDelta);
411
-
412
- // Apply motion config offsets/scales
413
- if (this.motioncfg) {
414
- for (const morphTarget in this.expressionData) {
415
- const offset = this.motioncfg.offset?.[morphTarget];
416
- const scale = this.motioncfg.scale?.[morphTarget];
417
- if (offset !== undefined && scale !== undefined) {
418
- this.expressionData[morphTarget] =
419
- this.expressionData[morphTarget] * scale + offset;
420
- }
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;
421
765
  }
422
766
  }
423
-
424
- this.setExpression();
425
767
  }
768
+
769
+ this.setExpression();
426
770
  }
427
771
 
428
772
  // Update viewer
@@ -431,7 +775,7 @@ export class GaussianSplatRenderer {
431
775
  // Render if needed
432
776
  const shouldRender = this.viewer.shouldRender();
433
777
  if (this._renderLogCount <= 3) {
434
- console.log('[GS-DEBUG] shouldRender:', shouldRender);
778
+ logger.debug('shouldRender check', { shouldRender });
435
779
  }
436
780
  if (shouldRender) {
437
781
  this.viewer.render();
@@ -465,6 +809,19 @@ export class GaussianSplatRenderer {
465
809
  // Update splat mesh blendshapes
466
810
  if (this.viewer?.splatMesh) {
467
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
+ }
468
825
  }
469
826
 
470
827
  // Update morph targets on avatar model
@@ -490,70 +847,7 @@ export class GaussianSplatRenderer {
490
847
  }
491
848
 
492
849
  /**
493
- * Load FLAME model from ZIP
494
- * @param {string} pathName - Path within ZIP
495
- * @param {object} motionConfig - Motion configuration
496
- */
497
- async loadFlameModel(pathName, motionConfig) {
498
- // Load all required files in parallel
499
- const [skinModel, lbs_weight_80k, flame_params, indexes, bone_tree] = await Promise.all([
500
- this.unpackAndLoadGlb(pathName + '/skin.glb'),
501
- this.unpackAndLoadJson(pathName + '/lbs_weight_20k.json'),
502
- this.unpackAndLoadJson(pathName + '/flame_params.json'),
503
- this.unpackAndLoadJson(pathName + '/vertex_order.json'),
504
- this.unpackAndLoadJson(pathName + '/bone_tree.json')
505
- ]);
506
-
507
- if (!this.viewer) {
508
- throw new Error('render viewer is not initialized');
509
- }
510
-
511
- // Find skinned mesh and bone root
512
- let skinModelSkinnedMesh;
513
- let boneRoot;
514
-
515
- skinModel.traverse((object) => {
516
- if (object.isSkinnedMesh) {
517
- skinModelSkinnedMesh = object;
518
- }
519
- if (object instanceof Bone && object.name === 'hip') {
520
- boneRoot = object;
521
- }
522
- });
523
-
524
- // Set viewer properties
525
- this.viewer.sortedIndexes = indexes;
526
- this.viewer.flame_params = flame_params;
527
- this.viewer.lbs_weight_80k = lbs_weight_80k;
528
- this.viewer.bone_tree = bone_tree;
529
- this.viewer.totalFrames = flame_params['expr']?.length || 1;
530
-
531
- if (skinModelSkinnedMesh) {
532
- this.viewer.gaussianSplatCount = skinModelSkinnedMesh.geometry.attributes.position.count;
533
- }
534
-
535
- this.viewer.avatarMesh = skinModel;
536
- this.viewer.skinModel = skinModelSkinnedMesh;
537
- this.viewer.boneRoot = boneRoot;
538
- this.motioncfg = motionConfig;
539
-
540
- // Update morph targets
541
- if (skinModelSkinnedMesh) {
542
- this.viewer.updateMorphTarget(skinModelSkinnedMesh);
543
- }
544
-
545
- // Add to scene (hidden)
546
- this.viewer.threeScene.add(skinModel);
547
- skinModel.visible = false;
548
-
549
- // Compute bone texture
550
- if (skinModelSkinnedMesh) {
551
- skinModelSkinnedMesh.skeleton.computeBoneTexture();
552
- }
553
- }
554
-
555
- /**
556
- * Load non-FLAME model with animation
850
+ * Load model with animation
557
851
  * @param {string} pathName - Path within ZIP
558
852
  * @param {object} animationConfig - Animation configuration
559
853
  * @param {object} motionConfig - Motion configuration
@@ -602,7 +896,7 @@ export class GaussianSplatRenderer {
602
896
  } else {
603
897
  this.viewer.totalFrames = 1;
604
898
  }
605
- console.log('loadModel: totalFrames set to', this.viewer.totalFrames);
899
+ logger.debug('Total frames calculated', { totalFrames: this.viewer.totalFrames });
606
900
 
607
901
  if (skinModelSkinnedMesh) {
608
902
  this.viewer.updateMorphTarget(skinModelSkinnedMesh);
@@ -617,36 +911,193 @@ export class GaussianSplatRenderer {
617
911
  }
618
912
 
619
913
  /**
620
- * Unpack file from ZIP as blob URL
621
- * @param {string} path - Path within ZIP
622
- * @returns {Promise<string>} Blob URL
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
623
922
  */
624
923
  async unpackFileAsBlob(path) {
625
- if (!this.zipUrls.urls.has(path)) {
626
- const modelFile = await this.zipUrls.zip?.file(path)?.async('blob');
627
- if (modelFile) {
628
- const modelUrl = URL.createObjectURL(modelFile);
629
- this.zipUrls.urls.set(path, modelUrl);
630
- }
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);
631
930
  }
632
- return this.zipUrls.urls.get(path);
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;
633
974
  }
634
975
 
635
976
  /**
636
- * Unpack and load GLB file
637
- * @param {string} path - Path within ZIP
638
- * @returns {Promise<THREE.Group|THREE.AnimationClip[]>}
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
639
1028
  */
640
1029
  async unpackAndLoadGlb(path) {
641
- if (!this.zipUrls.urls.has(path)) {
642
- const modelFile = await this.zipUrls.zip?.file(path)?.async('arraybuffer');
643
- if (modelFile) {
644
- const blob = new Blob([modelFile], { type: 'model/gltf-binary' });
645
- const modelUrl = URL.createObjectURL(blob);
646
- this.zipUrls.urls.set(path, modelUrl);
647
- }
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
+ );
648
1058
  }
649
- return this.LoadGLTF(this.zipUrls.urls.get(path));
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';
650
1101
  }
651
1102
 
652
1103
  /**