@xmov/avatar 2.0.0-beta.14 → 2.0.0-beta.16

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.
@@ -14,6 +14,7 @@ export declare function XMLRequest(options: {
14
14
  cache?: {
15
15
  enable: boolean;
16
16
  };
17
+ timeout?: number;
17
18
  onProgress: (progress: number, e: ProgressEvent) => void;
18
19
  }): Promise<[boolean, ArrayBuffer | string]>;
19
20
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmov/avatar",
3
- "version": "2.0.0-beta.14",
3
+ "version": "2.0.0-beta.16",
4
4
  "description": "魔珐数字人 JS-SDK",
5
5
  "type": "module",
6
6
  "source": "src/index.ts",
@@ -26,8 +26,10 @@
26
26
  "src/"
27
27
  ],
28
28
  "scripts": {
29
- "build": "microbundle --workers --define ENV=production,VERSION=$npm_package_version --external none --tsconfig tsconfig.json && pnpm run build:agent && node upload-sdk.js build",
29
+ "build": "pnpm run build:core && pnpm run build:agent && pnpm run build:e2e:cdn && pnpm run test:e2e:cdn && node upload-sdk.js build",
30
+ "build:core": "microbundle --workers --define ENV=production,VERSION=$npm_package_version --external none --tsconfig tsconfig.json",
30
31
  "build:agent": "microbundle --entry src/agent/index.ts --output dist/agent --format modern,esm,cjs,umd --external @xmov/avatar --alias ../index=@xmov/avatar --globals @xmov/avatar=XmovAvatar --name XingyunAvatarAgentModule --tsconfig tsconfig.agent.json && node scripts/build-agent-entry.mjs",
32
+ "build:e2e:cdn": "microbundle --entry src/agent/cdn-entry.ts --output cdn-dist/xmovAvatar_e2e.js --format iife --external none --workers --define ENV=production,VERSION=$npm_package_version --tsconfig tsconfig.agent.json --no-pkg-main --no-sourcemap",
31
33
  "sdk": "pnpm --filter @xmov/avatar run --parallel --no-bail \"/^sdk:.+$/\"",
32
34
  "sdk:core": "microbundle watch --workers --define ENV=development,VERSION=$npm_package_version --external none --tsconfig tsconfig.json",
33
35
  "sdk:agent": "node scripts/build-agent-entry.mjs && microbundle watch --entry src/agent/index.ts --output dist/agent --format modern,esm,cjs,umd --external @xmov/avatar --alias ../index=@xmov/avatar --globals @xmov/avatar=XmovAvatar --name XingyunAvatarAgentModule --tsconfig tsconfig.agent.json",
@@ -36,7 +38,8 @@
36
38
  "test:agent": "node scripts/run-agent-tests.mjs",
37
39
  "test:agent:exports": "pnpm run build:agent && node scripts/test-agent-package-exports.mjs",
38
40
  "test:agent:browser": "playwright test",
39
- "lite": "node replaceString.js && npm run build && node upload-sdk.js lite",
41
+ "test:e2e:cdn": "node scripts/test-e2e-cdn-build.mjs",
42
+ "lite": "node replaceString.js && pnpm run build:core && node upload-sdk.js lite",
40
43
  "proto": "pbjs -t static-module -w closure -o src/proto/face_data_pb.js src/proto/face_data.proto",
41
44
  "commitlint": "commitlint --edit",
42
45
  "prepare": "husky"
@@ -0,0 +1,15 @@
1
+ import XingyunAvatarAgent, {
2
+ AgentE2EClient,
3
+ DEFAULT_AGENT_AUDIO,
4
+ MicrophoneController,
5
+ } from "./index";
6
+
7
+ Object.assign(XingyunAvatarAgent, {
8
+ AgentE2EClient,
9
+ DEFAULT_AGENT_AUDIO,
10
+ MicrophoneController,
11
+ });
12
+
13
+ (globalThis as typeof globalThis & {
14
+ XingyunAvatarAgent: typeof XingyunAvatarAgent;
15
+ }).XingyunAvatarAgent = XingyunAvatarAgent;
@@ -840,6 +840,21 @@ export default class RenderScheduler {
840
840
  }])
841
841
  }
842
842
 
