@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.
Files changed (64) hide show
  1. package/README.md +30 -0
  2. package/dist/gsplat-flame-avatar-renderer.cjs.js +38 -33
  3. package/dist/gsplat-flame-avatar-renderer.cjs.min.js +1 -1
  4. package/dist/gsplat-flame-avatar-renderer.cjs.min.js.map +1 -1
  5. package/dist/gsplat-flame-avatar-renderer.esm.js +38 -33
  6. package/dist/gsplat-flame-avatar-renderer.esm.min.js +1 -1
  7. package/dist/gsplat-flame-avatar-renderer.esm.min.js.map +1 -1
  8. package/package.json +5 -2
  9. package/src/api/index.js +0 -7
  10. package/src/buffers/SplatBuffer.js +0 -1394
  11. package/src/buffers/SplatBufferGenerator.js +0 -41
  12. package/src/buffers/SplatPartitioner.js +0 -110
  13. package/src/buffers/UncompressedSplatArray.js +0 -106
  14. package/src/buffers/index.js +0 -11
  15. package/src/core/SplatGeometry.js +0 -48
  16. package/src/core/SplatMesh.js +0 -2627
  17. package/src/core/SplatScene.js +0 -43
  18. package/src/core/SplatTree.js +0 -200
  19. package/src/core/Viewer.js +0 -2746
  20. package/src/core/index.js +0 -13
  21. package/src/enums/EngineConstants.js +0 -58
  22. package/src/enums/LogLevel.js +0 -13
  23. package/src/enums/RenderMode.js +0 -11
  24. package/src/enums/SceneFormat.js +0 -21
  25. package/src/enums/SceneRevealMode.js +0 -11
  26. package/src/enums/SplatRenderMode.js +0 -10
  27. package/src/enums/index.js +0 -13
  28. package/src/errors/ApplicationError.js +0 -185
  29. package/src/errors/index.js +0 -17
  30. package/src/flame/FlameAnimator.js +0 -496
  31. package/src/flame/FlameConstants.js +0 -21
  32. package/src/flame/FlameTextureManager.js +0 -293
  33. package/src/flame/index.js +0 -22
  34. package/src/flame/utils.js +0 -50
  35. package/src/index.js +0 -39
  36. package/src/loaders/DirectLoadError.js +0 -14
  37. package/src/loaders/INRIAV1PlyParser.js +0 -223
  38. package/src/loaders/PlyLoader.js +0 -519
  39. package/src/loaders/PlyParser.js +0 -19
  40. package/src/loaders/PlyParserUtils.js +0 -311
  41. package/src/loaders/index.js +0 -13
  42. package/src/materials/SplatMaterial.js +0 -1068
  43. package/src/materials/SplatMaterial2D.js +0 -358
  44. package/src/materials/SplatMaterial3D.js +0 -323
  45. package/src/materials/index.js +0 -11
  46. package/src/raycaster/Hit.js +0 -37
  47. package/src/raycaster/Ray.js +0 -123
  48. package/src/raycaster/Raycaster.js +0 -175
  49. package/src/raycaster/index.js +0 -10
  50. package/src/renderer/AnimationManager.js +0 -577
  51. package/src/renderer/AppConstants.js +0 -101
  52. package/src/renderer/GaussianSplatRenderer.js +0 -1146
  53. package/src/renderer/index.js +0 -24
  54. package/src/utils/BlobUrlManager.js +0 -294
  55. package/src/utils/EventEmitter.js +0 -349
  56. package/src/utils/LoaderUtils.js +0 -66
  57. package/src/utils/Logger.js +0 -171
  58. package/src/utils/ObjectPool.js +0 -248
  59. package/src/utils/RenderLoop.js +0 -306
  60. package/src/utils/Util.js +0 -416
  61. package/src/utils/ValidationUtils.js +0 -331
  62. package/src/utils/index.js +0 -18
  63. package/src/worker/SortWorker.js +0 -284
  64. 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;