@spatius/avatarkit 1.3.1-beta.2 → 1.3.1-beta.4
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/CHANGELOG.md +25 -0
- package/dist/AvatarDownloader-C2JJVqKm.js +525 -0
- package/dist/AvatarSDK-CuejGi0J.js +6797 -0
- package/dist/OpusCodec-Bv4kzdt8.js +41726 -0
- package/dist/OpusDecoderProxy-BTWRku-I.js +129 -0
- package/dist/OpusEncoderProxy-_AWpY7N6.js +135 -0
- package/dist/StreamingAudioPlayer-DmotFnXu.js +561 -0
- package/dist/assets/AvatarDownloader-C-CiIH1p.js +753 -0
- package/dist/assets/AvatarSDK-RVj90oMR.js +6585 -0
- package/dist/assets/OpusDecoderWorker.worker-Bd4svkEs.js +41373 -0
- package/dist/assets/OpusEncoderWorker.worker-BdxYhZZ9.js +41567 -0
- package/dist/assets/avatar_core_wasm-wdep7Ict.js +2536 -0
- package/dist/assets/logger-B-X9jUON.js +15043 -0
- package/dist/assets/rolldown-runtime-B-1-B7_t.js +33 -0
- package/dist/avatar_core_wasm-BvVa8lO4.js +2532 -0
- package/dist/core/AvatarController.d.ts +53 -2
- package/dist/core/AvatarView.d.ts +0 -2
- package/dist/error-utils-PtNzUHiL.js +85 -0
- package/dist/index.js +7186 -24
- package/dist/logger-C3fw-NWP.js +15129 -0
- package/dist/pwa-cache-manager-D3nj4sd5.js +151 -0
- package/dist/rolldown-runtime-B-1-B7_t.js +33 -0
- package/dist/types/character.d.ts +4 -4
- package/dist/types/index.d.ts +60 -0
- package/package.json +8 -3
- package/dist/StreamingAudioPlayer-2bLnaLam.js +0 -656
- package/dist/avatar_core_wasm-BIbE25D3.js +0 -2697
- package/dist/index-CPolDcOo.js +0 -22241
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import { _ as recordHttpClientDuration, c as logEvent, dt as isDebugMode, l as logMetric, mt as AvatarError, pt as generateTraceId, t as logger, vt as ErrorCode } from "./logger-B-X9jUON.js";
|
|
2
|
+
import { t as AvatarSDK } from "./AvatarSDK-RVj90oMR.js";
|
|
3
|
+
//#region config/app-config.ts
|
|
4
|
+
const GLOBAL_FLAME_CDN_BASE = "https://cdn.spatialwalk.cloud/public";
|
|
5
|
+
const CN_FLAME_CDN_BASE = "https://cdn.spatialwalk.top/public";
|
|
6
|
+
function getFlameCdnBase(region) {
|
|
7
|
+
return region.startsWith("cn-") ? CN_FLAME_CDN_BASE : GLOBAL_FLAME_CDN_BASE;
|
|
8
|
+
}
|
|
9
|
+
const APP_CONFIG = {
|
|
10
|
+
testEnv: false,
|
|
11
|
+
get debug() {
|
|
12
|
+
return isDebugMode();
|
|
13
|
+
},
|
|
14
|
+
rendering: {
|
|
15
|
+
/**
|
|
16
|
+
* Sort mode
|
|
17
|
+
* - 'balance': Performance first, sort first frame then reuse (default)
|
|
18
|
+
* - 'quality': Quality first, re-sort every frame
|
|
19
|
+
*/
|
|
20
|
+
sortMode: "balance" },
|
|
21
|
+
camera: {
|
|
22
|
+
position: [
|
|
23
|
+
-.02,
|
|
24
|
+
-.013,
|
|
25
|
+
1.5
|
|
26
|
+
],
|
|
27
|
+
target: [
|
|
28
|
+
0,
|
|
29
|
+
0,
|
|
30
|
+
0
|
|
31
|
+
],
|
|
32
|
+
fov: 22,
|
|
33
|
+
near: .01,
|
|
34
|
+
far: 100
|
|
35
|
+
},
|
|
36
|
+
animation: { fps: 25 },
|
|
37
|
+
audio: { sampleRate: 16e3 },
|
|
38
|
+
avatar: {
|
|
39
|
+
baseAssetsPath: "",
|
|
40
|
+
wasmPath: "./wasm/avatar_core_wasm.js"
|
|
41
|
+
},
|
|
42
|
+
flame: {
|
|
43
|
+
cdnBase: GLOBAL_FLAME_CDN_BASE,
|
|
44
|
+
unifiedModelPath: "unified_model_body.pb.gz"
|
|
45
|
+
},
|
|
46
|
+
wasm: {
|
|
47
|
+
logLevel: "basic",
|
|
48
|
+
enableValidation: false,
|
|
49
|
+
enablePerformanceMetrics: true
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region utils/error-utils.ts
|
|
54
|
+
/**
|
|
55
|
+
* Error Utility Functions
|
|
56
|
+
* Provides consistent error handling and formatting across the application
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Convert unknown error to a readable error message string
|
|
60
|
+
*
|
|
61
|
+
* @param err - Unknown error object (could be Error, string, object, etc.)
|
|
62
|
+
* @returns Formatted error message string
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* try {
|
|
66
|
+
* riskyOperation()
|
|
67
|
+
* } catch (err) {
|
|
68
|
+
* const message = errorToMessage(err)
|
|
69
|
+
* logger.error('Operation failed:', message)
|
|
70
|
+
* }
|
|
71
|
+
*/
|
|
72
|
+
function errorToMessage(err) {
|
|
73
|
+
if (err instanceof Error) return err.message;
|
|
74
|
+
if (typeof err === "string") return err;
|
|
75
|
+
if (typeof err === "object" && err !== null) {
|
|
76
|
+
if ("message" in err && typeof err.message === "string") return err.message;
|
|
77
|
+
try {
|
|
78
|
+
return JSON.stringify(err);
|
|
79
|
+
} catch {
|
|
80
|
+
return String(err);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return String(err);
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region utils/pwa-cache-manager.ts
|
|
87
|
+
/**
|
|
88
|
+
* PWA Cache Manager
|
|
89
|
+
* Manages Service Worker Cache for character resources and template resources
|
|
90
|
+
* @internal
|
|
91
|
+
*/
|
|
92
|
+
/**
|
|
93
|
+
* PWA Cache Manager
|
|
94
|
+
* Manages character resources cache (per character) and template resources cache (versioned)
|
|
95
|
+
*/
|
|
96
|
+
var PwaCacheManager = class PwaCacheManager {
|
|
97
|
+
static TEMPLATE_RESOURCE_VERSION = "1.0.0";
|
|
98
|
+
static TEMPLATE_CACHE_NAME = `spatius-sdk-template-cache-${PwaCacheManager.TEMPLATE_RESOURCE_VERSION}`;
|
|
99
|
+
static TEMPLATE_VERSION_STORAGE_KEY = "spatius-sdk-template-cache-version";
|
|
100
|
+
static CHARACTER_CACHE_PREFIX = "spatius-sdk-character-";
|
|
101
|
+
static CHARACTER_CACHE_SUFFIX = "-cache";
|
|
102
|
+
static MAX_CHARACTER_CACHE_ENTRIES = 1e3;
|
|
103
|
+
/**
|
|
104
|
+
* Check if Cache API is supported
|
|
105
|
+
* @internal
|
|
106
|
+
*/
|
|
107
|
+
static isSupported() {
|
|
108
|
+
return typeof caches !== "undefined";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get character cache name
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
static getCharacterCacheName(characterId) {
|
|
115
|
+
return `${PwaCacheManager.CHARACTER_CACHE_PREFIX}${characterId}${PwaCacheManager.CHARACTER_CACHE_SUFFIX}`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get character resource from cache
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
static async getCharacterResource(characterId, url) {
|
|
122
|
+
if (!PwaCacheManager.isSupported()) return null;
|
|
123
|
+
try {
|
|
124
|
+
const cacheName = PwaCacheManager.getCharacterCacheName(characterId);
|
|
125
|
+
const response = await (await caches.open(cacheName)).match(url);
|
|
126
|
+
if (response) {
|
|
127
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
128
|
+
logger.log(`[PwaCacheManager] Character resource cache hit: ${url}`);
|
|
129
|
+
return arrayBuffer;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.warn(`[PwaCacheManager] Failed to get character resource from cache:`, error);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Put character resource into cache
|
|
139
|
+
* @internal
|
|
140
|
+
*/
|
|
141
|
+
static async putCharacterResource(characterId, url, data) {
|
|
142
|
+
if (!PwaCacheManager.isSupported()) return;
|
|
143
|
+
try {
|
|
144
|
+
const cacheName = PwaCacheManager.getCharacterCacheName(characterId);
|
|
145
|
+
const cache = await caches.open(cacheName);
|
|
146
|
+
const keys = await cache.keys();
|
|
147
|
+
if (keys.length >= PwaCacheManager.MAX_CHARACTER_CACHE_ENTRIES) {
|
|
148
|
+
const oldestKey = keys[0];
|
|
149
|
+
await cache.delete(oldestKey);
|
|
150
|
+
logger.log(`[PwaCacheManager] Character cache full, deleted oldest entry: ${oldestKey.url}`);
|
|
151
|
+
}
|
|
152
|
+
await cache.put(url, new Response(data));
|
|
153
|
+
logger.log(`[PwaCacheManager] Character resource cached: ${url}`);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logger.warn(`[PwaCacheManager] Failed to put character resource to cache:`, error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Get template resource from cache
|
|
160
|
+
* @internal
|
|
161
|
+
*/
|
|
162
|
+
static async getTemplateResource(url) {
|
|
163
|
+
if (!PwaCacheManager.isSupported()) return null;
|
|
164
|
+
try {
|
|
165
|
+
const response = await (await caches.open(PwaCacheManager.TEMPLATE_CACHE_NAME)).match(url);
|
|
166
|
+
if (response) {
|
|
167
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
168
|
+
logger.log(`[PwaCacheManager] Template resource cache hit: ${url}`);
|
|
169
|
+
return arrayBuffer;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.warn(`[PwaCacheManager] Failed to get template resource from cache:`, error);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Put template resource into cache
|
|
179
|
+
* Template resources have no quantity limit, permanently retained until version update
|
|
180
|
+
* @internal
|
|
181
|
+
*/
|
|
182
|
+
static async putTemplateResource(url, data) {
|
|
183
|
+
if (!PwaCacheManager.isSupported()) return;
|
|
184
|
+
try {
|
|
185
|
+
await (await caches.open(PwaCacheManager.TEMPLATE_CACHE_NAME)).put(url, new Response(data));
|
|
186
|
+
logger.log(`[PwaCacheManager] Template resource cached: ${url}`);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logger.warn(`[PwaCacheManager] Failed to put template resource to cache:`, error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Clear character cache
|
|
193
|
+
* @internal
|
|
194
|
+
*/
|
|
195
|
+
static async clearCharacterCache(characterId) {
|
|
196
|
+
if (!PwaCacheManager.isSupported()) return;
|
|
197
|
+
try {
|
|
198
|
+
const cacheName = PwaCacheManager.getCharacterCacheName(characterId);
|
|
199
|
+
await caches.delete(cacheName);
|
|
200
|
+
logger.log(`[PwaCacheManager] Character cache cleared: ${characterId}`);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
logger.warn(`[PwaCacheManager] Failed to clear character cache:`, error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Check template cache version, clear cache if version changed
|
|
207
|
+
* Uses independent template resource version (not dependent on SDK version), allowing different SDK versions to share the same template resource cache
|
|
208
|
+
* @returns true if version changed and cache was cleared, false otherwise
|
|
209
|
+
* @internal
|
|
210
|
+
*/
|
|
211
|
+
static async checkTemplateCacheVersion() {
|
|
212
|
+
if (!PwaCacheManager.isSupported()) return false;
|
|
213
|
+
try {
|
|
214
|
+
const currentTemplateVersion = PwaCacheManager.TEMPLATE_RESOURCE_VERSION;
|
|
215
|
+
const storedVersion = localStorage.getItem(PwaCacheManager.TEMPLATE_VERSION_STORAGE_KEY);
|
|
216
|
+
if (storedVersion !== currentTemplateVersion) {
|
|
217
|
+
if (storedVersion) {
|
|
218
|
+
const oldCacheName = `spatius-sdk-template-cache-${storedVersion}`;
|
|
219
|
+
await caches.delete(oldCacheName).catch(() => {});
|
|
220
|
+
}
|
|
221
|
+
localStorage.setItem(PwaCacheManager.TEMPLATE_VERSION_STORAGE_KEY, currentTemplateVersion);
|
|
222
|
+
logger.log(`[PwaCacheManager] Template cache version changed: ${storedVersion} -> ${currentTemplateVersion}, old cache cleared`);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
logger.warn(`[PwaCacheManager] Failed to check template cache version:`, error);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region core/AvatarDownloader.ts
|
|
234
|
+
/**
|
|
235
|
+
* Get cache information for a resource
|
|
236
|
+
* Detects browser cache, CDN cache, and PWA (Service Worker) cache
|
|
237
|
+
*/
|
|
238
|
+
function getCacheInfo(url, response) {
|
|
239
|
+
const resourceTiming = performance.getEntriesByName(url, "resource")[0];
|
|
240
|
+
const transferSize = resourceTiming?.transferSize ?? 0;
|
|
241
|
+
const deliveryType = resourceTiming?.deliveryType;
|
|
242
|
+
const hasServiceWorker = typeof navigator !== "undefined" && navigator.serviceWorker?.controller !== null;
|
|
243
|
+
const isPwaCache = transferSize === 0 && hasServiceWorker && (deliveryType === "cache" || deliveryType === "serviceworker") && resourceTiming?.duration !== void 0 && resourceTiming.duration > 0;
|
|
244
|
+
const isBrowserCache = transferSize === 0 && !isPwaCache && (!hasServiceWorker || deliveryType === "cache") && resourceTiming?.duration !== void 0 && resourceTiming.duration > 0;
|
|
245
|
+
const cfCacheStatus = response.headers.get("cf-cache-status");
|
|
246
|
+
const xCache = response.headers.get("x-cache");
|
|
247
|
+
const xSwiftCacheTime = response.headers.get("x-swift-cache-time");
|
|
248
|
+
const cdnCacheStatus = cfCacheStatus || xCache || xSwiftCacheTime;
|
|
249
|
+
let isCdnCache = false;
|
|
250
|
+
if (cdnCacheStatus) {
|
|
251
|
+
const statusLower = cdnCacheStatus.toLowerCase();
|
|
252
|
+
if (statusLower === "hit" || statusLower === "hits" || statusLower.includes("hit")) {
|
|
253
|
+
if (!statusLower.includes("miss") && !statusLower.includes("dynamic") && !statusLower.includes("bypass")) isCdnCache = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
let cacheHit = false;
|
|
257
|
+
let cacheType = "none";
|
|
258
|
+
if (isPwaCache) {
|
|
259
|
+
cacheHit = true;
|
|
260
|
+
cacheType = "pwa";
|
|
261
|
+
} else if (isBrowserCache) {
|
|
262
|
+
cacheHit = true;
|
|
263
|
+
cacheType = "browser";
|
|
264
|
+
} else if (isCdnCache) {
|
|
265
|
+
cacheHit = true;
|
|
266
|
+
cacheType = "cdn";
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
cacheHit,
|
|
270
|
+
cacheType,
|
|
271
|
+
transferSize,
|
|
272
|
+
cdnCacheStatus: cdnCacheStatus || void 0
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Simple download helper with CORS proxy support for development
|
|
277
|
+
* Static resources don't need appId and token headers
|
|
278
|
+
*
|
|
279
|
+
* Features:
|
|
280
|
+
* - Supports AbortSignal for cancellation
|
|
281
|
+
* - Automatic retry (default 3 times, no delay)
|
|
282
|
+
* - PWA cache integration
|
|
283
|
+
*
|
|
284
|
+
* @param url 资源 URL
|
|
285
|
+
* @param options 下载选项
|
|
286
|
+
* @returns ArrayBuffer and cache information
|
|
287
|
+
*/
|
|
288
|
+
async function downloadResource(url, options) {
|
|
289
|
+
const { signal, characterId, resourceType, maxRetries = 3 } = options || {};
|
|
290
|
+
if (signal?.aborted) throw new Error("Download cancelled");
|
|
291
|
+
try {
|
|
292
|
+
let cached = null;
|
|
293
|
+
let pwaCacheSubtype = void 0;
|
|
294
|
+
if (characterId) {
|
|
295
|
+
cached = await PwaCacheManager.getCharacterResource(characterId, url);
|
|
296
|
+
if (cached) pwaCacheSubtype = "character";
|
|
297
|
+
} else if (resourceType === "template") {
|
|
298
|
+
cached = await PwaCacheManager.getTemplateResource(url);
|
|
299
|
+
if (cached) pwaCacheSubtype = "template";
|
|
300
|
+
}
|
|
301
|
+
if (cached) {
|
|
302
|
+
const response = new Response(cached);
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
304
|
+
const cacheInfo = getCacheInfo(url, response);
|
|
305
|
+
cacheInfo.cacheHit = true;
|
|
306
|
+
cacheInfo.cacheType = "pwa";
|
|
307
|
+
cacheInfo.pwaCacheSubtype = pwaCacheSubtype;
|
|
308
|
+
return {
|
|
309
|
+
data: cached,
|
|
310
|
+
cacheInfo
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
let lastError = null;
|
|
314
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
315
|
+
if (signal?.aborted) throw new Error("Download cancelled");
|
|
316
|
+
try {
|
|
317
|
+
const response = await fetch(url, { signal });
|
|
318
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
319
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
320
|
+
const contentLength = response.headers.get("content-length");
|
|
321
|
+
if (contentLength) {
|
|
322
|
+
const expectedSize = parseInt(contentLength, 10);
|
|
323
|
+
if (!isNaN(expectedSize) && arrayBuffer.byteLength < expectedSize) throw new Error(`Download incomplete: received ${arrayBuffer.byteLength} bytes, expected ${expectedSize} bytes`);
|
|
324
|
+
}
|
|
325
|
+
if (characterId) PwaCacheManager.putCharacterResource(characterId, url, arrayBuffer).catch((err) => {
|
|
326
|
+
logger.warn(`[downloadResource] Failed to cache character resource:`, err);
|
|
327
|
+
});
|
|
328
|
+
else if (resourceType === "template") PwaCacheManager.putTemplateResource(url, arrayBuffer).catch((err) => {
|
|
329
|
+
logger.warn(`[downloadResource] Failed to cache template resource:`, err);
|
|
330
|
+
});
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
332
|
+
return {
|
|
333
|
+
data: arrayBuffer,
|
|
334
|
+
cacheInfo: getCacheInfo(url, response)
|
|
335
|
+
};
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (err instanceof Error && (err.name === "AbortError" || err.message === "Download cancelled")) throw err;
|
|
338
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
339
|
+
if (attempt < maxRetries) logger.warn(`[downloadResource] Attempt ${attempt}/${maxRetries} failed for ${url}, retrying immediately...`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
throw lastError || /* @__PURE__ */ new Error(`Failed to download ${url} after ${maxRetries} attempts`);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (err instanceof Error && (err.name === "AbortError" || err.message === "Download cancelled")) throw err;
|
|
345
|
+
const msg = errorToMessage(err);
|
|
346
|
+
throw new Error(`[downloadResource] ${url} → ${msg}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
var AvatarDownloader = class {
|
|
350
|
+
baseAssetsPath;
|
|
351
|
+
constructor(baseAssetsPath = "/") {
|
|
352
|
+
this.baseAssetsPath = baseAssetsPath;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Load unified template model (single gzip-compressed file)
|
|
356
|
+
* Includes PWA cache, retry, integrity check, and telemetry
|
|
357
|
+
* @internal
|
|
358
|
+
*/
|
|
359
|
+
async loadUnifiedTemplate() {
|
|
360
|
+
await PwaCacheManager.checkTemplateCacheVersion();
|
|
361
|
+
const startTime = Date.now();
|
|
362
|
+
const cdnBase = getFlameCdnBase(AvatarSDK.configuration?.region || "us-west");
|
|
363
|
+
const { unifiedModelPath } = APP_CONFIG.flame;
|
|
364
|
+
const url = `${cdnBase}/${unifiedModelPath}`;
|
|
365
|
+
logger.log(`📥 Loading unified template from: ${url}`);
|
|
366
|
+
const cached = await PwaCacheManager.getTemplateResource(url);
|
|
367
|
+
if (cached) {
|
|
368
|
+
const duration = Date.now() - startTime;
|
|
369
|
+
logger.log(`✅ Unified template loaded from cache (${(cached.byteLength / 1024 / 1024).toFixed(1)} MB)`);
|
|
370
|
+
logMetric("template_resources_load_measure", duration, {
|
|
371
|
+
file_count: 1,
|
|
372
|
+
cache_hit: true,
|
|
373
|
+
cache_type: "pwa"
|
|
374
|
+
});
|
|
375
|
+
return { unifiedModel: cached };
|
|
376
|
+
}
|
|
377
|
+
const maxRetries = 3;
|
|
378
|
+
let lastError = null;
|
|
379
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) try {
|
|
380
|
+
const response = await fetch(url);
|
|
381
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
382
|
+
let buffer;
|
|
383
|
+
if (APP_CONFIG.flame.unifiedModelPath.endsWith(".gz")) {
|
|
384
|
+
const decompressedStream = response.body.pipeThrough(new DecompressionStream("gzip"));
|
|
385
|
+
buffer = await new Response(decompressedStream).arrayBuffer();
|
|
386
|
+
} else buffer = await response.arrayBuffer();
|
|
387
|
+
logger.log(`✅ Unified template loaded (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB)`);
|
|
388
|
+
PwaCacheManager.putTemplateResource(url, buffer).catch((err) => {
|
|
389
|
+
logger.warn(`[loadUnifiedTemplate] Failed to cache:`, err);
|
|
390
|
+
});
|
|
391
|
+
logMetric("template_resources_load_measure", Date.now() - startTime, {
|
|
392
|
+
file_count: 1,
|
|
393
|
+
cache_hit: false,
|
|
394
|
+
cache_type: "none"
|
|
395
|
+
});
|
|
396
|
+
return { unifiedModel: buffer };
|
|
397
|
+
} catch (err) {
|
|
398
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
399
|
+
if (attempt < maxRetries) logger.warn(`[loadUnifiedTemplate] Attempt ${attempt}/${maxRetries} failed, retrying...`);
|
|
400
|
+
}
|
|
401
|
+
throw lastError || /* @__PURE__ */ new Error(`Failed to download unified template after ${maxRetries} attempts`);
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Load camera settings from CharacterMeta (optional)
|
|
405
|
+
* @internal
|
|
406
|
+
*/
|
|
407
|
+
async loadCameraSettings(characterMeta, options) {
|
|
408
|
+
const { signal } = options || {};
|
|
409
|
+
const cameraUrl = characterMeta.camera?.resource?.remote;
|
|
410
|
+
if (!cameraUrl) {
|
|
411
|
+
logger.log("ℹ️ No camera resource URL provided");
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (signal?.aborted) throw new Error("Load cancelled");
|
|
415
|
+
try {
|
|
416
|
+
logger.log(`📥 Loading camera info from: ${cameraUrl}`);
|
|
417
|
+
const { data: arrayBuffer } = await downloadResource(cameraUrl, {
|
|
418
|
+
signal,
|
|
419
|
+
characterId: characterMeta.characterId ?? void 0,
|
|
420
|
+
resourceType: "character"
|
|
421
|
+
});
|
|
422
|
+
const text = new TextDecoder().decode(arrayBuffer);
|
|
423
|
+
const cameraSettings = JSON.parse(text);
|
|
424
|
+
logger.log("✅ Camera info loaded:", cameraSettings);
|
|
425
|
+
return cameraSettings;
|
|
426
|
+
} catch (error) {
|
|
427
|
+
logger.warn("⚠️ Failed to load camera info, using default:", error);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Load character data from CharacterMeta (iOS compatible)
|
|
433
|
+
* @internal
|
|
434
|
+
*/
|
|
435
|
+
async loadCharacterData(characterMeta, options) {
|
|
436
|
+
const { progressCallback = null, signal, useCompressedModel = false } = options || {};
|
|
437
|
+
if (signal?.aborted) throw new Error("Download cancelled");
|
|
438
|
+
const totalStartTime = Date.now();
|
|
439
|
+
const shapeUrl = characterMeta.models?.shape?.resource?.remote;
|
|
440
|
+
const pointCloudUrl = useCompressedModel ? characterMeta.models?.gsStandard?.xrResource?.remote ?? characterMeta.models?.gsStandard?.resource?.remote : characterMeta.models?.gsStandard?.resource?.remote;
|
|
441
|
+
const idleAnimationUrl = characterMeta.animations?.frameIdle?.resource?.remote;
|
|
442
|
+
const monoAnimationUrl = characterMeta.animations?.frameMono?.resource?.remote;
|
|
443
|
+
if (!shapeUrl || !pointCloudUrl) throw new Error("Missing required resources: shape or gsStandard (point cloud)");
|
|
444
|
+
const filesToLoad = [{
|
|
445
|
+
key: "shape",
|
|
446
|
+
url: shapeUrl,
|
|
447
|
+
filename: "shape.pb"
|
|
448
|
+
}, {
|
|
449
|
+
key: "pointCloud",
|
|
450
|
+
url: pointCloudUrl,
|
|
451
|
+
filename: "point_cloud.ply"
|
|
452
|
+
}];
|
|
453
|
+
if (idleAnimationUrl) filesToLoad.push({
|
|
454
|
+
key: "idleAnimation",
|
|
455
|
+
url: idleAnimationUrl,
|
|
456
|
+
filename: "idle.pb",
|
|
457
|
+
optional: true
|
|
458
|
+
});
|
|
459
|
+
if (monoAnimationUrl) filesToLoad.push({
|
|
460
|
+
key: "monoAnimation",
|
|
461
|
+
url: monoAnimationUrl,
|
|
462
|
+
filename: "mono.pb",
|
|
463
|
+
optional: true
|
|
464
|
+
});
|
|
465
|
+
let loadedFiles = 0;
|
|
466
|
+
const totalFiles = filesToLoad.length;
|
|
467
|
+
const updateProgress = (filename, loaded) => {
|
|
468
|
+
if (progressCallback) {
|
|
469
|
+
if (loaded) loadedFiles++;
|
|
470
|
+
progressCallback({
|
|
471
|
+
stage: "character",
|
|
472
|
+
filename,
|
|
473
|
+
loaded: loadedFiles,
|
|
474
|
+
total: totalFiles,
|
|
475
|
+
progress: loadedFiles / totalFiles
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const characterData = {};
|
|
480
|
+
const cacheInfos = [];
|
|
481
|
+
const parallelStartTime = Date.now();
|
|
482
|
+
const downloadPromises = filesToLoad.map(async ({ key, url, filename, optional }) => {
|
|
483
|
+
updateProgress(filename, false);
|
|
484
|
+
try {
|
|
485
|
+
const { data: arrayBuffer, cacheInfo } = await downloadResource(url, {
|
|
486
|
+
signal,
|
|
487
|
+
characterId: characterMeta.characterId ?? void 0,
|
|
488
|
+
resourceType: "character"
|
|
489
|
+
});
|
|
490
|
+
if (key === "shape") characterData.shape = arrayBuffer;
|
|
491
|
+
else if (key === "pointCloud") characterData.pointCloud = arrayBuffer;
|
|
492
|
+
else if (key === "idleAnimation") characterData.idleAnimation = arrayBuffer;
|
|
493
|
+
else if (key === "monoAnimation") characterData.monoAnimation = arrayBuffer;
|
|
494
|
+
cacheInfos.push(cacheInfo);
|
|
495
|
+
updateProgress(filename, true);
|
|
496
|
+
return {
|
|
497
|
+
key,
|
|
498
|
+
success: true,
|
|
499
|
+
size: arrayBuffer.byteLength
|
|
500
|
+
};
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (error instanceof Error && (error.name === "AbortError" || error.message === "Download cancelled")) {
|
|
503
|
+
logEvent("download_avatar_assets_cancelled", "info", { avatar_id: characterMeta.characterId ?? "unknown" });
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
if (!optional) {
|
|
507
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
508
|
+
logEvent("download_avatar_assets_failed", "error", {
|
|
509
|
+
avatar_id: characterMeta.characterId ?? "unknown",
|
|
510
|
+
description: `Failed to download required resource: ${filename}`,
|
|
511
|
+
resource: key,
|
|
512
|
+
url,
|
|
513
|
+
error: errorMessage
|
|
514
|
+
});
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
logger.warn(`⚠️ Optional resource ${filename} failed to load:`, error);
|
|
518
|
+
updateProgress(filename, true);
|
|
519
|
+
return {
|
|
520
|
+
key,
|
|
521
|
+
success: false,
|
|
522
|
+
size: 0
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
await Promise.all(downloadPromises);
|
|
527
|
+
const parallelDuration = Date.now() - parallelStartTime;
|
|
528
|
+
const totalDuration = Date.now() - totalStartTime;
|
|
529
|
+
if (!characterData.shape || !characterData.pointCloud) {
|
|
530
|
+
const reason = "Failed to load character data";
|
|
531
|
+
logEvent("download_avatar_assets_failed", "error", {
|
|
532
|
+
avatar_id: characterMeta.characterId ?? "unknown",
|
|
533
|
+
description: reason
|
|
534
|
+
});
|
|
535
|
+
throw new Error(reason);
|
|
536
|
+
}
|
|
537
|
+
const cacheHit = cacheInfos.length > 0 && cacheInfos.every((info) => info.cacheHit);
|
|
538
|
+
const cacheType = cacheInfos[0]?.cacheType || "none";
|
|
539
|
+
const totalSize = Object.values(characterData).reduce((sum, buffer) => {
|
|
540
|
+
return sum + (buffer ? buffer.byteLength : 0);
|
|
541
|
+
}, 0);
|
|
542
|
+
logMetric("download_avatar_assets_latency", totalDuration, {
|
|
543
|
+
resolution: "default",
|
|
544
|
+
use_compressed_model: useCompressedModel,
|
|
545
|
+
file_count: filesToLoad.length,
|
|
546
|
+
cache_hit: cacheHit,
|
|
547
|
+
cache_type: cacheType
|
|
548
|
+
}, {
|
|
549
|
+
avatar_id: characterMeta.characterId ?? "unknown",
|
|
550
|
+
parallel_duration: parallelDuration,
|
|
551
|
+
total_size: totalSize
|
|
552
|
+
});
|
|
553
|
+
return characterData;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Preload all resources (template + character data + camera info + settings)
|
|
557
|
+
* @internal
|
|
558
|
+
*/
|
|
559
|
+
async preloadResources(characterMeta, options) {
|
|
560
|
+
const { progressCallback = null, signal, useCompressedModel = false } = options || {};
|
|
561
|
+
if (signal?.aborted) throw new Error("Preload cancelled");
|
|
562
|
+
const [characterData, preloadCameraSettings] = await Promise.all([this.loadCharacterData(characterMeta, {
|
|
563
|
+
signal,
|
|
564
|
+
useCompressedModel,
|
|
565
|
+
progressCallback: (info) => {
|
|
566
|
+
if (progressCallback) progressCallback({
|
|
567
|
+
...info,
|
|
568
|
+
stage: `character-${info.stage}`
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}), this.loadCameraSettings(characterMeta, { signal })]);
|
|
572
|
+
return {
|
|
573
|
+
characterData,
|
|
574
|
+
preloadCameraSettings,
|
|
575
|
+
characterSettings: characterMeta.characterSettings
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Get AvatarKit SDK API Client (region-templated endpoint composed by AvatarSDK.getEnvironmentConfig).
|
|
580
|
+
* Used for: character details and resource URLs (public endpoints, no auth required)
|
|
581
|
+
* Note: This endpoint does not require authentication, so we don't add X-App-Id or Authorization headers
|
|
582
|
+
* to avoid CORS preflight requests for simple GET requests
|
|
583
|
+
*/
|
|
584
|
+
getSdkApiClient() {
|
|
585
|
+
return { async request(url, options = {}) {
|
|
586
|
+
const baseUrl = AvatarSDK.getEnvironmentConfig().sdkApiBaseUrl;
|
|
587
|
+
const fullUrl = baseUrl + url;
|
|
588
|
+
const headers = {};
|
|
589
|
+
const method = options.method || "GET";
|
|
590
|
+
if (method !== "GET" && options.body) headers["Content-Type"] = "application/json";
|
|
591
|
+
const operation = url.split("?")[0].replace(/\/v2\/avatar\/[^/?]+/, "/v2/avatar/{id}");
|
|
592
|
+
let serverAddress;
|
|
593
|
+
try {
|
|
594
|
+
serverAddress = new URL(baseUrl).host;
|
|
595
|
+
} catch {}
|
|
596
|
+
const startMs = performance.now();
|
|
597
|
+
try {
|
|
598
|
+
const response = await fetch(fullUrl, {
|
|
599
|
+
method,
|
|
600
|
+
headers: {
|
|
601
|
+
...headers,
|
|
602
|
+
...options.headers
|
|
603
|
+
},
|
|
604
|
+
body: options.body ? JSON.stringify(options.body) : void 0,
|
|
605
|
+
signal: options.signal
|
|
606
|
+
});
|
|
607
|
+
recordHttpClientDuration({
|
|
608
|
+
operation,
|
|
609
|
+
method,
|
|
610
|
+
durationMs: Math.round(performance.now() - startMs),
|
|
611
|
+
statusCode: response.status,
|
|
612
|
+
serverAddress
|
|
613
|
+
});
|
|
614
|
+
if (!response.ok) {
|
|
615
|
+
let serverMessage = "";
|
|
616
|
+
try {
|
|
617
|
+
const body = await response.json();
|
|
618
|
+
if (body?.errors && Array.isArray(body.errors) && body.errors.length > 0) {
|
|
619
|
+
const e = body.errors[0];
|
|
620
|
+
serverMessage = e.detail || e.title || e.message || JSON.stringify(e);
|
|
621
|
+
} else serverMessage = body?.message || body?.error || JSON.stringify(body);
|
|
622
|
+
} catch {
|
|
623
|
+
serverMessage = response.statusText;
|
|
624
|
+
}
|
|
625
|
+
let error;
|
|
626
|
+
if (response.status === 404) {
|
|
627
|
+
const urlMatch = url.match(/\/v2\/(?:character|avatar)\/([^/?]+)/);
|
|
628
|
+
const extractedCharacterId = urlMatch ? urlMatch[1] : "unknown";
|
|
629
|
+
const callerTraceId = (options.headers || {})["x-sp-trace-id"];
|
|
630
|
+
logEvent("avatar_id_unrecognized", "error", {
|
|
631
|
+
avatar_id: extractedCharacterId,
|
|
632
|
+
description: `HTTP 404: ${serverMessage}`,
|
|
633
|
+
...callerTraceId ? { trace_id: callerTraceId } : {}
|
|
634
|
+
});
|
|
635
|
+
error = new AvatarError(`HTTP 404: ${serverMessage}`, ErrorCode.avatarIDUnrecognized);
|
|
636
|
+
} else error = new AvatarError(`HTTP ${response.status}: ${serverMessage}`, ErrorCode.failedToFetchAvatarMetadata);
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
return await response.json();
|
|
641
|
+
} catch {
|
|
642
|
+
throw new AvatarError("Avatar data is invalid. Please contact Spatius support.", ErrorCode.invalidAvatarMetadata);
|
|
643
|
+
}
|
|
644
|
+
} catch (err) {
|
|
645
|
+
if (err instanceof AvatarError) throw err;
|
|
646
|
+
recordHttpClientDuration({
|
|
647
|
+
operation,
|
|
648
|
+
method,
|
|
649
|
+
durationMs: Math.round(performance.now() - startMs),
|
|
650
|
+
serverAddress
|
|
651
|
+
});
|
|
652
|
+
throw new AvatarError("Failed to load avatar due to network issues, please check your connection and try again.", ErrorCode.failedToFetchAvatarMetadata);
|
|
653
|
+
}
|
|
654
|
+
} };
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Map the new `/v2/avatar/{id}` `AvatarAsset` payload onto the internal
|
|
658
|
+
* `CharacterMeta` shape used by the existing download / render pipeline.
|
|
659
|
+
*
|
|
660
|
+
* The backend (grpc-gateway) serialises proto fields as camelCase JSON, so the
|
|
661
|
+
* runtime object is loosely shaped like the generated `AvatarAsset`. We:
|
|
662
|
+
* - lift `models.gs` into `models.gsStandard` (downloader/asset-count read gsStandard)
|
|
663
|
+
* - rename `animations.frameFallback` → `animations.frameMono`
|
|
664
|
+
* - fold the inline `camera` / `transform` into `characterSettings` so the
|
|
665
|
+
* renderer's `resolveCameraConfig` reads structured values and no camera
|
|
666
|
+
* resource is downloaded (top-level `camera` is intentionally left unset)
|
|
667
|
+
* @internal
|
|
668
|
+
*/
|
|
669
|
+
mapAvatarAssetToCharacterMeta(asset, avatarId) {
|
|
670
|
+
const characterSettings = {
|
|
671
|
+
...asset.camera ? { camera: { ...asset.camera } } : {},
|
|
672
|
+
...asset.transform ? { transform: { ...asset.transform } } : {}
|
|
673
|
+
};
|
|
674
|
+
return {
|
|
675
|
+
characterId: avatarId,
|
|
676
|
+
version: asset.version ?? "",
|
|
677
|
+
compatibilityFlags: asset.compatibilityFlags ?? [],
|
|
678
|
+
updatedAt: asset.updatedAt,
|
|
679
|
+
models: {
|
|
680
|
+
shape: asset.models?.shape,
|
|
681
|
+
gsStandard: asset.models?.gs
|
|
682
|
+
},
|
|
683
|
+
animations: {
|
|
684
|
+
frameIdle: asset.animations?.frameIdle,
|
|
685
|
+
frameMono: asset.animations?.frameFallback
|
|
686
|
+
},
|
|
687
|
+
customAnimations: asset.animations?.customAnimations ?? [],
|
|
688
|
+
characterSettings
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Get single avatar by ID from AvatarKit SDK API (v2 driven-ingress avatar API).
|
|
693
|
+
* Domain: composed from region as api.${region}.spatius.ai
|
|
694
|
+
* Auth: Public endpoint, no authentication required
|
|
695
|
+
* Fetches the new `AvatarAsset` payload from `/v2/avatar/{id}` and maps it onto
|
|
696
|
+
* the internal `CharacterMeta` shape consumed by the download / render pipeline:
|
|
697
|
+
* - `models.gs` → `models.gsStandard`
|
|
698
|
+
* - `animations.frameFallback` → `animations.frameMono`
|
|
699
|
+
* - inline `camera` / `transform` → `characterSettings.{camera,transform}`
|
|
700
|
+
* (so the renderer reads structured values directly and no camera resource is downloaded)
|
|
701
|
+
* @internal
|
|
702
|
+
*/
|
|
703
|
+
async getCharacterById(characterId, options) {
|
|
704
|
+
const { signal } = options || {};
|
|
705
|
+
const startTime = Date.now();
|
|
706
|
+
const traceId = generateTraceId();
|
|
707
|
+
try {
|
|
708
|
+
if (signal?.aborted) throw new Error("Request cancelled");
|
|
709
|
+
const response = await this.getSdkApiClient().request(`/v2/avatar/${characterId}`, {
|
|
710
|
+
method: "GET",
|
|
711
|
+
headers: { "x-sp-trace-id": traceId },
|
|
712
|
+
signal
|
|
713
|
+
});
|
|
714
|
+
if (response?.errors && Array.isArray(response.errors) && response.errors.length > 0) {
|
|
715
|
+
const firstError = response.errors[0];
|
|
716
|
+
const serverMessage = firstError.detail || firstError.title || firstError.message || "Unknown server error";
|
|
717
|
+
throw new AvatarError(`${firstError.code || firstError.status || "SERVER_ERROR"}: ${serverMessage}`, ErrorCode.failedToFetchAvatarMetadata);
|
|
718
|
+
}
|
|
719
|
+
logMetric("fetch_avatar_metadata_latency", Date.now() - startTime, {}, {
|
|
720
|
+
avatar_id: characterId,
|
|
721
|
+
trace_id: traceId
|
|
722
|
+
});
|
|
723
|
+
return this.mapAvatarAssetToCharacterMeta(response, characterId);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error instanceof Error && (error.name === "AbortError" || error.message === "Request cancelled")) {
|
|
726
|
+
logEvent("fetch_avatar_metadata_cancelled", "info", {
|
|
727
|
+
avatar_id: characterId ?? "unknown",
|
|
728
|
+
trace_id: traceId
|
|
729
|
+
});
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
logger.error("Failed to fetch character:", error);
|
|
733
|
+
if (error instanceof AvatarError) {
|
|
734
|
+
logEvent("fetch_avatar_metadata_failed", "error", {
|
|
735
|
+
avatar_id: characterId ?? "unknown",
|
|
736
|
+
description: error.message,
|
|
737
|
+
trace_id: traceId
|
|
738
|
+
});
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
const errorMessage = error && typeof error === "object" && "message" in error ? String(error.message) : "Failed to fetch character";
|
|
742
|
+
const dataMessage = error && typeof error === "object" && "data" in error && typeof error.data === "object" && error.data?.message ? String(error.data.message) : null;
|
|
743
|
+
logEvent("fetch_avatar_metadata_failed", "error", {
|
|
744
|
+
avatar_id: characterId ?? "unknown",
|
|
745
|
+
description: dataMessage || errorMessage,
|
|
746
|
+
trace_id: traceId
|
|
747
|
+
});
|
|
748
|
+
throw new AvatarError("Failed to load avatar due to network issues, please check your connection and try again.", ErrorCode.failedToFetchAvatarMetadata);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
//#endregion
|
|
753
|
+
export { AvatarDownloader };
|