843
+ /**
844
+ * 重连成功恢复(含窗口内 socket.io 自动重连恢复、窗口外重开房间):
845
+ * 数据缓存在断网进入离线时已清空(_offlineMode/_offlineRun),此处仅恢复播放状态:
846
+ * - 标记帧号对齐:首个 BODY/AUDIO 数据到达时按 sf 重设帧时钟
847
+ * - 恢复音频播放
848
+ * - 解码器退出离线模式(重置 _offlineLastFrame、清空离线队列)
849
+ * 避免离线 idle 播放推进的帧号领先服务端续推数据导致人物卡死、音画不同步
850
+ */
851
+ markReconnectRecover() {
852
+ // 恢复音频播放
853
+ this.audioRenderer.resume();
854
+ // 解码器退出离线模式(重置 _offlineLastFrame、清空离线队列)
855
+ this.decoder._reload();
856
+ }
857
+
843
858
  _offlineMode() {
844
859
  this.dataCacheQueue._clearEvent();
845
860
  const currentFrame = this.getCurrentFrameIndex() ?? 0;
@@ -55,6 +55,7 @@ interface ITtsaOptions {
55
55
  enterOfflineMode: () => void;
56
56
  reStartSDK: () => void;
57
57
  reconnect_client_timeout: number;
58
+ reconnect_timeout: number;
58
59
  sendVoiceEnd: () => void;
59
60
  getCurrentFrameIndex: () => number;
60
61
  }
@@ -77,7 +78,9 @@ export default class Ttsa {
77
78
  private enterOfflineMode: () => void;
78
79
  private reStartSDK: () => void;
79
80
  private reconnect_client_timeout: number;
80
- private reconnectTimer = -1;
81
+ private reconnect_timeout: number;
82
+ private reconnectTimer = -1; // 重开房间定时器(reconnect_client_timeout 后 stopSession + reStartSDK)
83
+ private socketReconnectTimer = -1; // 停止 socket.io 自动重连定时器(reconnect_timeout 后旧房间已失效)
81
84
  private wsConnectTimer = 0;
82
85
  private enterOfflineFlag = false;
83
86
  private sendVoiceEnd: () => void;
@@ -94,13 +97,26 @@ export default class Ttsa {
94
97
  private readonly HEARTBEAT_INTERVAL = 2000;
95
98
 
96
99
  /**
97
- * 清除WebSocket重连定时器
100
+ * 清除WebSocket重连定时器(含停止 socket.io 自动重连的定时器)
98
101
  */
99
102
  public clearReconnectTimer(): void {
100
103
  if (this.reconnectTimer !== -1) {
101
104
  clearTimeout(this.reconnectTimer);
102
105
  this.reconnectTimer = -1;
103
106
  }
107
+ // 一并清理停止 socket.io 自动重连的定时器:网络恢复后交由 socket.io 自行重连,
108
+ // 不再强制断开(下次 disconnect 会重新按窗口设置)
109
+ this.clearSocketReconnectTimer();
110
+ }
111
+
112
+ /**
113
+ * 清除停止 socket.io 自动重连的定时器
114
+ */
115
+ private clearSocketReconnectTimer(): void {
116
+ if (this.socketReconnectTimer !== -1) {
117
+ clearTimeout(this.socketReconnectTimer);
118
+ this.socketReconnectTimer = -1;
119
+ }
104
120
  }
105
121
 
106
122
  /**
@@ -146,6 +162,7 @@ export default class Ttsa {
146
162
  this.enterOfflineMode = options.enterOfflineMode;
147
163
  this.reStartSDK = options.reStartSDK;
148
164
  this.reconnect_client_timeout = options.reconnect_client_timeout;
165
+ this.reconnect_timeout = options.reconnect_timeout;
149
166
  this.sendVoiceEnd = options.sendVoiceEnd;
150
167
  this.getCurrentFrameIndex = options.getCurrentFrameIndex;
151
168
 
@@ -154,6 +171,9 @@ export default class Ttsa {
154
171
  token: this.token,
155
172
  },
156
173
  transports: ['websocket'],
174
+ // 自动重连不限制次数:在 reconnect_timeout(服务端断开旧房间的窗口)内持续尝试,
175
+ // 失效房间必然重连失败;超过窗口由 disconnect 处理器中的停止定时器断开,
176
+ // 超过 reconnect_client_timeout 由重开房间定时器兜底(stopSession + reStartSDK)
157
177
  reconnection: true,
158
178
  reconnectionAttempts: Infinity,
159
179
  reconnectionDelay: 1000,
@@ -167,17 +187,21 @@ export default class Ttsa {
167
187
  this.initWsTimeoutTimer();
168
188
  // 启动客户端心跳
169
189
  this.startHeartbeat();
190
+ // 重置离线处理标志:重连成功后允许下次断网正常进入离线/重连流程
191
+ this.enterOfflineFlag = false;
170
192
  (window as any).performanceTracker.markEnd(performanceConstant.ttsa_connect);
171
193
  const payload = {
172
194
  room: this.room,
173
195
  client_type: "web",
174
196
  invisible_mode: this.sdk.getPendingInvisibleMode(),
175
197
  };
176
- // TODO: 临时处理,待后端修复
177
- if(this.sdk.getStatus()=== AvatarStatus.offline) {
178
- this.sdk.onStatusChange(AvatarStatus.online);
179
- }
180
198
  this.send("enter_room", payload);
199
+ // 窗口内自动重连恢复:服务端可能不重发 first_start_timestamp,
200
+ // 若仅依赖 reloadSuccess 补发 online/恢复播放,状态会卡在离线导致重连后卡死。
201
+ // 因此重连成功即恢复(仅初始化完成且此前已离线时;初次启动 status 为 -1 不受影响)
202
+ if (this.sdk.getStatus() === AvatarStatus.offline && this.sdk.getIsInitialized()) {
203
+ this.reloadSuccess();
204
+ }
181
205
  });
182
206
  ws.on("first_start_timestamp", (e: any) => {
183
207
  (window as any).avatarSDKLogger.log(this.TAG, "first_start_timestamp", e);
@@ -339,11 +363,23 @@ export default class Ttsa {
339
363
  // 无网断开则进入离线模式
340
364
  this.enterOfflineMode();
341
365
  }
342
- // 发起定时器,getReconnectClientTimeout时间内重连socket,超过时间则发起reStartSession
343
- // 只有在网络断开时才设置重连定时器,网络恢复时由NetworkMonitor处理
344
- if (!navigator.onLine) {
345
- // 清除可能存在的旧定时器
346
- this.clearReconnectTimer();
366
+ // 重连窗口策略(不再依赖 navigator.onLine,socket 断开即启动):
367
+ // ① socketReconnectTimer(reconnect_timeout):窗口内 socket.io 自动重连;
368
+ // 服务端会在 reconnect_timeout 后断开旧房间,因此窗口结束即停止无谓的自动重连
369
+ // ② reconnectTimer(reconnect_client_timeout):仍未能重连则停止旧连接并重开房间
370
+ // 均仅首次设置,避免 socket.io 重连失败反复触发 disconnect 重置定时器导致永不执行;
371
+ // 重连成功时 connect 处理器、网络恢复时 NetworkMonitor -> clearReconnectTimer 都会取消
372
+ if (this.socketReconnectTimer === -1 && this.reconnect_timeout > 0) {
373
+ this.socketReconnectTimer = window.setTimeout(() => {
374
+ try {
375
+ ws.disconnect();
376
+ } catch (error) {
377
+ (window as any).avatarSDKLogger.warn(this.TAG, "disconnect error", error);
378
+ }
379
+ this.socketReconnectTimer = -1;
380
+ }, this.reconnect_timeout * 1000);
381
+ }
382
+ if (this.reconnectTimer === -1) {
347
383
  this.reconnectTimer = window.setTimeout(async () => {
348
384
  try {
349
385
  ws.disconnect();
package/src/index.ts CHANGED
@@ -104,7 +104,6 @@ export default class XmovAvatar {
104
104
  private pcmCaptureMode: 'webcodec' | 'mediarecorder' | 'none' = 'none';
105
105
  private pcmEncodeId: number = 0; // 用于 reset 竞态检测,递增标识当前编码会话
106
106
 
107
- private reconnectDebounceTimer = -1; // 重连防抖定时器
108
107
  private networkInfo: any = {
109
108
  rtt: 0,
110
109
  downlink: 0.0,
@@ -200,6 +199,14 @@ export default class XmovAvatar {
200
199
  return this.status
201
200
  }
202
201
 
202
+ /**
203
+ * 初始化是否完成(progress === 100)
204
+ * 供 TTSA 重连恢复时判断是否可提前恢复在线状态
205
+ */
206
+ public getIsInitialized() {
207
+ return this.isInitialized
208
+ }
209
+
203
210
  public get businessENV() {
204
211
  return this._env
205
212
  }
@@ -294,6 +301,10 @@ export default class XmovAvatar {
294
301
  // 标记初始化完成
295
302
  this.isInitialized = progress === 100;
296
303
  if(progress === 100) {
304
+ // 初始化完成:若 start 时因未初始化完成而未上线(状态守卫),此处补发 online
305
+ if (!this.pendingInvisibleMode && this.status !== AvatarStatus.online && this.status !== AvatarStatus.invisible) {
306
+ this.onStatusChange(AvatarStatus.online);
307
+ }
297
308
  this.resourceManager._setOfflineCache();
298
309
  this.ttsa?.sendSdkPoint('loadvideo_decode_time', {
299
310
  timestamp: Date.now(),
@@ -1103,6 +1114,7 @@ export default class XmovAvatar {
1103
1114
  token: sessionInfo.token,
1104
1115
  framedata_proto_version: this.resourceManager.getConfig().framedata_proto_version,
1105
1116
  reconnect_client_timeout: sessionInfo?.reconnect_client_timeout || 0,
1117
+ reconnect_timeout: sessionInfo?.reconnect_timeout || 0,
1106
1118
  appInfo: {
1107
1119
  ...this.resourceManager.getAppInfo(),
1108
1120
  env: this._env
@@ -1111,7 +1123,7 @@ export default class XmovAvatar {
1111
1123
  this.ttsa?.sendSdkPoint('connect_sdk', {
1112
1124
  timestamp: this.startConnectTime,
1113
1125
  });
1114
- this.onDownloadProgress?.(85);
1126
+ // 不再强行上报 85:进度以真实资源下载为准,避免重连时 UI 进度被错误拉低
1115
1127
  this.ttsa?.sendSdkPoint('connect_sdk_success', {
1116
1128
  timestamp: this.connectSuccessTime,
1117
1129
  });
@@ -1165,16 +1177,18 @@ export default class XmovAvatar {
1165
1177
  }
1166
1178
 
1167
1179
  protected start() {
1168
- this.renderScheduler.render();
1169
- this.ttsa?.start();
1170
-
1171
- // 如果初始化时设置了隐身模式,则立即进入隐身状态
1180
+ // 先发布在线状态,再启动渲染:
1181
+ // 避免先 render() 触发 rendering -> visible(5) 后再发 online(0),造成 0/5 状态横跳
1172
1182
  if (this.pendingInvisibleMode) {
1173
1183
  this.pendingInvisibleMode = false;
1174
1184
  this.setInvisibleMode()
1175
- } else {
1185
+ } else if (this.isInitialized) {
1186
+ // 状态守卫:仅在初始化完成(progress === 100)时上线,
1187
+ // 避免重连重建房间过程中(资源未下载完)反复触发 online -> visible 状态横跳
1176
1188
  this.onStatusChange(AvatarStatus.online);
1177
1189
  }
1190
+ this.renderScheduler.render();
1191
+ this.ttsa?.start();
1178
1192
 
1179
1193
  }
1180
1194
 
@@ -1221,7 +1235,14 @@ export default class XmovAvatar {
1221
1235
  }
1222
1236
 
1223
1237
  reloadSuccess() {
1224
- this.onStatusChange(AvatarStatus.online);
1238
+ // 重连成功恢复:恢复音频、重置解码器离线状态、清空旧数据缓存,并按首个数据 sf 对齐帧号,
1239
+ // 解决窗口内自动重连恢复后人物卡死(帧号错位数据被过滤)、无声音/音画不同步的问题
1240
+ this.renderScheduler?.markReconnectRecover();
1241
+ // 状态守卫:仅初始化完成时上线;未完成时由 onDownloadProgress(100) 补发,
1242
+ // 避免重连重建房间过程中初始化未完成却提前进入 online
1243
+ if (this.isInitialized) {
1244
+ this.onStatusChange(AvatarStatus.online);
1245
+ }
1225
1246
  setTimeout(() => {
1226
1247
  window.clearInterval(this._offlineTimer)
1227
1248
  }, 1000)
@@ -1636,28 +1657,13 @@ export default class XmovAvatar {
1636
1657
  }
1637
1658
 
1638
1659
  private triggerReconnect() {
1639
- // 如果已经在重连中,直接返回,避免重复触发
1640
- if (this.isRetrying) {
1641
- (window as any).avatarSDKLogger.warn(this.TAG, "正在重连中,跳过本次触发");
1642
- return;
1643
- }
1644
-
1645
- if (this.reconnectDebounceTimer !== -1) {
1646
- clearTimeout(this.reconnectDebounceTimer);
1647
- this.reconnectDebounceTimer = -1;
1648
- }
1649
- if (this.retryTimer !== -1) {
1650
- clearTimeout(this.retryTimer);
1651
- this.retryTimer = -1;
1652
- }
1653
-
1654
- this.reconnectDebounceTimer = window.setTimeout(() => {
1655
- this.isRetrying = false;
1656
- this.retryCount = 1;
1657
- this.retryRound = 1;
1658
- this.reStartSession();
1659
- this.reconnectDebounceTimer = -1;
1660
- }, 100);
1660
+ // 网络恢复触发:无论是否正在退避重试,都取消挂起的重试定时器并立即重连(重开房间),
1661
+ // 避免网络恢复后仍需等待当前退避窗口(最长 32s)才重连
1662
+ this.clearAllRetryTimers();
1663
+ this.isRetrying = false;
1664
+ this.retryCount = 1;
1665
+ this.retryRound = 1;
1666
+ this.reStartSession();
1661
1667
  }
1662
1668
 
1663
1669
  reStartSession() {
@@ -1683,8 +1689,9 @@ export default class XmovAvatar {
1683
1689
  delay = Math.pow(2, this.retryCount) * 1000;
1684
1690
  }
1685
1691
 
1686
- // 使用setTimeout代替递归,避免调用栈溢出
1687
- setTimeout(async () => {
1692
+ // 使用setTimeout代替递归,避免调用栈溢出;记录句柄以便网络恢复时取消挂起的退避重试
1693
+ this.retryTimer = window.setTimeout(async () => {
1694
+ this.retryTimer = -1;
1688
1695
  if (!this.isRetrying || this.destroyed) {
1689
1696
  return;
1690
1697
  }
@@ -1759,12 +1766,7 @@ export default class XmovAvatar {
1759
1766
  }, delay);
1760
1767
  }
1761
1768
  private clearAllRetryTimers() {
1762
- // 清理重连防抖定时器
1763
- if (this.reconnectDebounceTimer !== -1) {
1764
- clearTimeout(this.reconnectDebounceTimer);
1765
- this.reconnectDebounceTimer = -1;
1766
- }
1767
- // 清理重试定时器
1769
+ // 清理重试定时器(含退避重试挂起的定时器)
1768
1770
  if (this.retryTimer !== -1) {
1769
1771
  clearTimeout(this.retryTimer);
1770
1772
  this.retryTimer = -1;
@@ -70,20 +70,32 @@ export function XMLRequest(options: {
70
70
  cache?: {
71
71
  enable: boolean
72
72
  }
73
+ timeout?: number
73
74
  onProgress: (progress: number, e: ProgressEvent) => void
74
75
  }): Promise<[boolean, ArrayBuffer | string]> {
75
76
  return new Promise((resolve, reject) => {
76
77
  const xhr = new XMLHttpRequest();
77
78
  xhr.open('GET', options.url);
78
79
  xhr.responseType = 'arraybuffer';
80
+ // 超时兜底:断网/请求挂起时不再永久等待(默认 60s,资源下载可能超时失败)
81
+ xhr.timeout = options.timeout ?? 60000;
79
82
  xhr.onload = function() {
80
83
  if (xhr.readyState === 4 && xhr.status === 200) {
81
84
  const arrayBuffer = xhr.response;
82
85
  resolve([false, arrayBuffer]);
83
- } else if (xhr.status !== 200) {
84
- reject([true, xhr.statusText]);
86
+ } else {
87
+ reject([true, `HTTP ${xhr.status} ${xhr.statusText}`]);
85
88
  }
86
89
  };
90
+ xhr.onerror = function() {
91
+ reject([true, 'network error']);
92
+ };
93
+ xhr.ontimeout = function() {
94
+ reject([true, `timeout after ${xhr.timeout}ms`]);
95
+ };
96
+ xhr.onabort = function() {
97
+ reject([true, 'aborted']);
98
+ };
87
99
  xhr.onprogress = (e) => {
88
100
  if (e.lengthComputable) {
89
101
  const percent = Math.round((e.loaded / e.total) * 100);