@spatius/avatarkit 1.3.1-beta.6 → 1.3.1

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 (22) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/{AvatarDownloader-Dfi7wtgQ.js → AvatarDownloader-DyhenPKd.js} +98 -43
  3. package/dist/{AvatarSDK-CnZqa_X4.js → AvatarSDK-DgPvHU4L.js} +162 -2560
  4. package/dist/{OpusCodec-D9DcUV65.js → OpusCodec-CXU7ysrU.js} +43 -9
  5. package/dist/{OpusDecoderProxy-Ar_dFXRB.js → OpusDecoderProxy-Cq8Z_MZY.js} +19 -6
  6. package/dist/{OpusEncoderProxy-9YIZ7nIt.js → OpusEncoderProxy-CzY-VckE.js} +42 -7
  7. package/dist/{StreamingAudioPlayer-D4q7c1U0.js → StreamingAudioPlayer-CIJmBp3z.js} +2 -2
  8. package/dist/assets/{AvatarDownloader-D5GePpJ2.js → AvatarDownloader-G5FO1bVl.js} +96 -41
  9. package/dist/assets/{AvatarSDK-DR8dNylh.js → AvatarSDK-C_Anluwp.js} +149 -12
  10. package/dist/assets/{OpusDecoderWorker.worker-B7R93nev.js → OpusDecoderWorker.worker-C2T0afkj.js} +21 -5
  11. package/dist/assets/{OpusEncoderWorker.worker-BYWIV5xu.js → OpusEncoderWorker.worker-DLYO9vPK.js} +53 -11
  12. package/dist/assets/{logger-D8rDVim7.js → logger-YPooEAbA.js} +451 -132
  13. package/dist/core/AvatarController.d.ts +10 -0
  14. package/dist/{error-utils-CqKOrHrY.js → error-utils-BogEllAd.js} +1 -1
  15. package/dist/index.js +279 -46
  16. package/dist/internal-telemetry.d.ts +45 -20
  17. package/dist/internal-telemetry.js +27 -2
  18. package/dist/{logger-fAU8oahT.js → logger-BOpQ7u0w.js} +468 -132
  19. package/dist/otel-trace-Ct4T48nC.js +2565 -0
  20. package/dist/{pwa-cache-manager-CwXaM3xR.js → pwa-cache-manager-C7NbU52Q.js} +1 -1
  21. package/dist/types/index.d.ts +30 -4
  22. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { n as __exportAll, r as __toESM, t as __commonJSMin } from "./rolldown-runtime-B-1-B7_t.js";
2
- import { t as logger } from "./logger-fAU8oahT.js";
2
+ import { t as logger } from "./logger-BOpQ7u0w.js";
3
3
  /** Opus granule positions are always in 48 kHz samples, regardless of input rate. */
4
4
  var OPUS_GRANULE_RATE = 48e3;
5
5
  /**
@@ -41418,12 +41418,26 @@ var OggOpusEncoder = class {
41418
41418
  * @param end true on the final chunk of the req_id; flushes the tail and emits the EOS page.
41419
41419
  * @returns Ogg page byte arrays to send over the socket, in order.
41420
41420
  */
