@xmov/avatar 2.0.0-alpha.38

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 (160) hide show
  1. package/README.md +139 -0
  2. package/dist/agent/__tests__/agent.test.d.ts +1 -0
  3. package/dist/agent/audio-debug.d.ts +3 -0
  4. package/dist/agent/audio-uplink.d.ts +62 -0
  5. package/dist/agent/avatar.cjs +2 -0
  6. package/dist/agent/avatar.cjs.map +1 -0
  7. package/dist/agent/avatar.modern.js +2 -0
  8. package/dist/agent/avatar.modern.js.map +1 -0
  9. package/dist/agent/avatar.module.js +2 -0
  10. package/dist/agent/avatar.module.js.map +1 -0
  11. package/dist/agent/avatar.umd.js +2 -0
  12. package/dist/agent/avatar.umd.js.map +1 -0
  13. package/dist/agent/e2e-client.d.ts +32 -0
  14. package/dist/agent/fixed-audio-track.d.ts +48 -0
  15. package/dist/agent/index.cjs +5 -0
  16. package/dist/agent/index.d.ts +104 -0
  17. package/dist/agent/index.umd.js +10 -0
  18. package/dist/agent/microphone.d.ts +49 -0
  19. package/dist/agent/types.d.ts +153 -0
  20. package/dist/baseRender/AudioRenderer.d.ts +46 -0
  21. package/dist/baseRender/AudioWorklet.d.ts +78 -0
  22. package/dist/baseRender/AvatarRenderer.d.ts +226 -0
  23. package/dist/baseRender/MSEAudioPlayer.d.ts +51 -0
  24. package/dist/baseRender/UIRenderer.d.ts +39 -0
  25. package/dist/baseRender/pcm-audio-processor.d.ts +63 -0
  26. package/dist/control/APIForwarder.d.ts +23 -0
  27. package/dist/control/DataCacheQueue.d.ts +69 -0
  28. package/dist/control/EventDispatcher.d.ts +35 -0
  29. package/dist/control/RenderScheduler.d.ts +162 -0
  30. package/dist/control/SaveAndDownload.d.ts +14 -0
  31. package/dist/control/StreamingClient.d.ts +33 -0
  32. package/dist/control/VideoPlayer.d.ts +47 -0
  33. package/dist/control/ttsa.d.ts +111 -0
  34. package/dist/encoding/media-recorder-encoder.d.ts +33 -0
  35. package/dist/encoding/pcm-webm-encoder.d.ts +13 -0
  36. package/dist/encoding/webcodec-opus-encoder.d.ts +41 -0
  37. package/dist/encoding/webm-muxer.d.ts +90 -0
  38. package/dist/index.cjs +2 -0
  39. package/dist/index.cjs.map +1 -0
  40. package/dist/index.d.ts +231 -0
  41. package/dist/index.modern.js +2 -0
  42. package/dist/index.modern.js.map +1 -0
  43. package/dist/index.module.js +2 -0
  44. package/dist/index.module.js.map +1 -0
  45. package/dist/index.umd.js +2 -0
  46. package/dist/index.umd.js.map +1 -0
  47. package/dist/modules/Composition.d.ts +28 -0
  48. package/dist/modules/ResourceManager.d.ts +295 -0
  49. package/dist/modules/TrackRenderer/base-track.d.ts +4 -0
  50. package/dist/modules/TrackRenderer/index.d.ts +12 -0
  51. package/dist/modules/TrackRenderer/render-implements.d.ts +19 -0
  52. package/dist/modules/TrackRenderer/track-pic.d.ts +8 -0
  53. package/dist/modules/TrackRenderer/track-subtitle.d.ts +8 -0
  54. package/dist/modules/cache-manager.d.ts +5 -0
  55. package/dist/modules/decoder.d.ts +84 -0
  56. package/dist/modules/error-handle.d.ts +17 -0
  57. package/dist/modules/network.d.ts +21 -0
  58. package/dist/proto/face_data_pb.d.ts +0 -0
  59. package/dist/types/capability.d.ts +15 -0
  60. package/dist/types/error.d.ts +70 -0
  61. package/dist/types/event.d.ts +25 -0
  62. package/dist/types/frame-data.d.ts +107 -0
  63. package/dist/types/index.d.ts +187 -0
  64. package/dist/types/render.d.ts +18 -0
  65. package/dist/utils/DataInterface.d.ts +113 -0
  66. package/dist/utils/GLDevice.d.ts +46 -0
  67. package/dist/utils/GLPipeline.d.ts +84 -0
  68. package/dist/utils/GLPipelineDebugTools.d.ts +5 -0
  69. package/dist/utils/Math.d.ts +21 -0
  70. package/dist/utils/__tests__/capability-checker.test.d.ts +1 -0
  71. package/dist/utils/audio-session.d.ts +11 -0
  72. package/dist/utils/audio.d.ts +1 -0
  73. package/dist/utils/capability-checker.d.ts +23 -0
  74. package/dist/utils/encodeToken.d.ts +7 -0
  75. package/dist/utils/face.d.ts +1 -0
  76. package/dist/utils/float32-decoder.d.ts +26 -0
  77. package/dist/utils/fpsTracker.d.ts +27 -0
  78. package/dist/utils/index.d.ts +22 -0
  79. package/dist/utils/logger.d.ts +9 -0
  80. package/dist/utils/media-recorder-timestamp.d.ts +15 -0
  81. package/dist/utils/pcmAnalyzer.d.ts +40 -0
  82. package/dist/utils/perfermance.d.ts +15 -0
  83. package/dist/utils/request.d.ts +19 -0
  84. package/dist/utils/requestAnimateFrames.d.ts +31 -0
  85. package/dist/utils/time.d.ts +9 -0
  86. package/dist/view/DebugOverlay.d.ts +54 -0
  87. package/dist/worker/streaming-video.d.ts +1 -0
  88. package/package.json +82 -0
  89. package/src/agent/__tests__/agent.test.ts +3787 -0
  90. package/src/agent/audio-debug.ts +36 -0
  91. package/src/agent/audio-uplink.ts +392 -0
  92. package/src/agent/e2e-client.ts +215 -0
  93. package/src/agent/fixed-audio-track.ts +547 -0
  94. package/src/agent/index.ts +1393 -0
  95. package/src/agent/microphone.ts +534 -0
  96. package/src/agent/types.ts +197 -0
  97. package/src/baseRender/AudioRenderer.ts +317 -0
  98. package/src/baseRender/AudioWorklet.ts +776 -0
  99. package/src/baseRender/AvatarRenderer.ts +1559 -0
  100. package/src/baseRender/MSEAudioPlayer.ts +243 -0
  101. package/src/baseRender/UIRenderer.ts +195 -0
  102. package/src/baseRender/pcm-audio-processor.js +267 -0
  103. package/src/control/APIForwarder.ts +67 -0
  104. package/src/control/DataCacheQueue.ts +372 -0
  105. package/src/control/EventDispatcher.ts +106 -0
  106. package/src/control/RenderScheduler.ts +945 -0
  107. package/src/control/SaveAndDownload.js +77 -0
  108. package/src/control/StreamingClient.ts +138 -0
  109. package/src/control/VideoPlayer.ts +306 -0
  110. package/src/control/ttsa.ts +676 -0
  111. package/src/encoding/media-recorder-encoder.ts +175 -0
  112. package/src/encoding/pcm-webm-encoder.ts +72 -0
  113. package/src/encoding/webcodec-opus-encoder.ts +258 -0
  114. package/src/encoding/webm-muxer.ts +337 -0
  115. package/src/global.d.ts +105 -0
  116. package/src/index.ts +1920 -0
  117. package/src/modules/Composition.ts +79 -0
  118. package/src/modules/ResourceManager.ts +899 -0
  119. package/src/modules/TrackRenderer/base-track.ts +7 -0
  120. package/src/modules/TrackRenderer/index.ts +40 -0
  121. package/src/modules/TrackRenderer/render-implements.ts +269 -0
  122. package/src/modules/TrackRenderer/track-pic.ts +19 -0
  123. package/src/modules/TrackRenderer/track-subtitle.ts +16 -0
  124. package/src/modules/cache-manager.ts +10 -0
  125. package/src/modules/decoder.ts +515 -0
  126. package/src/modules/error-handle.ts +20 -0
  127. package/src/modules/network.ts +71 -0
  128. package/src/proto/face_data.proto +45 -0
  129. package/src/proto/face_data_pb.js +1297 -0
  130. package/src/proto/protobuf.min.js +8 -0
  131. package/src/types/capability.ts +23 -0
  132. package/src/types/error.ts +211 -0
  133. package/src/types/event.ts +38 -0
  134. package/src/types/frame-data.ts +121 -0
  135. package/src/types/index.ts +207 -0
  136. package/src/types/render.ts +18 -0
  137. package/src/utils/DataInterface.ts +746 -0
  138. package/src/utils/GLDevice.ts +286 -0
  139. package/src/utils/GLPipeline.ts +1293 -0
  140. package/src/utils/GLPipelineDebugTools.ts +174 -0
  141. package/src/utils/Math.ts +266 -0
  142. package/src/utils/__tests__/capability-checker.test.ts +159 -0
  143. package/src/utils/audio-session.ts +144 -0
  144. package/src/utils/audio.ts +44 -0
  145. package/src/utils/blueimp-md5.d.ts +3 -0
  146. package/src/utils/capability-checker.ts +379 -0
  147. package/src/utils/encodeToken.ts +100 -0
  148. package/src/utils/face.ts +29 -0
  149. package/src/utils/float32-decoder.js +130 -0
  150. package/src/utils/fpsTracker.ts +100 -0
  151. package/src/utils/index.ts +182 -0
  152. package/src/utils/logger.js +36 -0
  153. package/src/utils/media-recorder-timestamp.ts +79 -0
  154. package/src/utils/pcmAnalyzer.ts +154 -0
  155. package/src/utils/perfermance.ts +141 -0
  156. package/src/utils/request.ts +95 -0
  157. package/src/utils/requestAnimateFrames.ts +195 -0
  158. package/src/utils/time.ts +32 -0
  159. package/src/view/DebugOverlay.ts +404 -0
  160. package/src/worker/streaming-video.ts +4647 -0
