@xmov/avatar 2.0.0-alpha.64 → 2.0.0-alpha.66

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.
@@ -99,6 +99,8 @@ export default class ResourceManager {
99
99
  options: TOptions;
100
100
  sdk: XmovAvatar;
101
101
  session_id?: string;
102
+ /** 单次 init 生命周期内的稳定 request_id,由 generateRequestId 生成;session_id 未返回时也能用于 stopSession 兜底销毁 */
103
+ session_request_id?: string;
102
104
  mouthShapeLib: any;
103
105
  offlineIdle: IOfflineIdle[];
104
106
  offlineCache: Map<string, {
@@ -1,3 +1,10 @@
1
+ /**
2
+ * 生成 request_id:用于 startSession / stopSession 的幂等与兜底销毁。
3
+ * 组成:md5(机器指纹 + 毫秒时间戳 + appId)
4
+ * 同一次 init → destroy 生命周期内始终不变,即使 session_id 尚未返回
5
+ * 也能用 request_id 让服务端销毁已占用的房间资源。
6
+ */
7
+ export declare function generateRequestId(appId: string): string;
1
8
  export interface Headers {
2
9
  [key: string]: string;
3
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmov/avatar",
3
- "version": "2.0.0-alpha.64",
3
+ "version": "2.0.0-alpha.66",
4
4
  "description": "魔珐数字人 JS-SDK",
5
5
  "type": "module",
6
6
  "source": "src/index.ts",
@@ -24,6 +24,7 @@ import '../proto/protobuf.min';
24
24
  import '../proto/face_data_pb';
25
25
 
26
26
  const proxyProtobuf = protobuf.roots.default;
27
+ const CLIENT_QUIT_ACK_TIMEOUT_MS = 3000;
27
28
 
28
29
  const pointNameObj = {
29
30
  'connect_sdk': '连接SDK',
@@ -63,6 +64,9 @@ interface ITtsaOptions {
63
64
  export default class Ttsa {
64
65
  private TAG = "[TTSA]";
65
66
  private ws: Socket;
67
+ private connectedOnce = false;
68
+ private quitRequested = false;
69
+ private quitPromise: Promise<void> | null = null;
66
70
  private room: string;
67
71
  private session_id: string;
68
72
  private token: string;
@@ -182,6 +186,11 @@ export default class Ttsa {
182
186
  });
183
187
 
184
188
  ws.on("connect", () => {
189
+ if (this.quitRequested) {
190
+ ws.disconnect();
191
+ return;
192
+ }
193
+ this.connectedOnce = true;
185
194
  // 连接成功时清理重连定时器
186
195
  this.clearReconnectTimer();
187
196
  this.initWsTimeoutTimer();
@@ -204,6 +213,9 @@ export default class Ttsa {
204
213
  }
205
214
  });
206
215
  ws.on("first_start_timestamp", (e: any) => {
216
+ if (this.quitRequested) {
217
+ return;
218
+ }
207
219
  (window as any).avatarSDKLogger.log(this.TAG, "first_start_timestamp", e);
208
220
  // 前端在发送first_start_timestamp后运行帧索引
209
221
  this.firstStartTimestamp(e.server_time);
@@ -342,7 +354,7 @@ export default class Ttsa {
342
354
  this.clearOldWsTimeoutTimer();
343
355
  // 停止客户端心跳
344
356
  this.stopHeartbeat();
345
- if(e === 'io client disconnect') {
357
+ if (this.quitRequested || e === 'io client disconnect') {
346
358
  return;
347
359
  }
348
360
  // 增加状态检查,避免重复处理断开事件
@@ -393,6 +405,10 @@ export default class Ttsa {
393
405
  }) as any;
394
406
  ws.on("client_quit", (e) => {
395
407
  (window as any).avatarSDKLogger.warn(this.TAG, "client_quit", "断开原因", e);
408
+ // A quit broadcast may precede its ACK. Let the local quit flow own teardown.
409
+ if (this.quitRequested) {
410
+ return;
411
+ }
396
412
  ws.disconnect();
397
413
  this.session_speak_req_id = 0;
398
414
  if (Object.keys(e).length) {
@@ -442,6 +458,9 @@ export default class Ttsa {
442
458
  };
443
459
  initWsTimeoutTimer = () => {
444
460
  this.clearOldWsTimeoutTimer();
461
+ if (this.quitRequested) {
462
+ return;
463
+ }
445
464
 
446
465
  this.wsConnectTimer = window.setTimeout(async () => {
447
466
 
@@ -658,6 +677,10 @@ export default class Ttsa {
658
677
  return this.ws.connected;
659
678
  }
660
679
 
680
+ get hasConnected(): boolean {
681
+ return this.connectedOnce;
682
+ }
683
+
661
684
  _setResumeInfoCallback(lastSessionId: string, _getResumeInfo: () => IResumeParams) {
662
685
  this._isResume = true
663
686
  this._lastSessionId = lastSessionId
@@ -682,6 +705,26 @@ export default class Ttsa {
682
705
  return `${this._uniqueSpeakId}-${this.session_id}`
683
706
  }
684
707
 
708
+ quit(stopReason: string, timeoutMs = CLIENT_QUIT_ACK_TIMEOUT_MS): Promise<void> {
709
+ if (this.quitPromise) {
710
+ return this.quitPromise;
711
+ }
712
+ this.quitRequested = true;
713
+ this.clearReconnectTimer();
714
+ this.clearOldWsTimeoutTimer();
715
+ this.stopHeartbeat();
716
+ this.quitPromise = this.sendClientQuit(stopReason, timeoutMs);
717
+ return this.quitPromise;
718
+ }
719
+
720
+ private async sendClientQuit(stopReason: string, timeoutMs: number): Promise<void> {
721
+ if (!this.ws.connected) {
722
+ return;
723
+ }
724
+ // ACK confirms receipt; no application-level ACK payload is assumed here.
725
+ await this.ws.timeout(timeoutMs).emitWithAck("client_quit", { stop_reason: stopReason });
726
+ }
727
+
685
728
  close() {
686
729
  // 使用 disconnect() 而不是 close(),disconnect() 会停止所有重连尝试
687
730
  if (this.ws) {
@@ -690,10 +733,7 @@ export default class Ttsa {
690
733
  this.clearOldWsTimeoutTimer();
691
734
  this.stopHeartbeat();
692
735
  this.session_speak_req_id = 0;
693
- if (this.reconnectTimer !== -1) {
694
- clearTimeout(this.reconnectTimer);
695
- this.reconnectTimer = -1;
696
- }
736
+ this.clearReconnectTimer();
697
737
  if (this.syncTimestampInterval) {
698
738
  clearInterval(this.syncTimestampInterval);
699
739
  this.syncTimestampInterval = 0; // 重置为初始值 0,保持状态统一
package/src/index.ts CHANGED
@@ -1424,9 +1424,7 @@ export default class XmovAvatar {
1424
1424
 
1425
1425
  // 薄客户端模式:通知 server 端销毁,然后清理本地资源
1426
1426
  if (this._isClientMode) {
1427
- this._apiForwarder?.destroy(stop_reason);
1428
- await this.destroyClient();
1429
- this.onStatusChange(AvatarStatus.close);
1427
+ await this.disconnectClientForDestroy(stop_reason);
1430
1428
  return;
1431
1429
  }
1432
1430
 
@@ -1441,6 +1439,30 @@ export default class XmovAvatar {
1441
1439
  return res
1442
1440
  }
1443
1441
 
1442
+ protected async disconnectClientForDestroy(stopReason: string): Promise<void> {
1443
+ this.replayData = null;
1444
+ try {
1445
+ if (this._isClientMode) {
1446
+ this._apiForwarder?.destroy(stopReason);
1447
+ } else if (!this.ttsa?.hasConnected) {
1448
+ // Initialization can own a session/request before TTSA first connects.
1449
+ // Mark an in-flight connection as quitting before waiting for DELETE.
1450
+ await this.ttsa?.quit(stopReason);
1451
+ await this.resourceManager.stopSession(stopReason);
1452
+ } else {
1453
+ this.ttsa.sendSdkPoint('close_session', {
1454
+ reason: stopReason,
1455
+ });
1456
+ await this.ttsa.quit(stopReason);
1457
+ }
1458
+ } catch (error) {
1459
+ (window as any).avatarSDKLogger.warn(this.TAG, "TTSA client_quit 未确认,继续释放本地资源", error);
1460
+ } finally {
1461
+ await this.destroyClient();
1462
+ this.onStatusChange(AvatarStatus.close);
1463
+ }
1464
+ }
1465
+
1444
1466
  idle() {
1445
1467
  if (this._isClientMode) { this._apiForwarder?.idle(); return; }
1446
1468
  if(this.enableClientInterrupt) {
@@ -1629,13 +1651,13 @@ export default class XmovAvatar {
1629
1651
  const e = Errors(params)
1630
1652
  if(params.code !== EErrorCode.RENDER_BODY_ERROR && params.code !== EErrorCode.RENDER_FACE_ERROR && params.code !== EErrorCode.BODY_DATA_EXPIRED) {
1631
1653
  this.options.onMessage(e)
1632
- }
1633
- // Sentry 错误上报(仅公有云构建时生效)
1634
- if (ENABLE_SENTRY && this.sentryReporter) {
1635
- try {
1636
- this.sentryReporter.captureError(e);
1637
- } catch (err) {
1638
- (window as any).avatarSDKLogger.warn(this.TAG, 'Sentry 上报失败', err);
1654
+ // Sentry 错误上报(仅公有云构建时生效)
1655
+ if (ENABLE_SENTRY && this.sentryReporter) {
1656
+ try {
1657
+ this.sentryReporter.captureError(e);
1658
+ } catch (err) {
1659
+ (window as any).avatarSDKLogger.warn(this.TAG, 'Sentry 上报失败', err);
1660
+ }
1639
1661
  }
1640
1662
  }
1641
1663
 
@@ -6,7 +6,7 @@ import {
6
6
  } from "../utils/DataInterface";
7
7
  import XmovAvatar from "../index";
8
8
  import { EErrorCode } from "../types/error";
9
- import { headersNeedSign } from "../utils/encodeToken";
9
+ import { headersNeedSign, generateRequestId } from "../utils/encodeToken";
10
10
  // import { isDEV } from "../utils";
11
11
  import CacheManager from './cache-manager';
12
12
 
@@ -121,6 +121,8 @@ export default class ResourceManager {
121
121
  options: TOptions;
122
122
  sdk: XmovAvatar;
123
123
  session_id?: string;
124
+ /** 单次 init 生命周期内的稳定 request_id,由 generateRequestId 生成;session_id 未返回时也能用于 stopSession 兜底销毁 */
125
+ session_request_id?: string;
124
126
  mouthShapeLib: any;
125
127
  offlineIdle: IOfflineIdle[] = []
126
128
  offlineCache = new Map<string, {
@@ -304,6 +306,7 @@ export default class ResourceManager {
304
306
  onDownloadProgress?.(this.progress);
305
307
  }, 100);
306
308
  // http 请求算 10 进度
309
+ this.session_request_id = generateRequestId(this.options.appId);
307
310
  const result = await this.startSession();
308
311
  const res = result as unknown as ISessionResponse;
309
312
  // 先记录 session_id,确保后续 stopSession 能正确释放房间
@@ -365,6 +368,7 @@ export default class ResourceManager {
365
368
 
366
369
 
367
370
  const data = {
371
+ request_id: this.session_request_id,
368
372
  tag: this.options.tag,
369
373
  gateway_type: this.options.gateway_type,
370
374
  ...(this.options.asr_id !== undefined ? { asr_id: this.options.asr_id } : {}),
@@ -404,12 +408,19 @@ export default class ResourceManager {
404
408
  async stopSession(stop_reason: string) {
405
409
  try {
406
410
  const url = `${this.options.gatewayServer}`;
407
- if (!this.session_id) {
411
+ // session_id 尚未返回时(如 init 未完成就 destroy),只要有 request_id 仍发请求,
412
+ // 让服务端用 request_id 匹配并销毁房间占用,避免 init 失败后的资源泄漏。
413
+ if (!this.session_id && !this.session_request_id) {
408
414
  return;
409
415
  }
410
- const data = {
411
- session_id: this.session_id,
416
+ const data: Record<string, any> = {
412
417
  stop_reason,
418
+ };
419
+ if (this.session_id) {
420
+ data.session_id = this.session_id;
421
+ }
422
+ if (this.session_request_id) {
423
+ data.request_id = this.session_request_id;
413
424
  }
414
425
  const { headers, data: signedData } = headersNeedSign(this.options.appId, this.options.appSecret, "DELETE", url, data);
415
426
  const response = await request(url, { method: "DELETE", data: signedData, headers: { ...headers, ...this.options.headers } });
@@ -908,5 +919,7 @@ export default class ResourceManager {
908
919
  this.bg = null;
909
920
  }
910
921
  this.progress = 0
922
+ this.session_id = undefined;
923
+ this.session_request_id = undefined;
911
924
  }
912
925
  }
@@ -1,9 +1,24 @@
1
1
  import md5 from 'blueimp-md5';
2
+ import { generateFingerprint } from './index';
2
3
 
3
4
  function encodeWithMd5(s: string): string {
4
5
  return md5(s);
5
6
  }
6
7
 
8
+ /**
9
+ * 生成 request_id:用于 startSession / stopSession 的幂等与兜底销毁。
10
+ * 组成:md5(机器指纹 + 毫秒时间戳 + appId)
11
+ * 同一次 init → destroy 生命周期内始终不变,即使 session_id 尚未返回
12
+ * 也能用 request_id 让服务端销毁已占用的房间资源。
13
+ */
14
+ export function generateRequestId(appId: string): string {
15
+ const fingerprint = (() => {
16
+ try { return generateFingerprint(); } catch { return 'unknown'; }
17
+ })();
18
+ const payload = `${fingerprint}|${Date.now()}|${appId}`;
19
+ return md5(payload);
20
+ }
21
+
7
22
  function escapeNonAsciiForPythonJson(value: string): string {
8
23
  return value.replace(/[^\u0000-\u007E]/g, character => {
9
24
  return `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`;