41421
- encode(reqId, pcm, end) {
41421
+ /**
41422
+ * @param onFrame 可选:每编完一帧(20ms)回调一次,参数是这帧的 PCM 字节数。
41423
+ * 给延迟打点用——编码内部逐帧进行,但结果打成页一起返回;只按整批记时会把
41424
+ * 一次调用的全部耗时算成一段(实测 1068ms 一条),看不出编码速率是否稳定。
41425
+ * @param onPages 可选:**每凑满 1 秒音频就吐一批页**,让调用方边编边发。
41426
+ *
41427
+ * 不给这个回调时退化为老行为(全部编完一次性返回)。宿主一次性灌入整段音频时,
41428
+ * 老行为要等全部编完才发出第一个包——实测 12 秒音频编码 1105ms,首包上行就白等
41429
+ * 这 1105ms(起播 startup_buffering 1804ms 里大半是它)。编 1 秒发 1 秒后,
41430
+ * 首包在几十毫秒内就能上线。
41431
+ */
41432
+ encode(reqId, pcm, end, onFrame, onPages) {
41422
41433
  const pages = [];
41434
+ const emit = onPages ?? ((built) => {
41435
+ pages.push(...built);
41436
+ });
41423
41437
  if (reqId !== this.currentReqId) {
41424
41438
  this.currentReqId = reqId;
41425
41439
  this.pcmRemainder = /* @__PURE__ */ new Int16Array(0);
41426
- pages.push(...this.muxer.startStream(reqId));
41440
+ emit(this.muxer.startStream(reqId));
41427
41441
  }
41428
41442
  const incoming = this.bytesToInt16(pcm);
41429
41443
  let samples = incoming;
@@ -41433,18 +41447,29 @@ var OggOpusEncoder = class {
41433
41447
  samples.set(incoming, this.pcmRemainder.length);
41434
41448
  }
41435
41449
  const frameGranule = this.muxer.toGranule(this.frameSamples);
41436
- const packets = [];
41450
+ const FRAMES_PER_SECOND = Math.round(1e3 / FRAME_MS);
41451
+ let packets = [];
41437
41452
  let offset = 0;
41453
+ const flushPackets = () => {
41454
+ if (packets.length === 0) return;
41455
+ const built = this.muxer.addPackets(packets, frameGranule);
41456
+ packets = [];
41457
+ emit(built);
41458
+ };
41438
41459
  while (samples.length - offset >= this.frameSamples) {
41439
41460
  const frame = samples.subarray(offset, offset + this.frameSamples);
41440
41461
  offset += this.frameSamples;
41441
41462
  const packet = this.encodeFrame(frame);
41442
- if (packet) packets.push(packet);
41463
+ if (packet) {
41464
+ packets.push(packet);
41465
+ onFrame?.(frame.length * 2);
41466
+ }
41467
+ if (onPages && packets.length >= FRAMES_PER_SECOND) flushPackets();
41443
41468
  }
41444
- pages.push(...this.muxer.addPackets(packets, frameGranule));
41469
+ flushPackets();
41445
41470
  this.pcmRemainder = samples.slice(offset);
41446
41471
  if (end) {
41447
- pages.push(...this.flush());
41472
+ emit(this.flush());
41448
41473
  this.currentReqId = null;
41449
41474
  }
41450
41475
  return pages;
@@ -41522,9 +41547,15 @@ var OggOpusDecoder = class {
41522
41547
  * trailing page is buffered until the rest arrives.
41523
41548
  *
41524
41549
  * @param oggBytes raw Ogg stream bytes (any slice boundary).
41550
+ * @param onSegment 可选:每解出一个 packet 就回调一次那一段 PCM。
41551
+ *
41552
+ * 给延迟打点用。`tap_N` 的语义是「第 N 秒音频进入播放链路的时刻」,若只在
41553
+ * decode 返回后按整块累计,宿主一次性喂进整段 Ogg 时所有 tap 会挤在同一个
41554
+ * 时刻(实测 tap_1/2/4 三条同值),送入节奏这个信息就没了。解码本来就是逐包
41555
+ * 进行的,把分段暴露出来,打点才能落在真实的音频时间轴上。
41525
41556
  * @returns decoded PCM16 bytes, possibly empty.
41526
41557
  */
41527
- decode(oggBytes) {
41558
+ decode(oggBytes, onSegment) {
41528
41559
  const incoming = oggBytes instanceof Uint8Array ? oggBytes : new Uint8Array(oggBytes);
41529
41560
  let buf;
41530
41561
  if (this.carry.length > 0) {
@@ -41540,7 +41571,10 @@ var OggOpusDecoder = class {
41540
41571
  offset = page.nextOffset;
41541
41572
  for (const packet of page.packets) {
41542
41573
  const pcm = this.decodePacket(packet);
41543
- if (pcm.length > 0) pcmChunks.push(pcm);
41574
+ if (pcm.length > 0) {
41575
+ pcmChunks.push(pcm);
41576
+ onSegment?.(pcm);
41577
+ }
41544
41578
  }
41545
41579
  }
41546
41580
  this.carry = offset < buf.length ? buf.slice(offset) : /* @__PURE__ */ new Uint8Array(0);
@@ -1,4 +1,4 @@
1
- import { t as logger } from "./logger-fAU8oahT.js";
1
+ import { t as logger } from "./logger-BOpQ7u0w.js";
2
2
  //#region audio/OpusDecoderProxy.ts
3
3
  var OpusDecoderProxy = class OpusDecoderProxy {
4
4
  worker = null;
@@ -21,7 +21,7 @@ var OpusDecoderProxy = class OpusDecoderProxy {
21
21
  } catch (err) {
22
22
  logger.warn("[Opus][DecoderProxy] Worker unavailable, falling back to main-thread decode:", err instanceof Error ? err.message : String(err));
23
23
  }
24
- const { OggOpusDecoder } = await import("./OpusCodec-D9DcUV65.js").then((n) => n.n);
24
+ const { OggOpusDecoder } = await import("./OpusCodec-CXU7ysrU.js").then((n) => n.n);
25
25
  proxy.fallbackDecoder = new OggOpusDecoder();
26
26
  proxy.backend = "main-thread";
27
27
  return proxy;
@@ -32,7 +32,7 @@ var OpusDecoderProxy = class OpusDecoderProxy {
32
32
  try {
33
33
  worker = new Worker(new URL(
34
34
  /* @vite-ignore */
35
- "/assets/OpusDecoderWorker.worker-B7R93nev.js",
35
+ "/assets/OpusDecoderWorker.worker-C2T0afkj.js",
36
36
  "" + import.meta.url
37
37
  ), { type: "module" });
38
38
  } catch (err) {
@@ -56,7 +56,11 @@ var OpusDecoderProxy = class OpusDecoderProxy {
56
56
  return;
57
57
  }
58
58
  if (d.type === "pcm") {
59
- this.pending.get(d.seq)?.resolve(new Uint8Array(d.pcm));
59
+ this.pending.get(d.seq)?.resolve({
60
+ pcm: new Uint8Array(d.pcm),
61
+ segments: d.segments ?? [],
62
+ segmentTs: (d.segmentTs ?? []).map((ts) => ts - performance.timeOrigin)
63
+ });
60
64
  this.pending.delete(d.seq);
61
65
  return;
62
66
  }
@@ -103,8 +107,17 @@ var OpusDecoderProxy = class OpusDecoderProxy {
103
107
  });
104
108
  }
105
109
  if (this.fallbackDecoder) try {
106
- const pcm = this.fallbackDecoder.decode(ogg);
107
- return Promise.resolve(pcm);
110
+ const segments = [];
111
+ const segmentTs = [];
112
+ const pcm = this.fallbackDecoder.decode(ogg, (seg) => {
113
+ segments.push(seg.length);
114
+ segmentTs.push(performance.now());
115
+ });
116
+ return Promise.resolve({
117
+ pcm,
118
+ segments,
119
+ segmentTs
120
+ });
108
121
  } catch (err) {
109
122
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
110
123
  }
@@ -1,5 +1,22 @@
1
- import { t as logger } from "./logger-fAU8oahT.js";
1
+ import { n as __exportAll } from "./rolldown-runtime-B-1-B7_t.js";
2
+ import { t as logger } from "./logger-BOpQ7u0w.js";
2
3
  //#region audio/OpusEncoderProxy.ts
4
+ var OpusEncoderProxy_exports = /* @__PURE__ */ __exportAll({
5
+ OpusEncoderProxy: () => OpusEncoderProxy,
6
+ setOpusEncodeFrameSink: () => setOpusEncodeFrameSink
7
+ });
8
+ /**
9
+ * 逐帧编码时刻的接收钩子。由 `AnimationWebSocketClient` 装上,转交 trace 打点。
10
+ *
11
+ * 与解码那侧同理:编码在 Worker 里逐帧进行,结果打成页一起返回;只有把每帧的时刻
12
+ * 带回来,span 才能按音频秒数切,而不是把整批耗时算成一段。
13
+ * @internal
14
+ */
15
+ var onEncodeFrames = null;
16
+ /** @internal */
17
+ function setOpusEncodeFrameSink(fn) {
18
+ onEncodeFrames = fn;
19
+ }
3
20
  var OpusEncoderProxy = class OpusEncoderProxy {
4
21
  worker = null;
5
22
  fallbackEncoder = null;
@@ -26,7 +43,7 @@ var OpusEncoderProxy = class OpusEncoderProxy {
26
43
  } catch (err) {
27
44
  logger.warn("[Opus][Proxy] Worker unavailable, falling back to main-thread encode:", err instanceof Error ? err.message : String(err));
28
45
  }
29
- const { OggOpusEncoder } = await import("./OpusCodec-D9DcUV65.js").then((n) => n.n);
46
+ const { OggOpusEncoder } = await import("./OpusCodec-CXU7ysrU.js").then((n) => n.n);
30
47
  proxy.fallbackEncoder = new OggOpusEncoder(sampleRate, bitrate);
31
48
  proxy.backend = "main-thread";
32
49
  return proxy;
@@ -37,7 +54,7 @@ var OpusEncoderProxy = class OpusEncoderProxy {
37
54
  try {
38
55
  worker = new Worker(new URL(
39
56
  /* @vite-ignore */
40
- "/assets/OpusEncoderWorker.worker-BYWIV5xu.js",
57
+ "/assets/OpusEncoderWorker.worker-DLYO9vPK.js",
41
58
  "" + import.meta.url
42
59
  ), { type: "module" });
43
60
  } catch (err) {
@@ -61,6 +78,11 @@ var OpusEncoderProxy = class OpusEncoderProxy {
61
78
  return;
62
79
  }
63
80
  if (d.type === "pages") {
81
+ if (!d.last) {
82
+ this.pending.get(d.seq)?.onPages(d.pages);
83
+ return;
84
+ }
85
+ onEncodeFrames?.(d.frames ?? [], (d.frameTs ?? []).map((ts) => ts - performance.timeOrigin));
64
86
  this.pending.get(d.seq)?.resolve(d.pages);
65
87
  this.pending.delete(d.seq);
66
88
  return;
@@ -92,13 +114,19 @@ var OpusEncoderProxy = class OpusEncoderProxy {
92
114
  * worker path the main thread stays free while encoding runs; on the fallback
93
115
  * path this blocks the main thread (same as the legacy behavior).
94
116
  */
95
- encode(reqId, pcm, end) {
117
+ /**
118
+ * @param onPages 每凑满 1 秒音频回调一次那一批页,调用方应立刻发出去。
119
+ * 返回的 Promise 只带**收尾批**(通常为空)——所有页都从这个回调出去,等
120
+ * Promise 再发会退回「整段编完才发」,白等一秒以上(见 OpusCodec.encode)。
121
+ */
122
+ encode(reqId, pcm, end, onPages = () => {}) {
96
123
  if (this.worker) {
97
124
  const seq = this.seq++;
98
125
  return new Promise((resolve, reject) => {
99
126
  this.pending.set(seq, {
100
127
  resolve,
101
- reject
128
+ reject,
129
+ onPages
102
130
  });
103
131
  this.worker.postMessage({
104
132
  type: "encode",
@@ -110,7 +138,14 @@ var OpusEncoderProxy = class OpusEncoderProxy {
110
138
  });
111
139
  }
112
140
  if (this.fallbackEncoder) try {
113
- return Promise.resolve(this.fallbackEncoder.encode(reqId, pcm, end));
141
+ const frames = [];
142
+ const frameTs = [];
143
+ const pages = this.fallbackEncoder.encode(reqId, pcm, end, (pcmBytes) => {
144
+ frames.push(pcmBytes);
145
+ frameTs.push(performance.now());
146
+ }, onPages);
147
+ onEncodeFrames?.(frames, frameTs);
148
+ return Promise.resolve(pages);
114
149
  } catch (err) {
115
150
  return Promise.reject(err instanceof Error ? err : new Error(String(err)));
116
151
  }
@@ -132,4 +167,4 @@ var OpusEncoderProxy = class OpusEncoderProxy {
132
167
  }
133
168
  };
134
169
  //#endregion
135
- export { OpusEncoderProxy };
170
+ export { setOpusEncodeFrameSink as n, OpusEncoderProxy_exports as t };
@@ -1,5 +1,5 @@
1
- import { c as logEvent, t as logger } from "./logger-fAU8oahT.js";
2
- import { n as APP_CONFIG, t as errorToMessage } from "./error-utils-CqKOrHrY.js";
1
+ import { c as logEvent, t as logger } from "./logger-BOpQ7u0w.js";
2
+ import { n as APP_CONFIG, t as errorToMessage } from "./error-utils-BogEllAd.js";
3
3
  //#region audio/StreamingAudioPlayer.ts
4
4
  /**
5
5
  * Streaming Audio Player
@@ -1,5 +1,5 @@
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-D8rDVim7.js";
2
- import { t as AvatarSDK } from "./AvatarSDK-DR8dNylh.js";
1
+ import { bt as ErrorCode, c as logEvent, gt as AvatarError, h as hostOf, ht as generateTraceId, l as logMetric, pt as isDebugMode, t as logger, y as recordHttpClientDuration } from "./logger-YPooEAbA.js";
2
+ import { t as AvatarSDK } from "./AvatarSDK-C_Anluwp.js";
3
3
  //#region config/app-config.ts
4
4
  const GLOBAL_FLAME_CDN_BASE = "https://cdn.spatialwalk.cloud/public";
5
5
  const CN_FLAME_CDN_BASE = "https://cdn.spatialwalk.top/public";
@@ -273,20 +273,46 @@ function getCacheInfo(url, response) {
273
273
  };
274
274
  }
275
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
276
+ * 把整次角色加载的资产下载汇总成**一条** Transaction。
277
+ *
278
+ * 成败取「最差的一片」:任一片 transport error 即整次记 transport error;否则取
279
+ * 状态码最大的那个(4xx/5xx 会盖过 200)。这样后端按状态码切出的成功率,含义是
280
+ * 「这次加载的资产是否全部拿到」——与用户实际体验一致,一片挂了角色就出不来。
281
+ *
282
+ * duration 传整体耗时而非单片,与 `download_avatar_assets_latency` 同口径。
283
+ */
284
+ function recordAssetTransaction(outcomes, durationMs, operation = ASSET_OPERATION) {
285
+ const real = outcomes.filter((o) => !o.skipped);
286
+ if (real.length === 0) {
287
+ recordHttpClientDuration({
288
+ operation,
289
+ method: "GET",
290
+ durationMs,
291
+ statusCode: 200,
292
+ cacheHit: true
293
+ });
294
+ return;
295
+ }
296
+ recordHttpClientDuration({
297
+ operation,
298
+ method: "GET",
299
+ durationMs,
300
+ statusCode: real.some((o) => o.statusCode === void 0) ? void 0 : real.reduce((max, o) => Math.max(max, o.statusCode ?? 0), 0),
301
+ serverAddress: real[0].host,
302
+ cacheHit: false
303
+ });
304
+ }
305
+ /**
306
+ * Transaction 的 `operation` 取值。必须低基数:资产 URL 带 avatar id 与文件名,
307
+ * 直接当维度会让时间序列随角色数无限增长。要定位「哪个文件挂了」用
308
+ * `download_avatar_assets_failed` log,它带 resource/url/error。
287
309
  */
310
+ const ASSET_OPERATION = "/assets/character";
311
+ const TEMPLATE_OPERATION = "/assets/template";
288
312
  async function downloadResource(url, options) {
289
- const { signal, characterId, resourceType, maxRetries = 3 } = options || {};
313
+ const { signal, characterId, resourceType, maxRetries = 3, outcomes } = options || {};
314
+ const outcomeSlotIndex = outcomes ? outcomes.length : -1;
315
+ if (outcomes) outcomes.push({ host: hostOf(url) });
290
316
  if (signal?.aborted) throw new Error("Download cancelled");
291
317
  try {
292
318
  let cached = null;
@@ -305,6 +331,10 @@ async function downloadResource(url, options) {
305
331
  cacheInfo.cacheHit = true;
306
332
  cacheInfo.cacheType = "pwa";
307
333
  cacheInfo.pwaCacheSubtype = pwaCacheSubtype;
334
+ if (outcomes && outcomeSlotIndex >= 0) outcomes[outcomeSlotIndex] = {
335
+ host: "",
336
+ skipped: true
337
+ };
308
338
  return {
309
339
  data: cached,
310
340
  cacheInfo
@@ -313,8 +343,11 @@ async function downloadResource(url, options) {
313
343
  let lastError = null;
314
344
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
315
345
  if (signal?.aborted) throw new Error("Download cancelled");
346
+ const outcomeSlot = { host: hostOf(url) };
347
+ if (outcomes) outcomes[outcomeSlotIndex] = outcomeSlot;
316
348
  try {
317
349
  const response = await fetch(url, { signal });
350
+ outcomeSlot.statusCode = response.status;
318
351
  if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
319
352
  const arrayBuffer = await response.arrayBuffer();
320
353
  const contentLength = response.headers.get("content-length");
@@ -372,32 +405,41 @@ var AvatarDownloader = class {
372
405
  cache_hit: true,
373
406
  cache_type: "pwa"
374
407
  });
408
+ recordAssetTransaction([], duration, TEMPLATE_OPERATION);
375
409
  return { unifiedModel: cached };
376
410
  }
377
411
  const maxRetries = 3;
378
412
  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...`);
413
+ const templateOutcomes = [];
414
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
415
+ const outcomeSlot = { host: hostOf(url) };
416
+ templateOutcomes[0] = outcomeSlot;
417
+ try {
418
+ const response = await fetch(url);
419
+ outcomeSlot.statusCode = response.status;
420
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
421
+ let buffer;
422
+ if (APP_CONFIG.flame.unifiedModelPath.endsWith(".gz")) {
423
+ const decompressedStream = response.body.pipeThrough(new DecompressionStream("gzip"));
424
+ buffer = await new Response(decompressedStream).arrayBuffer();
425
+ } else buffer = await response.arrayBuffer();
426
+ logger.log(`✅ Unified template loaded (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB)`);
427
+ PwaCacheManager.putTemplateResource(url, buffer).catch((err) => {
428
+ logger.warn(`[loadUnifiedTemplate] Failed to cache:`, err);
429
+ });
430
+ logMetric("template_resources_load_measure", Date.now() - startTime, {
431
+ file_count: 1,
432
+ cache_hit: false,
433
+ cache_type: "none"
434
+ });
435
+ recordAssetTransaction(templateOutcomes, Date.now() - startTime, TEMPLATE_OPERATION);
436
+ return { unifiedModel: buffer };
437
+ } catch (err) {
438
+ lastError = err instanceof Error ? err : new Error(String(err));
439
+ if (attempt < maxRetries) logger.warn(`[loadUnifiedTemplate] Attempt ${attempt}/${maxRetries} failed, retrying...`);
440
+ }
400
441
  }
442
+ recordAssetTransaction(templateOutcomes, Date.now() - startTime, TEMPLATE_OPERATION);
401
443
  throw lastError || /* @__PURE__ */ new Error(`Failed to download unified template after ${maxRetries} attempts`);
402
444
  }
403
445
  /**
@@ -478,6 +520,8 @@ var AvatarDownloader = class {
478
520
  };
479
521
  const characterData = {};
480
522
  const cacheInfos = [];
523
+ /** 各片的 HTTP 结果,收尾时汇总成一条 Transaction(见 recordAssetTransaction)。 */
524
+ const httpOutcomes = [];
481
525
  const parallelStartTime = Date.now();
482
526
  const downloadPromises = filesToLoad.map(async ({ key, url, filename, optional }) => {
483
527
  updateProgress(filename, false);
@@ -485,7 +529,8 @@ var AvatarDownloader = class {
485
529
  const { data: arrayBuffer, cacheInfo } = await downloadResource(url, {
486
530
  signal,
487
531
  characterId: characterMeta.characterId ?? void 0,
488
- resourceType: "character"
532
+ resourceType: "character",
533
+ outcomes: httpOutcomes
489
534
  });
490
535
  if (key === "shape") characterData.shape = arrayBuffer;
491
536
  else if (key === "pointCloud") characterData.pointCloud = arrayBuffer;
@@ -523,7 +568,11 @@ var AvatarDownloader = class {
523
568
  };
524
569
  }
525
570
  });
526
- await Promise.all(downloadPromises);
571
+ try {
572
+ await Promise.all(downloadPromises);
573
+ } finally {
574
+ recordAssetTransaction(httpOutcomes, Date.now() - totalStartTime);
575
+ }
527
576
  const parallelDuration = Date.now() - parallelStartTime;
528
577
  const totalDuration = Date.now() - totalStartTime;
529
578
  if (!characterData.shape || !characterData.pointCloud) {
@@ -550,7 +599,11 @@ var AvatarDownloader = class {
550
599
  parallel_duration: parallelDuration,
551
600
  total_size: totalSize
552
601
  });
553
- return characterData;
602
+ return {
603
+ data: characterData,
604
+ cacheHit,
605
+ cacheType
606
+ };
554
607
  }
555
608
  /**
556
609
  * Preload all resources (template + character data + camera info + settings)
@@ -559,7 +612,7 @@ var AvatarDownloader = class {
559
612
  async preloadResources(characterMeta, options) {
560
613
  const { progressCallback = null, signal, useCompressedModel = false } = options || {};
561
614
  if (signal?.aborted) throw new Error("Preload cancelled");
562
- const [characterData, preloadCameraSettings] = await Promise.all([this.loadCharacterData(characterMeta, {
615
+ const [characterResult, preloadCameraSettings] = await Promise.all([this.loadCharacterData(characterMeta, {
563
616
  signal,
564
617
  useCompressedModel,
565
618
  progressCallback: (info) => {
@@ -570,9 +623,11 @@ var AvatarDownloader = class {
570
623
  }
571
624
  }), this.loadCameraSettings(characterMeta, { signal })]);
572
625
  return {
573
- characterData,
626
+ characterData: characterResult.data,
574
627
  preloadCameraSettings,
575
- characterSettings: characterMeta.characterSettings
628
+ characterSettings: characterMeta.characterSettings,
629
+ cacheHit: characterResult.cacheHit,
630
+ cacheType: characterResult.cacheType
576
631
  };
577
632
  }
578
633
  /**