@@ -0,0 +1,1559 @@
1
+ /**
2
+ * 3D 渲染(身体+表情)
3
+ */
4
+ import ResourceManager from "../modules/ResourceManager";
5
+ import { DataCacheQueue, IBodyFrame } from "../control/DataCacheQueue";
6
+ import { GLDevice } from "../utils/GLDevice";
7
+ import { GLPipeline, GLPipelineCharData } from "../utils/GLPipeline";
8
+ import { performanceConstant } from "../utils/perfermance";
9
+ import { EErrorCode } from "../types/error";
10
+ import DefaultWidgetRenderer from "../modules/TrackRenderer/render-implements";
11
+ import { Layout, RenderState, WalkConfig } from "../types";
12
+ import { IBRAnimationFrameData_NN } from "../utils/DataInterface";
13
+ import SaveAndDownload from "../control/SaveAndDownload";
14
+ import { getStyleStr, updateCanvasXOffset, isharmony } from "../utils";
15
+ import { IRawFaceFrameData } from "types/frame-data";
16
+
17
+ type Option = {
18
+ dataCacheQueue: DataCacheQueue;
19
+ resourceManager: ResourceManager;
20
+ saveAndDownload: SaveAndDownload;
21
+ forceSyncDecoder: () => void;
22
+ reportMessage: (message: {
23
+ code: EErrorCode;
24
+ message: string;
25
+ e?: object;
26
+ }) => void;
27
+ onDownloadProgress: (progress: number) => void;
28
+ onStateChange: (state: string) => void;
29
+ onRenderChange: (state: RenderState) => void;
30
+ sendVideoInfo: (info: { name: string; body_id: number; id: number }) => void;
31
+ onError: (error: any) => void;
32
+ sendSdkPoint: (point: string, data: any) => void;
33
+ };
34
+ export default class AvatarRender {
35
+ private TAG = "[AvatarRender]";
36
+ options: Option;
37
+ canvas = document.createElement("canvas");
38
+ device: GLDevice | null;
39
+ pipeline: GLPipeline | null;
40
+ currentBodyFrame: IBodyFrame | null = null;
41
+ lastFaceFrame: IRawFaceFrameData | null = null;
42
+ private isInit = false;
43
+ private isFirstRender = true;
44
+ private lastFrameState: string = "";
45
+ private lastRenderState: RenderState = RenderState.init;
46
+ private onDownloadProgress: (progress: number) => void;
47
+ private onStateChange?: (state: string) => void = () => {};
48
+ private onRenderChange?: (state: RenderState) => void = () => {};
49
+ private sendVideoInfo: (info: {
50
+ name: string;
51
+ body_id: number;
52
+ id: number;
53
+ }) => void = () => {};
54
+ private lostHandler: (event: Event) => void = () => {};
55
+ private restoreHandler: (event: Event) => void = () => {};
56
+ private avatarCanvasVisible: boolean = true;
57
+ private lastRealFaceFrameData: any = null; // 上一帧渲染的实时数据
58
+ private lastRealFaceFrame: number = -1; // 上一次实时表情数据帧号
59
+ private lastWeight: number = 0.0;
60
+
61
+ private lastFrameIndex = -1;
62
+ private saveAndDownload: SaveAndDownload;
63
+ private canvasOffsetX = -1;
64
+ private baseWalkOffset = 0; // 行走偏移基准值,setWalkConfig 时记录,用于差值补偿
65
+ private lastBodyOffset = 0; // 最近一次 body 帧的世界坐标偏移
66
+ private pendingCharData: GLPipelineCharData | null = null; // 待设置的字符数据
67
+ private interrupt: boolean = false; // 是否中断speak
68
+ private walkConfig: WalkConfig | null = null;
69
+ layout: Layout | null = null;
70
+ private allLostFrameCount = 0; // 累计丢失的帧数
71
+ private allLostFaceFrameCount = 0; // 累计丢失的表情帧数
72
+ private maxLostFrameCount = 300; // 最大允许丢失帧数
73
+ private forceSyncDecoder: () => void = () => {}; // 强制同步解码器到最新一帧
74
+ private createPipelineLock = false; // 创建 pipeline 锁
75
+ private restoreTimeout: any = null; // 恢复超时定时器
76
+ private isRecovering: boolean = false; // 是否正在恢复中
77
+ private recoverStartTime: number = 0; // 恢复开始时间
78
+
79
+ // ── 分阶段恢复配置(指数退避)─────────────────────────────────────
80
+ // light:复用现有 canvas,仅重建 GLDevice + GLPipeline(代价最小)
81
+ // medium:创建新 canvas 替换 DOM 节点,重建 GLDevice + GLPipeline
82
+ // full :在全量重建基础上降低渲染分辨率,作为最后手段
83
+ private recoveryStage: 'light' | 'medium' | 'full' = 'light';
84
+ // 各阶段最大尝试次数
85
+ // light 减为 2 次:同一 canvas 上 getContext 返回死 context,多次重试无意义
86
+ // 适用于 exit_on_context_lost(GPU 进程崩溃)场景,快速升级到 medium
87
+ private readonly recoveryMaxAttempts: Record<string, number> = { light: 2, medium: 3, full: 2 };
88
+ // 各阶段指数退避基础延迟(ms):实际延迟 = baseDelay * 2^attempt
89
+ // light 缩短到 300ms:快速失败,尽早升级
90
+ // medium 增加到 4000ms:GPU 进程重启后 D3D11 设备初始化需要更长时间
91
+ private readonly recoveryBaseDelays: Record<string, number> = { light: 300, medium: 4000, full: 5000 };
92
+ // 最大延迟上限(ms),防止退避时间过长
93
+ private readonly recoveryMaxDelay: number = 30000;
94
+ // 当前阶段内已尝试次数
95
+ private currentStageAttempts: number = 0;
96
+ // 是否正在使用降级分辨率(full 阶段恢复后)
97
+ private isUsingReducedResolution: boolean = false;
98
+ // 恢复分辨率定时器
99
+ private resolutionRestoreTimer: any = null;
100
+ // 降级分辨率稳定运行时长要求(ms),超过此时间后才尝试恢复原始分辨率
101
+ private readonly RESOLUTION_RESTORE_DELAY: number = 30000; // 30 秒
102
+ private scale = 1.0; // 缩放比例
103
+ private sendSdkPoint: (point: string, data: any) => void = () => {};
104
+ private copyCanvas: HTMLCanvasElement;
105
+ private copyCtx: CanvasRenderingContext2D | null;
106
+ constructor(options: Option) {
107
+ this.options = options;
108
+ this.onDownloadProgress = options.onDownloadProgress;
109
+ this.onStateChange = options.onStateChange;
110
+ this.onRenderChange = options.onRenderChange;
111
+ this.sendVideoInfo = options.sendVideoInfo;
112
+ this.saveAndDownload = options.saveAndDownload;
113
+ this.forceSyncDecoder = options.forceSyncDecoder;
114
+ this.lostHandler = this._lostHandler.bind(this);
115
+ this.restoreHandler = this._restoreHandler.bind(this);
116
+ this.setCanvasVisibility(this.avatarCanvasVisible);
117
+ this.sendSdkPoint = options.sendSdkPoint;
118
+ // 延迟创建 GLDevice 和 GLPipeline,避免与视频解码器竞争 GPU 资源
119
+ // 将在 init 方法中首次创建
120
+ this.device = null;
121
+ this.pipeline = null;
122
+ this.copyCanvas = document.createElement('canvas');
123
+ this.copyCtx = this.copyCanvas.getContext('2d');
124
+ }
125
+
126
+ /**
127
+ * 重置表情相关状态(用于从隐身模式恢复渲染时)
128
+ */
129
+ resetFaceFrameState() {
130
+ this.lastRealFaceFrame = -1;
131
+ this.lastRealFaceFrameData = null;
132
+ this.lastWeight = 0.0;
133
+ (window as any).avatarSDKLogger?.log(
134
+ this.TAG,
135
+ "重置表情状态(从隐身模式恢复)"
136
+ );
137
+ }
138
+
139
+ /**
140
+ * 创建 pipeline 并设置字符数据
141
+ */
142
+ public createPipeline() {
143
+ if (this.pipeline || !this.device || this.createPipelineLock) {
144
+ return;
145
+ }
146
+ this.createPipelineLock = true;
147
+ this.pipeline = new GLPipeline(this.device, this.sendSdkPoint);
148
+ this.createPipelineLock = false;
149
+ // 如果有待设置的字符数据,立即设置
150
+ if (this.pendingCharData) {
151
+ this.pipeline.setCharData(this.pendingCharData);
152
+ this.pipeline.setSyncMedia();
153
+ }
154
+ }
155
+
156
+ // ── WebGL 上下文丢失 / 恢复 ──────────────────────────────────────
157
+
158
+ _lostHandler(event: Event) {
159
+ event.preventDefault();
160
+ (window as any).avatarSDKLogger.error("Context lost.", event);
161
+
162
+ // 先移除事件监听器,避免旧 device 的匿名监听器残留
163
+ if (this.device) {
164
+ try {
165
+ this.device.canvas.removeEventListener("webglcontextlost", this.lostHandler);
166
+ this.device.canvas.removeEventListener("webglcontextrestored", this.restoreHandler);
167
+ } catch (_) { /* ignore */ }
168
+ }
169
+
170
+ // 销毁旧 device 释放 GPU 资源(关键:不调用 destroy 会导致资源泄漏,
171
+ // 后续恢复阶段在新 canvas 上也可能因 GPU 内存耗尽而失败)
172
+ if (this.device) {
173
+ try { this.device.destroy(); } catch (_) { /* ignore */ }
174
+ }
175
+ if (this.pipeline) {
176
+ try { this.pipeline.destroy(); } catch (_) { /* ignore */ }
177
+ }
178
+ this.device = null;
179
+ this.pipeline = null;
180
+
181
+ this.options.reportMessage({
182
+ code: EErrorCode.WEBGL_CONTEXT_LOST,
183
+ message: "Context lost.",
184
+ e: {
185
+ event,
186
+ timestamp: Date.now(),
187
+ },
188
+ });
189
+
190
+ // 如果已经在恢复中,不重复启动恢复流程
191
+ if (this.isRecovering) {
192
+ (window as any).avatarSDKLogger.warn("Context recovery already in progress, skipping.");
193
+ return;
194
+ }
195
+
196
+ // 取消分辨率恢复计划(上下文再次丢失)
197
+ if (this.resolutionRestoreTimer) {
198
+ clearTimeout(this.resolutionRestoreTimer);
199
+ this.resolutionRestoreTimer = null;
200
+ (window as any).avatarSDKLogger.log("[Recovery] Cancelled resolution restore due to context loss");
201
+ }
202
+
203
+ // 初始化分阶段恢复状态
204
+ this.isRecovering = true;
205
+ this.recoverStartTime = Date.now();
206
+ this.recoveryStage = 'light';
207
+ this.currentStageAttempts = 0;
208
+
209
+ // 启动第一次恢复尝试
210
+ this._scheduleNextAttempt();
211
+ }
212
+
213
+ _restoreHandler(event: Event) {
214
+ // 清除定时器,因为 restored 事件已经触发
215
+ if (this.restoreTimeout) {
216
+ clearTimeout(this.restoreTimeout);
217
+ this.restoreTimeout = null;
218
+ }
219
+
220
+ if (this.isRecovering) {
221
+ (window as any).avatarSDKLogger.warn("Context restore event received during recovery, skipping duplicate recovery.");
222
+ return;
223
+ }
224
+
225
+ this.options.reportMessage({
226
+ code: EErrorCode.WEBGL_CONTEXT_RESTORED,
227
+ message: "Context restored event received.",
228
+ e: {
229
+ event,
230
+ timestamp: Date.now(),
231
+ },
232
+ });
233
+
234
+ this.isRecovering = true;
235
+ // 浏览器已主动恢复,优先使用 light 阶段
236
+ this.recoveryStage = 'light';
237
+ this.currentStageAttempts = 0;
238
+ this._recoverContext();
239
+ }
240
+
241
+ // ── 分阶段恢复核心 ──────────────────────────────────────────────
242
+
243
+ /**
244
+ * 根据当前阶段分发到对应的恢复方法
245
+ */
246
+ private _recoverContext(): void {
247
+ // 防止竞态条件:如果恢复已完成(device/pipeline 已存在且 context 正常),跳过
248
+ if (this.device && this.pipeline && this.device.gl && !this.device.gl.isContextLost()) {
249
+ (window as any).avatarSDKLogger.log("[Recovery] Already recovered, skipping.");
250
+ this.isRecovering = false;
251
+ return;
252
+ }
253
+
254
+ (window as any).avatarSDKLogger.log(
255
+ `[Recovery] stage=${this.recoveryStage}, attempt=${this.currentStageAttempts + 1}/${this.recoveryMaxAttempts[this.recoveryStage]}`
256
+ );
257
+
258
+ if (this.restoreTimeout) {
259
+ clearTimeout(this.restoreTimeout);
260
+ this.restoreTimeout = null;
261
+ }
262
+
263
+ let success = false;
264
+ try {
265
+ switch (this.recoveryStage) {
266
+ case 'light':
267
+ success = this._tryLightRecovery();
268
+ break;
269
+ case 'medium':
270
+ success = this._tryMediumRecovery();
271
+ break;
272
+ case 'full':
273
+ success = this._tryFullRecovery();
274
+ break;
275
+ }
276
+ } catch (error) {
277
+ (window as any).avatarSDKLogger.error(`[Recovery] stage=${this.recoveryStage} threw:`, error);
278
+ }
279
+
280
+ if (success) {
281
+ this._onRecoverySuccess();
282
+ return;
283
+ }
284
+
285
+ // 本阶段失败,判断是否还有本阶段的重试机会
286
+ this.currentStageAttempts++;
287
+ this.isRecovering = false;
288
+
289
+ const maxAttempts = this.recoveryMaxAttempts[this.recoveryStage];
290
+ if (this.currentStageAttempts < maxAttempts) {
291
+ // 本阶段还可以重试
292
+ this._scheduleNextAttempt();
293
+ } else {
294
+ // 本阶段用尽,升级到下一阶段
295
+ this._advanceStage();
296
+ }
297
+ }
298
+
299
+ /**
300
+ * 阶段 1 — 轻量恢复
301
+ * 复用现有 canvas,仅重建 GLDevice + GLPipeline
302
+ * 适用于:上下文偶发丢失、浏览器后台恢复
303
+ */
304
+ private _tryLightRecovery(): boolean {
305
+ try {
306
+ (window as any).avatarSDKLogger.log("[Recovery] Trying light recovery (reuse canvas)...");
307
+
308
+ // 清理残留的 device(如果有)
309
+ if (this.device) {
310
+ try {
311
+ this.device.canvas.removeEventListener("webglcontextlost", this.lostHandler);
312
+ this.device.canvas.removeEventListener("webglcontextrestored", this.restoreHandler);
313
+ this.device.destroy();
314
+ } catch (_) { /* ignore */ }
315
+ this.device = null;
316
+ }
317
+
318
+ // 在现有 canvas 上重建 GLDevice
319
+ this.device = new GLDevice(this.canvas);
320
+ this.device.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
321
+ this.device.canvas.addEventListener("webglcontextrestored", this.restoreHandler, false);
322
+
323
+ // 验证上下文可用
324
+ if (!this.device.gl || this.device.gl.isContextLost()) {
325
+ throw new Error("WebGL context still lost after light recovery");
326
+ }
327
+
328
+ // 重建 pipeline
329
+ this.createPipelineLock = true;
330
+ this.pipeline = new GLPipeline(this.device, this.sendSdkPoint);
331
+ this.createPipelineLock = false;
332
+
333
+ if (this.pendingCharData) {
334
+ this.pipeline.setCharData(this.pendingCharData);
335
+ this.pipeline.setSyncMedia();
336
+ }
337
+ this.pipeline.reinitialize();
338
+ this.pipeline.setSyncMedia();
339
+ this.isInit = true;
340
+
341
+ return true;
342
+ } catch (error) {
343
+ (window as any).avatarSDKLogger.warn("[Recovery] Light recovery failed:", error);
344
+ this._reportRecoveryFailure('light', error);
345
+ return false;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * 阶段 2 — 中等恢复
351
+ * 创建新 canvas 替换 DOM 节点,重建 GLDevice + GLPipeline
352
+ * 适用于:canvas 节点本身出现异常(如全屏切换导致的 DOM 重建)
353
+ */
354
+ private _tryMediumRecovery(): boolean {
355
+ try {
356
+ (window as any).avatarSDKLogger.log("[Recovery] Trying medium recovery (replace canvas)...");
357
+
358
+ // 销毁旧 device
359
+ if (this.device) {
360
+ try {
361
+ this.device.canvas.removeEventListener("webglcontextlost", this.lostHandler);
362
+ this.device.canvas.removeEventListener("webglcontextrestored", this.restoreHandler);
363
+ this.device.destroy();
364
+ } catch (_) { /* ignore */ }
365
+ this.device = null;
366
+ }
367
+
368
+ // 销毁旧 pipeline
369
+ if (this.pipeline) {
370
+ try { this.pipeline.destroy(); } catch (_) { /* ignore */ }
371
+ this.pipeline = null;
372
+ }
373
+
374
+ // 创建新 canvas 并替换 DOM 节点
375
+ const oldCanvas = this.canvas;
376
+ const oldCanvasStyle = oldCanvas.style.cssText;
377
+ const oldCanvasParent = oldCanvas.parentNode;
378
+
379
+ const newCanvas = document.createElement("canvas");
380
+ const resolution = this.options.resourceManager.getConfig()?.resolution;
381
+ if (resolution) {
382
+ newCanvas.width = resolution.width ?? 1080;
383
+ newCanvas.height = resolution.height ?? 1920;
384
+ }
385
+ newCanvas.setAttribute("style", oldCanvasStyle);
386
+
387
+ if (oldCanvasParent) {
388
+ oldCanvasParent.replaceChild(newCanvas, oldCanvas);
389
+ }
390
+ this.canvas = newCanvas;
391
+
392
+ // 在新 canvas 上初始化 GLDevice
393
+ this.device = new GLDevice(this.canvas);
394
+ this.device.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
395
+ this.device.canvas.addEventListener("webglcontextrestored", this.restoreHandler, false);
396
+
397
+ if (!this.device.gl || this.device.gl.isContextLost()) {
398
+ throw new Error("WebGL context lost after medium recovery");
399
+ }
400
+
401
+ // 重建 pipeline
402
+ this.createPipelineLock = true;
403
+ this.pipeline = new GLPipeline(this.device, this.sendSdkPoint);
404
+ this.createPipelineLock = false;
405
+
406
+ if (this.pendingCharData) {
407
+ this.pipeline.setCharData(this.pendingCharData);
408
+ this.pipeline.setSyncMedia();
409
+ }
410
+ this.pipeline.reinitialize();
411
+ this.pipeline.setSyncMedia();
412
+ this.isInit = true;
413
+
414
+ return true;
415
+ } catch (error) {
416
+ (window as any).avatarSDKLogger.warn("[Recovery] Medium recovery failed:", error);
417
+ this._reportRecoveryFailure('medium', error);
418
+ return false;
419
+ }
420
+ }
421
+
422
+ /**
423
+ * 阶段 3 — 完全恢复(降级分辨率)
424
+ * 在全量重建基础上降低渲染分辨率,减少 GPU 压力
425
+ * 适用于:GPU 资源受限设备(笔记本集成显卡)连续上下文丢失
426
+ */
427
+ private _tryFullRecovery(): boolean {
428
+ try {
429
+ (window as any).avatarSDKLogger.log("[Recovery] Trying full recovery (reduced resolution)...");
430
+
431
+ // 销毁旧 device
432
+ if (this.device) {
433
+ try {
434
+ this.device.canvas.removeEventListener("webglcontextlost", this.lostHandler);
435
+ this.device.canvas.removeEventListener("webglcontextrestored", this.restoreHandler);
436
+ this.device.destroy();
437
+ } catch (_) { /* ignore */ }
438
+ this.device = null;
439
+ }
440
+
441
+ // 销毁旧 pipeline
442
+ if (this.pipeline) {
443
+ try { this.pipeline.destroy(); } catch (_) { /* ignore */ }
444
+ this.pipeline = null;
445
+ }
446
+
447
+ // 创建新 canvas,降低分辨率至原始的 75%
448
+ const oldCanvas = this.canvas;
449
+ const oldCanvasStyle = oldCanvas.style.cssText;
450
+ const oldCanvasParent = oldCanvas.parentNode;
451
+
452
+ const newCanvas = document.createElement("canvas");
453
+ const resolution = this.options.resourceManager.getConfig()?.resolution;
454
+ const scaleFactor = 0.75;
455
+ const targetWidth = Math.round((resolution?.width ?? 1080) * scaleFactor);
456
+ const targetHeight = Math.round((resolution?.height ?? 1920) * scaleFactor);
457
+ newCanvas.width = targetWidth;
458
+ newCanvas.height = targetHeight;
459
+ newCanvas.setAttribute("style", oldCanvasStyle);
460
+
461
+ if (oldCanvasParent) {
462
+ oldCanvasParent.replaceChild(newCanvas, oldCanvas);
463
+ }
464
+ this.canvas = newCanvas;
465
+
466
+ (window as any).avatarSDKLogger.log(
467
+ `[Recovery] Using reduced resolution: ${targetWidth}x${targetHeight} (scale=${scaleFactor})`
468
+ );
469
+
470
+ // 初始化 GLDevice
471
+ this.device = new GLDevice(this.canvas);
472
+ this.device.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
473
+ this.device.canvas.addEventListener("webglcontextrestored", this.restoreHandler, false);
474
+
475
+ if (!this.device.gl || this.device.gl.isContextLost()) {
476
+ throw new Error("WebGL context lost after full recovery");
477
+ }
478
+
479
+ // 重建 pipeline
480
+ this.createPipelineLock = true;
481
+ this.pipeline = new GLPipeline(this.device, this.sendSdkPoint);
482
+ this.createPipelineLock = false;
483
+
484
+ if (this.pendingCharData) {
485
+ this.pipeline.setCharData(this.pendingCharData);
486
+ this.pipeline.setSyncMedia();
487
+ }
488
+ this.pipeline.reinitialize();
489
+ this.pipeline.setSyncMedia();
490
+ this.isInit = true;
491
+
492
+ return true;
493
+ } catch (error) {
494
+ (window as any).avatarSDKLogger.warn("[Recovery] Full recovery failed:", error);
495
+ this._reportRecoveryFailure('full', error);
496
+ return false;
497
+ }
498
+ }
499
+
500
+ // ── 恢复辅助方法 ────────────────────────────────────────────────
501
+
502
+ /**
503
+ * 恢复成功后的统一收尾处理
504
+ */
505
+ private _onRecoverySuccess(): void {
506
+ const recoveryTime = Date.now() - this.recoverStartTime;
507
+ const wasFullRecovery = this.recoveryStage === 'full';
508
+
509
+ (window as any).avatarSDKLogger.log(
510
+ `[Recovery] Success! stage=${this.recoveryStage}, time=${recoveryTime}ms${wasFullRecovery ? ' (reduced resolution)' : ''}`
511
+ );
512
+ this.options.reportMessage({
513
+ code: EErrorCode.WEBGL_CONTEXT_RECOVERED,
514
+ message: `Context recovered via ${this.recoveryStage} recovery in ${recoveryTime}ms.`,
515
+ e: {
516
+ timestamp: Date.now(),
517
+ recoveryTime,
518
+ stage: this.recoveryStage,
519
+ attempts: this.currentStageAttempts + 1,
520
+ reducedResolution: wasFullRecovery,
521
+ },
522
+ });
523
+
524
+ // 如果是 full 阶段恢复,标记降级分辨率并安排恢复
525
+ if (wasFullRecovery) {
526
+ this.isUsingReducedResolution = true;
527
+ this._scheduleResolutionRestore();
528
+ }
529
+
530
+ // 重置所有恢复状态
531
+ this.isRecovering = false;
532
+ this.recoveryStage = 'light';
533
+ this.currentStageAttempts = 0;
534
+ }
535
+
536
+ /**
537
+ * 安排恢复原始分辨率
538
+ * 在降级分辨率稳定运行一段时间后尝试恢复
539
+ */
540
+ private _scheduleResolutionRestore(): void {
541
+ // 清除之前的定时器(如果有)
542
+ if (this.resolutionRestoreTimer) {
543
+ clearTimeout(this.resolutionRestoreTimer);
544
+ }
545
+
546
+ (window as any).avatarSDKLogger.log(
547
+ `[Recovery] Scheduling resolution restore in ${this.RESOLUTION_RESTORE_DELAY}ms`
548
+ );
549
+
550
+ this.resolutionRestoreTimer = setTimeout(() => {
551
+ this._restoreOriginalResolution();
552
+ }, this.RESOLUTION_RESTORE_DELAY);
553
+ }
554
+
555
+ /**
556
+ * 恢复原始分辨率
557
+ * 尝试将 canvas 恢复到原始分辨率,如果再次丢失上下文则保持降级
558
+ */
559
+ private _restoreOriginalResolution(): void {
560
+ if (!this.isUsingReducedResolution || !this.device || this.device.gl.isContextLost()) {
561
+ (window as any).avatarSDKLogger.warn("[Recovery] Cannot restore resolution: not in reduced mode or context lost");
562
+ return;
563
+ }
564
+
565
+ try {
566
+ (window as any).avatarSDKLogger.log("[Recovery] Attempting to restore original resolution...");
567
+
568
+ // 获取原始分辨率配置
569
+ const resolution = this.options.resourceManager.getConfig()?.resolution;
570
+ const originalWidth = resolution?.width ?? 1080;
571
+ const originalHeight = resolution?.height ?? 1920;
572
+
573
+ // 保存当前 canvas 的样式
574
+ const currentStyle = this.canvas.style.cssText;
575
+ const parent = this.canvas.parentNode;
576
+
577
+ // 创建新 canvas 使用原始分辨率
578
+ const newCanvas = document.createElement("canvas");
579
+ newCanvas.width = originalWidth;
580
+ newCanvas.height = originalHeight;
581
+ newCanvas.setAttribute("style", currentStyle);
582
+
583
+ // 替换 DOM 中的 canvas
584
+ if (parent) {
585
+ parent.replaceChild(newCanvas, this.canvas);
586
+ }
587
+ this.canvas = newCanvas;
588
+
589
+ // 销毁旧的 device 和 pipeline
590
+ if (this.device) {
591
+ try {
592
+ this.device.canvas.removeEventListener("webglcontextlost", this.lostHandler);
593
+ this.device.canvas.removeEventListener("webglcontextrestored", this.restoreHandler);
594
+ this.device.destroy();
595
+ } catch (_) { /* ignore */ }
596
+ this.device = null;
597
+ }
598
+ if (this.pipeline) {
599
+ try { this.pipeline.destroy(); } catch (_) { /* ignore */ }
600
+ this.pipeline = null;
601
+ }
602
+
603
+ // 重新初始化 GLDevice
604
+ this.device = new GLDevice(this.canvas);
605
+ this.device.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
606
+ this.device.canvas.addEventListener("webglcontextrestored", this.restoreHandler, false);
607
+
608
+ if (!this.device.gl || this.device.gl.isContextLost()) {
609
+ throw new Error("Context lost after resolution restore");
610
+ }
611
+
612
+ // 重建 pipeline
613
+ this.createPipelineLock = true;
614
+ this.pipeline = new GLPipeline(this.device, this.sendSdkPoint);
615
+ this.createPipelineLock = false;
616
+
617
+ if (this.pendingCharData) {
618
+ this.pipeline.setCharData(this.pendingCharData);
619
+ this.pipeline.setSyncMedia();
620
+ }
621
+ this.pipeline.reinitialize();
622
+ this.pipeline.setSyncMedia();
623
+
624
+ // 恢复成功
625
+ this.isUsingReducedResolution = false;
626
+ (window as any).avatarSDKLogger.log(
627
+ `[Recovery] Resolution restored to ${originalWidth}x${originalHeight}`
628
+ );
629
+ this.options.reportMessage({
630
+ code: EErrorCode.WEBGL_RESOLUTION_RESTORED,
631
+ message: `Resolution restored to ${originalWidth}x${originalHeight}.`,
632
+ e: {
633
+ timestamp: Date.now(),
634
+ resolution: { width: originalWidth, height: originalHeight },
635
+ },
636
+ });
637
+ } catch (error) {
638
+ (window as any).avatarSDKLogger.warn("[Recovery] Failed to restore resolution, keeping reduced:", error);
639
+ // 恢复失败,保持降级状态,不再重试
640
+ } finally {
641
+ this.resolutionRestoreTimer = null;
642
+ }
643
+ }
644
+
645
+ /**
646
+ * 升级恢复阶段:light → medium → full → 放弃
647
+ */
648
+ private _advanceStage(): void {
649
+ const stages: Array<'light' | 'medium' | 'full'> = ['light', 'medium', 'full'];
650
+ const currentIndex = stages.indexOf(this.recoveryStage);
651
+
652
+ if (currentIndex < stages.length - 1) {
653
+ const nextStage = stages[currentIndex + 1];
654
+ (window as any).avatarSDKLogger.log(
655
+ `[Recovery] Stage "${this.recoveryStage}" exhausted, upgrading to "${nextStage}"`
656
+ );
657
+ this.recoveryStage = nextStage;
658
+ this.currentStageAttempts = 0;
659
+ // 立即启动下一阶段第一次尝试
660
+ this._scheduleNextAttempt();
661
+ } else {
662
+ // 所有阶段全部失败,通知业务层
663
+ (window as any).avatarSDKLogger.error("[Recovery] All recovery stages exhausted. Giving up.");
664
+ this.options.reportMessage({
665
+ code: EErrorCode.WEBGL_CONTEXT_RECOVERY_MAX_ATTEMPTS,
666
+ message: "All WebGL recovery stages exhausted. SDK restart required.",
667
+ e: {
668
+ timestamp: Date.now(),
669
+ elapsedTime: Date.now() - this.recoverStartTime,
670
+ stagesAttempted: {
671
+ light: this.recoveryMaxAttempts.light,
672
+ medium: this.recoveryMaxAttempts.medium,
673
+ full: this.recoveryMaxAttempts.full,
674
+ },
675
+ },
676
+ });
677
+ this.isRecovering = false;
678
+ }
679
+ }
680
+
681
+ /**
682
+ * 按指数退避延迟安排下一次恢复尝试
683
+ * 延迟计算:baseDelay * 2^attempt,受 maxDelay 上限约束
684
+ */
685
+ private _scheduleNextAttempt(): void {
686
+ const baseDelay = this.recoveryBaseDelays[this.recoveryStage];
687
+ const maxAttempts = this.recoveryMaxAttempts[this.recoveryStage];
688
+ // 指数退避:baseDelay * 2^attempt
689
+ const calculatedDelay = baseDelay * Math.pow(2, this.currentStageAttempts);
690
+ const delay = Math.min(calculatedDelay, this.recoveryMaxDelay);
691
+
692
+ (window as any).avatarSDKLogger.log(
693
+ `[Recovery] Scheduling ${this.recoveryStage} attempt ${this.currentStageAttempts + 1}/${maxAttempts} in ${delay}ms (base=${baseDelay}ms, exp=${calculatedDelay}ms)`
694
+ );
695
+
696
+ this.restoreTimeout = setTimeout(() => {
697
+ this.isRecovering = true;
698
+ this._recoverContext();
699
+ }, delay);
700
+ }
701
+
702
+ /**
703
+ * 上报单次恢复失败事件
704
+ */
705
+ private _reportRecoveryFailure(stage: string, error: unknown): void {
706
+ this.options.reportMessage({
707
+ code: EErrorCode.WEBGL_CONTEXT_RECOVERY_FAILED,
708
+ message: `[${stage}] recovery attempt ${this.currentStageAttempts + 1}/${this.recoveryMaxAttempts[stage]} failed.`,
709
+ e: {
710
+ error,
711
+ stage,
712
+ attempt: this.currentStageAttempts + 1,
713
+ timestamp: Date.now(),
714
+ },
715
+ });
716
+ }
717
+
718
+ init(data: GLPipelineCharData | null) {
719
+ // this.style(this.options.resourceManager.getConfig()?.resolution?.width ?? 1080, this.options.resourceManager.getConfig()?.resolution?.height ?? 1920)
720
+ let charInitData = data;
721
+ // if (!this.device) {
722
+ // return;
723
+ // }
724
+ this.style(this.options.resourceManager.getConfig()?.resolution?.width ?? 1080, this.options.resourceManager.getConfig()?.resolution?.height ?? 1920)
725
+ this.setCharacterCanvasAnchor();
726
+ if(!charInitData?.char) {
727
+ const charInfo = this.options.resourceManager.getMouthShapeLib().char_info;
728
+ charInitData = {
729
+ char: charInfo,
730
+ LUT: null,
731
+ transform: {
732
+ offsetX: 0.0,
733
+ offsetY: 0.0,
734
+ scaleX: 1.0,
735
+ scaleY: 1.0
736
+ },
737
+ multisample: 4
738
+ }
739
+ }
740
+
741
+ // 保存字符数据,但不创建 pipeline
742
+ // pipeline 将在切换到在线模式时(resumeRender)才创建,避免在隐身模式时创建 pipeline 导致的 GPU 资源竞争
743
+ this.pendingCharData = charInitData;
744
+ this.isInit = true;
745
+
746
+ this.device = new GLDevice(this.canvas);
747
+ this.device.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
748
+ this.device.canvas.addEventListener("webglcontextrestored", this.restoreHandler, false);
749
+ this.createPipeline();
750
+ }
751
+
752
+ private style(width = 0, height = 0) {
753
+ if (width > 0 && height > 0) {
754
+ // 设置canvas宽度为输入图片宽度的一半,因为输入的是左右两张图片合并的
755
+ this.canvas.width = width;
756
+ this.canvas.height = height;
757
+ }
758
+ }
759
+
760
+ /**
761
+ * 设置画布样式(修复transform失效 + 优化对齐逻辑 + 优雅拼接样式)
762
+ * @param avatar 布局配置
763
+ */
764
+ setCanvasStyle(layout: Layout) {
765
+ this.layout = layout;
766
+ // 重置偏移缓存,使下一渲染帧强制重新计算
767
+ this.canvasOffsetX = -1;
768
+ const initPoint = this.walkConfig?.init_point ?? this.options.resourceManager.getConfig()?.walk_config?.init_point ?? null;
769
+ const {width = 1080, height = 1920} = this.options.resourceManager.getConfig()?.resolution ?? {width: 1080, height: 1920};
770
+ const [containerWidth, containerHeight] = layout.container?.size ?? [width, height];
771
+ // 解构并设置默认值
772
+ let {
773
+ v_align = 'center', // 垂直对齐:top/middle/bottom(修正命名逻辑)
774
+ h_align = 'center', // 水平对齐:left/center/right
775
+ scale = 1.0,
776
+ offset_x = 0.0,
777
+ offset_y = 0.0
778
+ } = layout.avatar || {};
779
+
780
+ // [【LiteSDK】行走2期](https://rsjqcmnt5p.feishu.cn/wiki/U2Waw2SDZikg7UkyVgxcorh7nne)
781
+ if(initPoint !== null && initPoint !== undefined) {
782
+ h_align = 'left';
783
+ offset_x = 0;
784
+ }
785
+
786
+ // 判断如果非数字,且包含vh时,根据vh计算scale 计算公式是 vh值 / 100 * 容器高度 / 分辨率高度 = scale
787
+ if (typeof scale === 'string' && scale.includes('vh')) {
788
+ const vhValue = parseFloat(scale.replace('vh', ''));
789
+ if (!isNaN(vhValue)) {
790
+ scale = (vhValue / 100 * containerHeight) / height;
791
+ } else {
792
+ scale = 1;
793
+ }
794
+ }
795
+ this.scale = Number(scale);
796
+ const avatarHeight = height * Number(scale);
797
+ const avatarWidth = width * Number(scale);
798
+ const marginX = avatarWidth / 2;
799
+ const marginY = avatarHeight / 2;
800
+
801
+ // 基础样式(提取固定项,避免重复拼接)
802
+ const baseStyles = {
803
+ position: 'absolute',
804
+ zIndex: 100,
805
+ height: `${avatarHeight}px`,
806
+ width: `${avatarWidth}px`,
807
+ objectFit: 'contain',
808
+ top: 'auto',
809
+ right: 'auto',
810
+ bottom: 'auto',
811
+ left: 'auto',
812
+ marginLeft: '0',
813
+ marginTop: '0',
814
+ willChange: 'transform',
815
+ transform: 'translateZ(0)',
816
+ };
817
+
818
+ // 1. 处理水平对齐(h_align):left/center/right
819
+ switch (h_align) {
820
+ case 'left':
821
+ baseStyles.left = initPoint !== null && initPoint !== undefined ? `${initPoint - marginX}px` : '0';
822
+ baseStyles.marginLeft = `${offset_x}px`;
823
+ break;
824
+ case 'right':
825
+ baseStyles.right = `-${marginX}px`;
826
+ baseStyles.marginLeft = `${offset_x}px`;
827
+ break;
828
+ default: // center
829
+ baseStyles.position = 'absolute';
830
+ baseStyles.left = '50%';
831
+ baseStyles.marginLeft = `${-marginX + offset_x}px`;
832
+ break;
833
+ }
834
+
835
+ // 2. 处理垂直对齐(v_align):top/middle/bottom
836
+ switch (v_align) {
837
+ case 'top':
838
+ baseStyles.top = '0';
839
+ baseStyles.marginTop = `${offset_y}px`;
840
+ break;
841
+ case 'bottom':
842
+ baseStyles.bottom = '0';
843
+ baseStyles.marginTop = `${offset_y}px`;
844
+ break;
845
+ default: // middle
846
+ baseStyles.position = 'absolute';
847
+ baseStyles.top = '50%';
848
+ baseStyles.marginTop = `${-marginY + offset_y}px`;
849
+ break;
850
+ }
851
+
852
+ // 3. 合并transform(核心:只定义一次,避免覆盖)
853
+ // 4. 拼接样式字符串(优雅转换为css样式)
854
+ const styleStr = getStyleStr(baseStyles);
855
+
856
+ // 应用样式
857
+ this.canvas.setAttribute('style', styleStr);
858
+ }
859
+
860
+ setWalkConfig(walkConfig: WalkConfig) {
861
+ this.walkConfig = walkConfig;
862
+ // 记录当前偏移作为基准,后续渲染用差值补偿,避免后端累计偏移导致闪烁
863
+ this.baseWalkOffset = this.lastBodyOffset;
864
+ if(walkConfig.init_point != null && this.layout) {
865
+ this.setCanvasStyle(this.layout);
866
+ }
867
+ }
868
+
869
+ setCharacterCanvasAnchor(layout?: Layout) {
870
+ if(layout?.avatar) {
871
+ this.setCanvasStyle(layout);
872
+ } else if(this.options.resourceManager.getConfig()?.layout) {
873
+ const layout =this.options.resourceManager.getConfig()?.layout || {
874
+ avatar: {
875
+ v_align: "default",
876
+ h_align: "default",
877
+ scale: 1.0,
878
+ offset_x: 0.0,
879
+ offset_y: 0.0,
880
+ },
881
+ };
882
+ this.setCanvasStyle(layout);
883
+ } else {
884
+ const baseStyles = {
885
+ position: 'absolute',
886
+ zIndex: 100,
887
+ height: `100%`,
888
+ objectFit: 'contain',
889
+ top: 'auto',
890
+ right: 'auto',
891
+ bottom: 'auto',
892
+ left: 'auto',
893
+ marginLeft: '0',
894
+ marginTop: '0'
895
+ };
896
+ const styleStr = getStyleStr(baseStyles);
897
+ this.canvas.setAttribute('style', styleStr);
898
+ const auchor = this.options.resourceManager.getConfig()?.init_events?.find((item) => item.type === 'SetCharacterCanvasAnchor') || {
899
+ type: 'SetCharacterCanvasAnchor',
900
+ x_location: 0.0,
901
+ y_location: 0.0,
902
+ width: 1.0,
903
+ height: 1.0,
904
+ };
905
+ const {x_location, y_location, width, height} = auchor;
906
+ const style = this.canvas.style.cssText + `left: calc(${x_location} * 100%); top: calc(${y_location} * 100%); transform: scale(${width}, ${height}); transform-origin: top left;`
907
+ this.canvas.setAttribute("style", style);
908
+ }
909
+ }
910
+
911
+ // 获取权重
912
+ // this.lastWeight应初始化为0
913
+ computeWeight(frameIndex) {
914
+ const maxTweenStep = 12;
915
+
916
+ const frameDiff = frameIndex - this.lastFrameIndex;
917
+ const isLost: boolean = frameIndex > this.lastRealFaceFrame;
918
+
919
+ if (isLost) {
920
+ // 丢帧:权重累加步长,最大不超过1.0
921
+ this.lastWeight = Math.min(this.lastWeight + frameDiff, maxTweenStep);
922
+ } else {
923
+ // 未丢帧:权重递减步长,最小不低于0.0
924
+ this.lastWeight = Math.max(this.lastWeight - frameDiff, 0);
925
+ }
926
+ return this.lastWeight / maxTweenStep;
927
+ }
928
+
929
+ render(frameIndex: number): { canvas: HTMLCanvasElement; rendered: boolean } {
930
+ // 每次渲染时,清理过期的表情数据
931
+ this.options.dataCacheQueue.clearOldFaceData(frameIndex);
932
+ if (!this.isInit) {
933
+ return { canvas: this.canvas, rendered: false };
934
+ }
935
+ // 确保 device 已创建
936
+ // if (!this.device) {
937
+ // this.device = new GLDevice(this.canvas);
938
+ // this.device.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
939
+ // this.device.canvas.addEventListener("webglcontextrestored", this.restoreHandler, false);
940
+ // }
941
+ // 如果还没有创建,这里作为兜底方案创建(防止遗漏)
942
+ // if (!this.pipeline) {
943
+ // this.createPipeline();
944
+ // // 如果创建失败,返回 canvas
945
+ // if (!this.pipeline) {
946
+ // return this.canvas;
947
+ // }
948
+ // }
949
+ const bodyFrame =
950
+ this.options.dataCacheQueue._getBodyImageBitmap(frameIndex);
951
+ let faceFrame = this.options.dataCacheQueue._getFaceImageBitmap(
952
+ frameIndex,
953
+ bodyFrame?.body_id ?? bodyFrame?.id ?? 0
954
+ );
955
+ let curRealFaceData = this.options.dataCacheQueue._getRealFaceImageBitmap(frameIndex, bodyFrame?.body_id ?? bodyFrame?.id ?? 0);
956
+ if (this.lastFrameState === "speak" && bodyFrame?.frameState !== "speak") {
957
+ // 从speak切出,重置表情权重相关状态
958
+ this.lastRealFaceFrame = -1;
959
+ this.lastRealFaceFrameData = null;
960
+ this.lastWeight = 0.0;
961
+ }
962
+ if(this.interrupt) {
963
+ faceFrame = curRealFaceData;
964
+ } else if(faceFrame?.FaceFrameData && faceFrame?.face_frame_type) {
965
+ // 在当前第 i 帧时,如果有实时表情数据real_face_data_i,则用其来渲染,同时更新last_real_face_data和last_real_face_data_frame
966
+ this.lastRealFaceFrameData = faceFrame.FaceFrameData;
967
+ this.lastRealFaceFrame = frameIndex;
968
+ // 实时数据帧连续时,权重递减
969
+ if (this.lastRealFaceFrame !== -1 && this.lastWeight > 0 && curRealFaceData) {
970
+ const lastWeight = this.computeWeight(frameIndex)
971
+ faceFrame = {
972
+ frameIndex,
973
+ state: bodyFrame?.frameState || 'idle',
974
+ body_id: bodyFrame?.body_id || -1,
975
+ sf: frameIndex,
976
+ ef: frameIndex,
977
+ cs: faceFrame?.cs || "",
978
+ face_frame_type: 0,
979
+ id: bodyFrame?.body_id || -1,
980
+ FaceFrameData: IBRAnimationFrameData_NN.interp(
981
+ this.lastRealFaceFrameData,
982
+ curRealFaceData.FaceFrameData,
983
+ curRealFaceData.FaceFrameData,
984
+ lastWeight,
985
+ this.options.resourceManager.resource_pack?.interpolate_joints || []
986
+ )
987
+ }
988
+ }
989
+ } else {
990
+ if(this.lastRealFaceFrame === -1) {
991
+ faceFrame = curRealFaceData;
992
+ // this.options.onError({
993
+ // code: EErrorCode.RENDER_FACE_ERROR,
994
+ // message: `第${frameIndex}帧 实时面部数据为空,原始数据渲染`,
995
+ // e: JSON.stringify({ bodyFrame, faceFrame }),
996
+ // });
997
+ } else {
998
+ // this.options.onError({
999
+ // code: EErrorCode.RENDER_FACE_ERROR,
1000
+ // message: `第${frameIndex}帧 实时面部数据为空,插值渲染`,
1001
+ // e: JSON.stringify({ bodyFrame, faceFrame }),
1002
+ // });
1003
+ if(curRealFaceData?.FaceFrameData) {
1004
+ if(this.lastRealFaceFrameData === null) {
1005
+ // 如果只有原始表情数据idle_face_data_i,若last_real_face_data为空,直接用idle_face_data_i渲染
1006
+ faceFrame = curRealFaceData;
1007
+ } else {
1008
+ // 若last_real_face_data非空,则使用last_real_face_data和idle_face_data_i的插值结果来渲染
1009
+ const lastWeight = this.computeWeight(frameIndex)
1010
+ faceFrame = {
1011
+ frameIndex,
1012
+ state: bodyFrame?.frameState || 'idle',
1013
+ body_id: bodyFrame?.body_id || -1,
1014
+ sf: frameIndex,
1015
+ ef: frameIndex,
1016
+ cs: faceFrame?.cs || "",
1017
+ face_frame_type: 0,
1018
+ id: bodyFrame?.body_id || -1,
1019
+ FaceFrameData: IBRAnimationFrameData_NN.interp(
1020
+ this.lastRealFaceFrameData,
1021
+ curRealFaceData.FaceFrameData,
1022
+ curRealFaceData.FaceFrameData,
1023
+ lastWeight,
1024
+ this.options.resourceManager.resource_pack?.interpolate_joints || []
1025
+ )
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ this._setCurrentBodyFrame(bodyFrame);
1032
+ if (
1033
+ ((!bodyFrame || !faceFrame) && bodyFrame?.hfd) ||
1034
+ (!bodyFrame?.hfd && !bodyFrame?.frame)
1035
+ ) {
1036
+ // 判断bodyFrame和faceFrame是否为空
1037
+ if (!bodyFrame) {
1038
+ this.allLostFrameCount++;
1039
+ this.options.onError({
1040
+ code: EErrorCode.RENDER_BODY_ERROR,
1041
+ message: `Error: 第${frameIndex}帧 bodyFrame为空`,
1042
+ e: JSON.stringify({ bodyFrame, faceFrame }),
1043
+ });
1044
+ } else if (!faceFrame) {
1045
+ this.allLostFaceFrameCount++;
1046
+ this.options.onError({
1047
+ code: EErrorCode.RENDER_FACE_ERROR,
1048
+ message: `Error: 第${frameIndex}帧 faceFrame为空`,
1049
+ e: JSON.stringify({ bodyFrame, faceFrame }),
1050
+ });
1051
+ }
1052
+ if(this.allLostFrameCount === this.maxLostFrameCount) {
1053
+ this.options.reportMessage({
1054
+ code: EErrorCode.RENDER_DATA_MISSING,
1055
+ message: `连续${this.maxLostFrameCount}帧 身体渲染数据缺失.`,
1056
+ });
1057
+ // 丢失过多帧,达到最大允许丢失帧数后,触发解码器对齐到最新的一帧
1058
+ this.options.onError({
1059
+ code: EErrorCode.RENDER_DATA_MISSING,
1060
+ message: `Error: 连续${this.maxLostFrameCount}帧 身体渲染数据缺失`,
1061
+ e: JSON.stringify({ bodyFrame, faceFrame }),
1062
+ });
1063
+ this.forceSyncDecoder();
1064
+ this.allLostFrameCount = 0;
1065
+ }
1066
+ if(this.allLostFaceFrameCount === this.maxLostFrameCount) {
1067
+ this.options.reportMessage({
1068
+ code: EErrorCode.RENDER_FACE_DATA_MISSING,
1069
+ message: `连续${this.maxLostFrameCount}帧 渲染表情数据缺失.`,
1070
+ e: { bodyFrame, frameIndex },
1071
+ });
1072
+ }
1073
+ (window as any).avatarSDKLogger.log(
1074
+ this.TAG,
1075
+ "render第",
1076
+ frameIndex,
1077
+ "丢帧,bodyFrame",
1078
+ bodyFrame,
1079
+ "faceFrame",
1080
+ faceFrame
1081
+ );
1082
+ return { canvas: this.canvas, rendered: false };
1083
+ } else {
1084
+ // this.onRenderChange?.(RenderState.rendering);
1085
+ this.allLostFrameCount = 0;
1086
+ this.allLostFaceFrameCount = 0;
1087
+ }
1088
+
1089
+ if (
1090
+ ((bodyFrame && faceFrame?.FaceFrameData && bodyFrame?.hfd) ||
1091
+ (!bodyFrame?.hfd && bodyFrame?.frame)) &&
1092
+ this.device
1093
+ ) {
1094
+ this.sendVideoInfo({
1095
+ name: bodyFrame.name,
1096
+ body_id: bodyFrame.body_id,
1097
+ id: bodyFrame.id,
1098
+ });
1099
+ if (bodyFrame?.frameState !== this.lastFrameState) {
1100
+ this.onStateChange?.(bodyFrame?.frameState);
1101
+ if (bodyFrame.frameState !== "speak") {
1102
+ (window as any).performanceTracker.markEnd(
1103
+ performanceConstant.start_action_render,
1104
+ bodyFrame.frameState
1105
+ );
1106
+ }
1107
+ this.lastFrameState = bodyFrame?.frameState;
1108
+ }
1109
+ if (this.isFirstRender) {
1110
+ (window as any).performanceTracker.markEnd(
1111
+ performanceConstant.first_avatar_render
1112
+ );
1113
+ this.isFirstRender = false;
1114
+ this.renderBackground();
1115
+ (window as any).performanceTracker.markStart(
1116
+ performanceConstant.first_render
1117
+ );
1118
+ this.onDownloadProgress?.(100);
1119
+ }
1120
+ // this.style(bodyFrame.frame.displayWidth, bodyFrame.frame.displayHeight);
1121
+ let rendered = false;
1122
+ if (this.pipeline) {
1123
+ try {
1124
+ const isEnableRobotExpression = this.options.resourceManager.getIsEnableRobotExpression()
1125
+ const faceData = isEnableRobotExpression ? (faceFrame?.FaceFrameData ?? null) : bodyFrame?.hfd ? (faceFrame?.FaceFrameData ?? null) : null;
1126
+ if (faceData) {
1127
+ this.saveAndDownload.appendMultipleToArray("render_faceData", [faceFrame]);
1128
+ }
1129
+ this.lastFaceFrame = faceFrame;
1130
+ let renderBodyFrame = bodyFrame.frame;
1131
+ if(isharmony()) {
1132
+ this.copyCanvas.width = bodyFrame.frame.displayWidth;
1133
+ this.copyCanvas.height = bodyFrame.frame.displayHeight;
1134
+ this.copyCtx?.drawImage(bodyFrame.frame, 0, 0);
1135
+ renderBodyFrame = this.copyCanvas;
1136
+ }
1137
+ // (window as any).avatarSDKLogger.log(
1138
+ // this.TAG,
1139
+ // "render第",
1140
+ // frameIndex,
1141
+ // "帧,bodyFrame",
1142
+ // bodyFrame,
1143
+ // "faceFrame",
1144
+ // faceFrame
1145
+ // );
1146
+ this.pipeline.renderFrame({
1147
+ charBodyTexture: renderBodyFrame,
1148
+ data: faceData,
1149
+ backgroundTexture: null,
1150
+ // 临时方案,后续需要根据实际情况调整
1151
+ fx: this.options.resourceManager.getConfig()?.fx ?? {},
1152
+ onPostRender: () => null
1153
+ });
1154
+ let pos_orig = [0, 0, 0]; //随便取了一个参考点。这里取了0号关节的位置
1155
+ let pos_new = [...pos_orig];
1156
+ const bodyOffset = bodyFrame?.offset ?? 0;
1157
+ this.lastBodyOffset = bodyOffset;
1158
+ pos_new[0] += bodyOffset - this.baseWalkOffset;
1159
+ const transformX = this.pipeline.getBillboardTransform2D(
1160
+ [pos_orig[0], pos_orig[1], pos_orig[2]],
1161
+ [pos_new[0], pos_new[1], pos_new[2]]
1162
+ ).offsetX;
1163
+ const offset_PX = transformX * this.canvas.width * this.scale;
1164
+
1165
+ if(offset_PX !== this.canvasOffsetX) {
1166
+ this.canvasOffsetX = offset_PX;
1167
+ if (DefaultWidgetRenderer.CUSTOM_WIDGET) {
1168
+ DefaultWidgetRenderer.CUSTOM_WIDGET({
1169
+ type: "set_character_canvas_offset",
1170
+ data: offset_PX
1171
+ });
1172
+ } else if (DefaultWidgetRenderer.PROXY_WIDGET && DefaultWidgetRenderer.PROXY_WIDGET["set_character_canvas_offset"]) {
1173
+ // 如果有代理的渲染器,则根据类型执行代理的渲染器
1174
+ DefaultWidgetRenderer.PROXY_WIDGET["set_character_canvas_offset"](offset_PX);
1175
+ } else {
1176
+ updateCanvasXOffset(this.canvas, offset_PX);
1177
+ }
1178
+ }
1179
+ this.lastFrameIndex = frameIndex;
1180
+ rendered = true;
1181
+ } catch (error) {
1182
+ (window as any).avatarSDKLogger.error(this.TAG, "渲染帧失败:", error);
1183
+ this.options.onError({
1184
+ code: EErrorCode.WebGL_RENDER_ERROR,
1185
+ message: `Error: 第${frameIndex}帧 webgl渲染异常`,
1186
+ e: JSON.stringify({ bodyFrame, faceFrame, error }),
1187
+ });
1188
+ } finally {
1189
+ // 渲染完成后再关闭VideoFrame
1190
+ if (bodyFrame.frame && typeof bodyFrame.frame.close === "function") {
1191
+ bodyFrame.frame.close();
1192
+ }
1193
+ }
1194
+ this.onRenderChange?.(RenderState.rendering);
1195
+ } else {
1196
+ // 如果没有渲染,也要关闭VideoFrame
1197
+ if (bodyFrame?.frame && typeof bodyFrame.frame.close === "function") {
1198
+ bodyFrame.frame.close();
1199
+ }
1200
+ }
1201
+ return { canvas: this.canvas, rendered };
1202
+ }
1203
+ return { canvas: this.canvas, rendered: false };
1204
+ }
1205
+
1206
+ renderBackground() {
1207
+ const background = this.options.resourceManager.getBackgroundImageElement();
1208
+ if (!background) return;
1209
+ const bgContainer = document.getElementById("avatar-bg-container");
1210
+ if (!bgContainer) return;
1211
+ // 设置背景图样式
1212
+ bgContainer.style.backgroundImage = `url(${background.src})`;
1213
+ // 可添加其他背景样式(如覆盖方式、定位等)
1214
+ bgContainer.style.backgroundSize = "cover";
1215
+ bgContainer.style.backgroundPosition = "center";
1216
+ }
1217
+
1218
+ _setCurrentBodyFrame(bodyFrame?: IBodyFrame) {
1219
+ if (bodyFrame) {
1220
+ this.currentBodyFrame = bodyFrame;
1221
+ }
1222
+ }
1223
+ _getCurrentBodyFrameInfo(frameIndex: number) {
1224
+ if (this.currentBodyFrame && frameIndex > 0) {
1225
+ return {
1226
+ client_frame: frameIndex,
1227
+ current_ani: this.currentBodyFrame.name,
1228
+ current_ani_frame:
1229
+ this.currentBodyFrame.frameIndex - this.currentBodyFrame.sf,
1230
+ next_state: this.lastFrameState || "idle",
1231
+ };
1232
+ }
1233
+ return {
1234
+ client_frame: 0,
1235
+ current_ani: "",
1236
+ current_ani_frame: 0,
1237
+ next_state: "idle",
1238
+ };
1239
+ }
1240
+
1241
+ /**
1242
+ * 获取当前帧的 body 和 face 原始数据(用于回调)
1243
+ * 不包含渲染数据,只返回帧号、body_id 等元数据
1244
+ */
1245
+ _getCurrentFrameData(frameIndex: number): {
1246
+ body: { body_id: number; name: string; video_frame: number; sf: number; state: string } | null;
1247
+ face: { body_id: number; sf: number; ef: number; state: string; face_frame_type: number } | null;
1248
+ } {
1249
+ const bodyFrame = this.currentBodyFrame;
1250
+ const faceFrame = this.lastFaceFrame;
1251
+
1252
+ return {
1253
+ body: bodyFrame ? {
1254
+ body_id: bodyFrame.body_id ?? bodyFrame.id ?? 0,
1255
+ name: bodyFrame.name,
1256
+ video_frame: bodyFrame.frameIndex - bodyFrame.sf,
1257
+ sf: bodyFrame.sf,
1258
+ state: bodyFrame.frameState,
1259
+ } : null,
1260
+ face: faceFrame ? {
1261
+ body_id: faceFrame.body_id ?? faceFrame.id ?? 0,
1262
+ sf: faceFrame.sf,
1263
+ ef: faceFrame.ef,
1264
+ state: faceFrame.state,
1265
+ face_frame_type: faceFrame.face_frame_type ?? 0,
1266
+ } : null,
1267
+ };
1268
+ }
1269
+ /**
1270
+ * 设置canvas的显隐状态
1271
+ * @param visible 是否可见
1272
+ */
1273
+ setCanvasVisibility(visible: boolean) {
1274
+ this.avatarCanvasVisible = visible;
1275
+ if (this.canvas) {
1276
+ this.canvas.style.display = visible ? "" : "none";
1277
+ }
1278
+ }
1279
+
1280
+ /**
1281
+ * 获取canvas的显隐状态
1282
+ */
1283
+ getCanvasVisibility(): boolean {
1284
+ return this.avatarCanvasVisible;
1285
+ }
1286
+
1287
+ destroy() {
1288
+ this.allLostFrameCount = 0;
1289
+ this.allLostFaceFrameCount = 0;
1290
+
1291
+ // 清理恢复定时器
1292
+ if (this.restoreTimeout) {
1293
+ clearTimeout(this.restoreTimeout);
1294
+ this.restoreTimeout = null;
1295
+ }
1296
+ this.isRecovering = false;
1297
+ this.recoveryStage = 'light';
1298
+ this.currentStageAttempts = 0;
1299
+
1300
+ // 先移除事件监听器,避免在销毁过程中触发
1301
+ if (this.device) {
1302
+ this.device.canvas.removeEventListener(
1303
+ "webglcontextlost",
1304
+ this.lostHandler
1305
+ );
1306
+ this.device.canvas.removeEventListener(
1307
+ "webglcontextrestored",
1308
+ this.restoreHandler
1309
+ );
1310
+
1311
+ // 如果 pipeline 存在,先销毁 pipeline(释放 WebGL 资源)
1312
+ if (this.pipeline) {
1313
+ this.pipeline.destroy();
1314
+ this.pipeline = null;
1315
+ }
1316
+
1317
+ // 最后销毁 device(会释放 WebGL context)
1318
+ // 对于隐身的数字人,如果没有 pipeline,说明没有真正使用 WebGL
1319
+ // 使用延迟销毁,避免在渲染关键帧时释放 GPU 资源导致其他数字人渲染异常
1320
+ if (this.device.gl) {
1321
+ // 保存 device 引用,延迟销毁
1322
+ const deviceToDestroy = this.device;
1323
+ this.device = null; // 先清除引用,避免后续操作使用
1324
+
1325
+ // 延迟到下一帧销毁,给其他数字人留出完成当前渲染的时间
1326
+ // 这样可以避免在销毁隐身数字人时影响正在说话的数字人的面部渲染
1327
+ requestAnimationFrame(() => {
1328
+ try {
1329
+ if (deviceToDestroy) {
1330
+ deviceToDestroy.destroy();
1331
+ }
1332
+ } catch (error) {
1333
+ // 忽略销毁错误,避免影响其他数字人
1334
+ (window as any).avatarSDKLogger?.warn?.('[AvatarRenderer] 销毁 device 时出错:', error);
1335
+ }
1336
+ });
1337
+ } else {
1338
+ // 如果 gl 为 null,直接清理引用
1339
+ this.device = null;
1340
+ }
1341
+ }
1342
+
1343
+ // 清理状态
1344
+ this.isInit = false;
1345
+ this.isFirstRender = true;
1346
+ this.lastRealFaceFrame = -1;
1347
+ this.lastRealFaceFrameData = null;
1348
+ this.lastWeight = 0.0;
1349
+ this.onStateChange = undefined;
1350
+ this.lastFrameState = "";
1351
+ this.pipeline = null;
1352
+ this.pendingCharData = null;
1353
+ this.scale = 1.0;
1354
+
1355
+ const bgContainer = document.getElementById("avatar-bg-container");
1356
+ if (bgContainer && bgContainer.parentNode) {
1357
+ bgContainer.parentNode.removeChild(bgContainer);
1358
+ }
1359
+
1360
+
1361
+ // 移除 canvas
1362
+ if (this.canvas && this.canvas.parentNode) {
1363
+ this.canvas.remove();
1364
+ }
1365
+ }
1366
+ setInterrupt(interrupt: boolean) {
1367
+ if(this.lastFaceFrame?.state !== 'speak' && interrupt) return
1368
+ this.interrupt = interrupt;
1369
+ }
1370
+
1371
+ /**
1372
+ * [测试用] 模拟 WebGL 上下文丢失
1373
+ * 用于测试分阶段恢复策略是否正常工作
1374
+ * @param restoreAfterMs 自动恢复上下文的延迟时间(毫秒),设置为 0 则不自动恢复
1375
+ * @param forceFail 强制恢复失败(用于测试 medium/full 阶段的升级逻辑)
1376
+ */
1377
+ public simulateContextLoss(restoreAfterMs: number = 5000, forceFail: boolean = false): void {
1378
+ if (!this.device || !this.device.gl) {
1379
+ (window as any).avatarSDKLogger.warn("[Test] No WebGL device available for testing");
1380
+ return;
1381
+ }
1382
+
1383
+ const gl = this.device.gl;
1384
+ const loseExtension = gl.getExtension("WEBGL_lose_context");
1385
+
1386
+ if (!loseExtension) {
1387
+ (window as any).avatarSDKLogger.error("[Test] WEBGL_lose_context extension not available");
1388
+ return;
1389
+ }
1390
+
1391
+ (window as any).avatarSDKLogger.log("[Test] Simulating WebGL context loss...");
1392
+
1393
+ // 如果设置了 forceFail,临时禁用恢复
1394
+ if (forceFail) {
1395
+ (window as any).avatarSDKLogger.warn("[Test] Force fail mode enabled - will not auto-restore");
1396
+ }
1397
+
1398
+ // 触发上下文丢失
1399
+ loseExtension.loseContext();
1400
+
1401
+ // 如果设置了自动恢复,则在指定时间后恢复上下文
1402
+ if (restoreAfterMs > 0 && !forceFail) {
1403
+ setTimeout(() => {
1404
+ (window as any).avatarSDKLogger.log(`[Test] Auto-restoring context after ${restoreAfterMs}ms...`);
1405
+ loseExtension.restoreContext();
1406
+ }, restoreAfterMs);
1407
+ }
1408
+ }
1409
+
1410
+ /**
1411
+ * [测试用] 获取当前恢复状态信息
1412
+ */
1413
+ public getRecoveryStatus(): {
1414
+ isRecovering: boolean;
1415
+ stage: string;
1416
+ attempts: number;
1417
+ maxAttempts: number;
1418
+ isUsingReducedResolution: boolean;
1419
+ } {
1420
+ return {
1421
+ isRecovering: this.isRecovering,
1422
+ stage: this.recoveryStage,
1423
+ attempts: this.currentStageAttempts,
1424
+ maxAttempts: this.recoveryMaxAttempts[this.recoveryStage] || 0,
1425
+ isUsingReducedResolution: this.isUsingReducedResolution,
1426
+ };
1427
+ }
1428
+
1429
+ /**
1430
+ * [测试用] 批量测试分阶段恢复
1431
+ * 依次触发多次上下文丢失,观察各阶段的恢复情况
1432
+ */
1433
+ public async testStagedRecovery(): Promise<void> {
1434
+ (window as any).avatarSDKLogger.log("[Test] Starting staged recovery test...");
1435
+
1436
+ const testCases = [
1437
+ { desc: "Light recovery (should succeed)", delay: 1000, forceFail: false },
1438
+ { desc: "Context loss again (may upgrade to medium)", delay: 8000, forceFail: false },
1439
+ ];
1440
+
1441
+ for (let i = 0; i < testCases.length; i++) {
1442
+ const tc = testCases[i];
1443
+ (window as any).avatarSDKLogger.log(`[Test] Step ${i + 1}: ${tc.desc}`);
1444
+
1445
+ await new Promise<void>((resolve) => {
1446
+ setTimeout(() => {
1447
+ this.simulateContextLoss(tc.delay, tc.forceFail);
1448
+ resolve();
1449
+ }, tc.delay);
1450
+ });
1451
+
1452
+ // 等待恢复完成
1453
+ await new Promise<void>((resolve) => setTimeout(resolve, tc.delay + 2000));
1454
+
1455
+ const status = this.getRecoveryStatus();
1456
+ (window as any).avatarSDKLogger.log(`[Test] After step ${i + 1}:`, status);
1457
+ }
1458
+
1459
+ (window as any).avatarSDKLogger.log("[Test] Staged recovery test completed!");
1460
+ }
1461
+
1462
+ /**
1463
+ * 手动重启 WebGL:创建新 canvas + 新 GLDevice + 新 GLPipeline
1464
+ * 用于分阶段恢复全部失败后的手动恢复
1465
+ * @returns 是否成功
1466
+ */
1467
+ public restartWebGL(): boolean {
1468
+ (window as any).avatarSDKLogger.log("[restartWebGL] Starting manual WebGL restart...");
1469
+
1470
+ // 取消所有恢复定时器
1471
+ if (this.restoreTimeout) {
1472
+ clearTimeout(this.restoreTimeout);
1473
+ this.restoreTimeout = null;
1474
+ }
1475
+ if (this.resolutionRestoreTimer) {
1476
+ clearTimeout(this.resolutionRestoreTimer);
1477
+ this.resolutionRestoreTimer = null;
1478
+ }
1479
+ this.isRecovering = false;
1480
+ this.recoveryStage = 'light';
1481
+ this.currentStageAttempts = 0;
1482
+
1483
+ try {
1484
+ // 销毁旧 device
1485
+ if (this.device) {
1486
+ try {
1487
+ this.device.canvas.removeEventListener("webglcontextlost", this.lostHandler);
1488
+ this.device.canvas.removeEventListener("webglcontextrestored", this.restoreHandler);
1489
+ this.device.destroy();
1490
+ } catch (_) { /* ignore */ }
1491
+ this.device = null;
1492
+ }
1493
+
1494
+ // 销毁旧 pipeline
1495
+ if (this.pipeline) {
1496
+ try { this.pipeline.destroy(); } catch (_) { /* ignore */ }
1497
+ this.pipeline = null;
1498
+ }
1499
+
1500
+ // 创建新 canvas 并替换 DOM 节点
1501
+ const oldCanvas = this.canvas;
1502
+ const oldCanvasStyle = oldCanvas.style.cssText;
1503
+ const oldCanvasParent = oldCanvas.parentNode;
1504
+
1505
+ const newCanvas = document.createElement("canvas");
1506
+ const resolution = this.options.resourceManager.getConfig()?.resolution;
1507
+ if (resolution) {
1508
+ newCanvas.width = resolution.width ?? 1080;
1509
+ newCanvas.height = resolution.height ?? 1920;
1510
+ }
1511
+ newCanvas.setAttribute("style", oldCanvasStyle);
1512
+
1513
+ if (oldCanvasParent) {
1514
+ oldCanvasParent.replaceChild(newCanvas, oldCanvas);
1515
+ }
1516
+ this.canvas = newCanvas;
1517
+
1518
+ // 在新 canvas 上初始化 GLDevice
1519
+ this.device = new GLDevice(this.canvas);
1520
+ this.device.canvas.addEventListener("webglcontextlost", this.lostHandler, false);
1521
+ this.device.canvas.addEventListener("webglcontextrestored", this.restoreHandler, false);
1522
+
1523
+ // 检查 context 是否可用
1524
+ if (!this.device.gl || this.device.gl.isContextLost()) {
1525
+ throw new Error("WebGL context still lost after creating new canvas");
1526
+ }
1527
+
1528
+ // 重建 pipeline
1529
+ this.createPipelineLock = true;
1530
+ this.pipeline = new GLPipeline(this.device, this.sendSdkPoint);
1531
+ this.createPipelineLock = false;
1532
+
1533
+ if (this.pendingCharData) {
1534
+ this.pipeline.setCharData(this.pendingCharData);
1535
+ this.pipeline.setSyncMedia();
1536
+ }
1537
+ this.pipeline.reinitialize();
1538
+ this.pipeline.setSyncMedia();
1539
+ this.isInit = true;
1540
+
1541
+ (window as any).avatarSDKLogger.log("[restartWebGL] WebGL restart successful!");
1542
+
1543
+ this.options.reportMessage({
1544
+ code: EErrorCode.WEBGL_CONTEXT_MANUAL_RESTARTED,
1545
+ message: "WebGL context recovered via manual restart.",
1546
+ e: {
1547
+ timestamp: Date.now(),
1548
+ method: "manual_restart",
1549
+ },
1550
+ });
1551
+
1552
+ return true;
1553
+ } catch (error) {
1554
+ (window as any).avatarSDKLogger.error("[restartWebGL] Failed:", error);
1555
+ this._reportRecoveryFailure('manual', error);
1556
+ return false;
1557
+ }
1558
+ }
1559
+ }