@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,77 @@
1
+
2
+ class SaveAndDownload {
3
+ constructor(fileName = "avatarData.json", enabled = false) {
4
+ this.fileName = fileName;
5
+ this.enabled = enabled; // 功能开关
6
+ this.data = {}; // 存储JSON数据
7
+ this.downloadButton = null;
8
+ if (enabled) {
9
+ this.initDownloadButton();
10
+ }
11
+ }
12
+
13
+ // 初始化下载按钮
14
+ initDownloadButton() {
15
+ if (!this.enabled) return;
16
+
17
+ this.downloadButton = document.createElement("a");
18
+ this.downloadButton.style.position = "absolute";
19
+ this.downloadButton.style.left = "0";
20
+ this.downloadButton.style.bottom = "30px";
21
+ this.downloadButton.style.background = "#4CAF50";
22
+ this.downloadButton.style.color = "white";
23
+ this.downloadButton.style.fontSize = "12px";
24
+ this.downloadButton.style.padding = "10px";
25
+ this.downloadButton.style.borderRadius = "4px";
26
+ this.downloadButton.style.textDecoration = "none";
27
+ this.downloadButton.style.zIndex = "1000";
28
+ this.downloadButton.style.cursor = "pointer";
29
+ this.downloadButton.innerHTML = "下载数据提交开发回放";
30
+ this.downloadButton.addEventListener("click", () => {
31
+ this.download();
32
+ });
33
+ document.body.appendChild(this.downloadButton);
34
+ }
35
+
36
+ // 写入多个字段
37
+ writeFields(fields) {
38
+ if (!this.enabled) return;
39
+ Object.assign(this.data, fields);
40
+ }
41
+
42
+ // 追加多个项目到数组字段
43
+ appendMultipleToArray(fieldName, items) {
44
+ if (!this.enabled) return;
45
+ if (!this.data[fieldName]) {
46
+ this.data[fieldName] = [];
47
+ }
48
+ this.data[fieldName].push(...items);
49
+ }
50
+
51
+ // 下载文件
52
+ download() {
53
+ if (!this.enabled) return;
54
+ const jsonContent = this.generateJSONContent();
55
+ this.downloadFile(jsonContent, this.fileName, "application/json");
56
+ }
57
+
58
+ // 生成JSON文件内容
59
+ generateJSONContent() {
60
+ return JSON.stringify(this.data, null, 2);
61
+ }
62
+
63
+ // 下载文件的具体实现
64
+ downloadFile(content, fileName, contentType) {
65
+ if (!this.enabled) return;
66
+ const blob = new Blob([content], { type: contentType });
67
+ const url = URL.createObjectURL(blob);
68
+
69
+ this.downloadButton.href = url;
70
+ this.downloadButton.download = fileName;
71
+ this.downloadButton.scrollIntoView({ behavior: "smooth", block: "center" });
72
+
73
+ this.downloadButton.dataset.objectUrl = url;
74
+ }
75
+ }
76
+
77
+ export default SaveAndDownload;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * StreamingClient - 薄客户端模式下的 WebSocket 连接管理器
3
+ * 负责与 Streaming Server 建立连接,管理消息路由、心跳和重连。
4
+ */
5
+
6
+ export interface StreamingClientOptions {
7
+ url: string;
8
+ onReady?: () => void;
9
+ onOffer?: (sdp: string) => void;
10
+ onIce?: (candidate: RTCIceCandidateInit) => void;
11
+ onEventProxy?: (eventType: string, data: any, serverFrame?: number) => void;
12
+ onError?: (error: string) => void;
13
+ onDisconnect?: () => void;
14
+ }
15
+
16
+ export class StreamingClient {
17
+ private ws: WebSocket | null = null;
18
+ private options: StreamingClientOptions;
19
+ private retryCount = 0;
20
+ private maxRetry = 27; // 3轮 × 9次
21
+ private retryTimer: number = -1;
22
+ private destroyed = false;
23
+ private connected = false;
24
+
25
+ constructor(options: StreamingClientOptions) {
26
+ this.options = options;
27
+ }
28
+
29
+ connect() {
30
+ if (this.destroyed) return;
31
+
32
+ try {
33
+ this.ws = new WebSocket(this.options.url);
34
+ } catch (e) {
35
+ console.error('[StreamingClient] 创建 WebSocket 失败:', e);
36
+ this.scheduleRetry();
37
+ return;
38
+ }
39
+
40
+ this.ws.onopen = () => {
41
+ this.connected = true;
42
+ this.retryCount = 0;
43
+ console.log('[StreamingClient] 已连接');
44
+ // 以 client 角色加入
45
+ this.send({ type: 'join', role: 'client' });
46
+ };
47
+
48
+ this.ws.onmessage = (e) => {
49
+ let msg: any;
50
+ try {
51
+ msg = JSON.parse(e.data);
52
+ } catch {
53
+ return;
54
+ }
55
+ this.handleMessage(msg);
56
+ };
57
+
58
+ this.ws.onclose = () => {
59
+ this.connected = false;
60
+ if (!this.destroyed) {
61
+ this.options.onDisconnect?.();
62
+ this.scheduleRetry();
63
+ }
64
+ };
65
+
66
+ this.ws.onerror = () => {
67
+ this.options.onError?.('WebSocket 连接错误');
68
+ };
69
+ }
70
+
71
+ private handleMessage(msg: any) {
72
+ switch (msg.type) {
73
+ case 'ready':
74
+ this.options.onReady?.();
75
+ break;
76
+ case 'offer':
77
+ this.options.onOffer?.(msg.sdp);
78
+ break;
79
+ case 'answer':
80
+ // Client 不发送 offer,不会收到 answer
81
+ break;
82
+ case 'ice':
83
+ this.options.onIce?.(msg.candidate);
84
+ break;
85
+ case 'event_proxy':
86
+ this.options.onEventProxy?.(msg.event_type, msg.data, msg.server_frame);
87
+ break;
88
+ case 'error':
89
+ this.options.onError?.(msg.message || '未知错误');
90
+ break;
91
+ }
92
+ }
93
+
94
+ send(msg: object) {
95
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
96
+ this.ws.send(JSON.stringify(msg));
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 发送 API 调用
102
+ */
103
+ sendApiCall(method: string, params: any[] = []) {
104
+ this.send({ type: 'api_call', method, params });
105
+ }
106
+
107
+ private scheduleRetry() {
108
+ if (this.destroyed) return;
109
+ if (this.retryCount >= this.maxRetry) {
110
+ console.warn('[StreamingClient] 重试次数已达上限');
111
+ this.options.onError?.('连接重试次数已达上限');
112
+ return;
113
+ }
114
+
115
+ const delay = this.retryCount === 0 ? 1000 : Math.pow(2, Math.min(this.retryCount, 6)) * 1000;
116
+ this.retryCount++;
117
+ this.retryTimer = window.setTimeout(() => {
118
+ this.connect();
119
+ }, delay);
120
+ }
121
+
122
+ isConnected() {
123
+ return this.connected;
124
+ }
125
+
126
+ destroy() {
127
+ this.destroyed = true;
128
+ if (this.retryTimer !== -1) {
129
+ clearTimeout(this.retryTimer);
130
+ this.retryTimer = -1;
131
+ }
132
+ if (this.ws) {
133
+ this.ws.close();
134
+ this.ws = null;
135
+ }
136
+ this.connected = false;
137
+ }
138
+ }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * VideoPlayer - 薄客户端模式下的 WebRTC 收流与播放管理
3
+ * 支持两种模式:
4
+ * - 普通模式:直接 <video> 播放
5
+ * - Alpha Mask 模式:canvas 渲染 + 双路流 alpha 合成(透明背景)
6
+ */
7
+
8
+ import { StreamingClient } from './StreamingClient';
9
+
10
+ export interface VideoPlayerOptions {
11
+ container: HTMLElement;
12
+ streamingClient: StreamingClient;
13
+ useAlphaMask?: boolean; // 是否启用 alpha mask 透明背景
14
+ onConnected?: () => void;
15
+ onDisconnected?: () => void;
16
+ }
17
+
18
+ export class VideoPlayer {
19
+ private video: HTMLVideoElement | null = null;
20
+ private canvas: HTMLCanvasElement | null = null;
21
+ private canvasCtx: CanvasRenderingContext2D | null = null;
22
+ private pc: RTCPeerConnection | null = null;
23
+ private stream: MediaStream | null = null;
24
+ private options: VideoPlayerOptions;
25
+ private destroyed = false;
26
+ private animFrameId = 0;
27
+ // alpha 处理用的临时 canvas
28
+ private tempCanvas: HTMLCanvasElement | null = null;
29
+ private tempCtx: CanvasRenderingContext2D | null = null;
30
+
31
+ constructor(options: VideoPlayerOptions) {
32
+ this.options = options;
33
+ if (options.useAlphaMask) {
34
+ this.createCanvasElement();
35
+ } else {
36
+ this.createVideoElement();
37
+ }
38
+ }
39
+
40
+ private createVideoElement() {
41
+ const video = document.createElement('video');
42
+ video.autoplay = true;
43
+ video.playsInline = true;
44
+ video.muted = true; // 先静音,等音频轨到达后取消
45
+ video.style.width = '100%';
46
+ video.style.height = '100%';
47
+ video.style.objectFit = 'contain';
48
+ video.style.background = 'transparent';
49
+ this.video = video;
50
+ this.options.container.appendChild(video);
51
+ }
52
+
53
+ private createCanvasElement() {
54
+ const canvas = document.createElement('canvas');
55
+ canvas.style.width = '100%';
56
+ canvas.style.height = '100%';
57
+ canvas.style.objectFit = 'contain';
58
+ canvas.style.background = 'transparent';
59
+ this.canvas = canvas;
60
+ this.canvasCtx = canvas.getContext('2d', { willReadFrequently: true });
61
+
62
+ // 用于 alpha 合成的临时 canvas
63
+ this.tempCanvas = document.createElement('canvas');
64
+ this.tempCtx = this.tempCanvas.getContext('2d', { willReadFrequently: true });
65
+
66
+ this.options.container.appendChild(canvas);
67
+
68
+ // 创建隐藏的 video 元素用于接收 WebRTC 流
69
+ // 注意:不能用 display:none,某些浏览器不解码。用绝对定位 + 1px 尺寸
70
+ const video = document.createElement('video');
71
+ video.autoplay = true;
72
+ video.playsInline = true;
73
+ video.muted = true;
74
+ video.style.position = 'absolute';
75
+ video.style.width = '1px';
76
+ video.style.height = '1px';
77
+ video.style.opacity = '0';
78
+ video.style.pointerEvents = 'none';
79
+ video.style.zIndex = '-1';
80
+ this.video = video;
81
+ this.options.container.appendChild(video);
82
+ }
83
+
84
+ /**
85
+ * 处理远端 SDP offer,创建 answer 并通过 StreamingClient 回传
86
+ */
87
+ async handleOffer(sdp: string) {
88
+ this.destroyPeer();
89
+
90
+ this.pc = new RTCPeerConnection({
91
+ iceServers: [
92
+ { urls: 'stun:stun.l.google.com:19302' },
93
+ { urls: 'stun:stun1.l.google.com:19302' },
94
+ // Open Relay 免费 TURN 服务器(跨网络 NAT 穿透)
95
+ { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
96
+ { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
97
+ { urls: 'turns:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
98
+ ]
99
+ });
100
+
101
+ this.pc.onicecandidate = (e) => {
102
+ if (e.candidate) {
103
+ this.options.streamingClient.send({
104
+ type: 'ice',
105
+ candidate: e.candidate.toJSON(),
106
+ });
107
+ }
108
+ };
109
+
110
+ this.pc.ontrack = (e) => {
111
+ console.log('[VideoPlayer] 收到轨道:', e.track.kind, e.track.id);
112
+ if (!this.stream) {
113
+ this.stream = new MediaStream();
114
+ if (this.video) {
115
+ this.video.srcObject = this.stream;
116
+ }
117
+ }
118
+ this.stream.addTrack(e.track);
119
+
120
+ // 音频轨到达后取消静音并尝试播放
121
+ if (e.track.kind === 'audio' && this.video) {
122
+ this.video.muted = false;
123
+ this.video.play().catch(() => {});
124
+ }
125
+
126
+ // 视频轨到达后,如果启用 alpha mask,开始渲染循环
127
+ if (e.track.kind === 'video' && this.options.useAlphaMask) {
128
+ console.log('[VideoPlayer] Alpha Mask 模式:启动 canvas 渲染循环');
129
+ this.startAlphaRendering();
130
+ }
131
+ };
132
+
133
+ this.pc.onconnectionstatechange = () => {
134
+ if (!this.pc) return;
135
+ console.log('[VideoPlayer] WebRTC 状态:', this.pc.connectionState);
136
+ if (this.pc.connectionState === 'connected') {
137
+ this.options.onConnected?.();
138
+ } else if (this.pc.connectionState === 'disconnected' || this.pc.connectionState === 'failed') {
139
+ this.options.onDisconnected?.();
140
+ }
141
+ };
142
+
143
+ await this.pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp }));
144
+ const answer = await this.pc.createAnswer();
145
+ await this.pc.setLocalDescription(answer);
146
+
147
+ this.options.streamingClient.send({ type: 'answer', sdp: answer.sdp });
148
+ }
149
+
150
+ /**
151
+ * Alpha Mask 渲染循环
152
+ * 将双路流(RGB + Alpha)合成为带透明度的画面
153
+ */
154
+ private startAlphaRendering() {
155
+ if (!this.video || !this.canvas || !this.canvasCtx || !this.tempCanvas || !this.tempCtx) return;
156
+
157
+ const video = this.video;
158
+ const canvas = this.canvas;
159
+ const ctx = this.canvasCtx;
160
+ const tempCanvas = this.tempCanvas;
161
+ const tempCtx = this.tempCtx;
162
+
163
+ let lastWidth = 0;
164
+ let lastHeight = 0;
165
+ let frameCount = 0;
166
+ let debugInterval: ReturnType<typeof setInterval> | null = null;
167
+
168
+ // 启动调试日志:每 2 秒打印一次状态
169
+ debugInterval = setInterval(() => {
170
+ if (this.destroyed) {
171
+ if (debugInterval) clearInterval(debugInterval);
172
+ return;
173
+ }
174
+ console.log(`[VideoPlayer] Alpha 渲染调试: frames=${frameCount}, videoReadyState=${video.readyState}, videoSize=${video.videoWidth}x${video.videoHeight}, currentTime=${video.currentTime.toFixed(2)}s, paused=${video.paused}`);
175
+ }, 2000);
176
+
177
+ const render = () => {
178
+ if (this.destroyed) {
179
+ if (debugInterval) clearInterval(debugInterval);
180
+ return;
181
+ }
182
+
183
+ // 视频数据就绪
184
+ if (video.readyState >= 2 && video.videoWidth > 0) {
185
+ const vw = video.videoWidth;
186
+ const vh = video.videoHeight;
187
+
188
+ // 双路流:宽度是原始 2x,左半 RGB,右半 Alpha
189
+ const realW = vw / 2;
190
+ const realH = vh;
191
+
192
+ // 尺寸变化时更新 canvas 大小
193
+ if (vw !== lastWidth || vh !== lastHeight) {
194
+ canvas.width = realW;
195
+ canvas.height = realH;
196
+ tempCanvas.width = vw; // 需要容纳完整视频帧(RGB + Alpha)
197
+ tempCanvas.height = realH;
198
+ lastWidth = vw;
199
+ lastHeight = vh;
200
+ console.log(`[VideoPlayer] Alpha Mask: 检测到双流 ${vw}x${vh} → 原始 ${realW}x${realH}`);
201
+ }
202
+
203
+ // 快照:先将整个视频帧绘制到临时 canvas,避免两次 drawImage(video) 之间视频切帧
204
+ tempCtx.clearRect(0, 0, realW * 2, realH);
205
+ tempCtx.drawImage(video, 0, 0);
206
+
207
+ // 1. 从快照绘制左半 RGB 到输出 canvas
208
+ ctx.clearRect(0, 0, realW, realH);
209
+ ctx.drawImage(tempCanvas, 0, 0, realW, realH, 0, 0, realW, realH);
210
+
211
+ // 2. 从同一快照读取右半 Alpha 灰度图
212
+ // 3. 读取像素数据并合成 alpha
213
+ try {
214
+ const rgbData = ctx.getImageData(0, 0, realW, realH);
215
+ const alphaData = tempCtx.getImageData(realW, 0, realW, realH);
216
+ const rgb = rgbData.data;
217
+ const alpha = alphaData.data;
218
+
219
+ // 首帧调试:检查像素数据
220
+ if (frameCount === 0) {
221
+ let nonZeroRGB = 0;
222
+ let nonZeroAlpha = 0;
223
+ const sampleSize = Math.min(1000, rgb.length / 4);
224
+ for (let i = 0; i < sampleSize * 4; i += 4) {
225
+ if (rgb[i] > 0 || rgb[i+1] > 0 || rgb[i+2] > 0) nonZeroRGB++;
226
+ if (alpha[i] > 0) nonZeroAlpha++;
227
+ }
228
+ console.log(`[VideoPlayer] 首帧像素检查: RGB非零=${nonZeroRGB}/${sampleSize}, Alpha非零=${nonZeroAlpha}/${sampleSize}`);
229
+ if (nonZeroRGB === 0) {
230
+ console.warn(`[VideoPlayer] 警告: RGB 全黑!视频可能没有内容`);
231
+ }
232
+ }
233
+
234
+ // 将 alpha 灰度(R 通道)写入 RGB 的 alpha 通道
235
+ for (let i = 0; i < rgb.length; i += 4) {
236
+ rgb[i + 3] = alpha[i]; // alpha[i] = R 通道 = 灰度值 = 原始 alpha
237
+ }
238
+
239
+ ctx.putImageData(rgbData, 0, 0);
240
+ frameCount++;
241
+ } catch (e) {
242
+ // getImageData/putImageData 可能因 tainted canvas 失败
243
+ // 降级为直接绘制(无透明背景)
244
+ console.warn(`[VideoPlayer] Alpha 合成失败,降级为直接绘制:`, e);
245
+ ctx.drawImage(video, 0, 0, realW, realH, 0, 0, realW, realH);
246
+ frameCount++;
247
+ }
248
+ }
249
+
250
+ this.animFrameId = requestAnimationFrame(render);
251
+ };
252
+
253
+ this.animFrameId = requestAnimationFrame(render);
254
+ }
255
+
256
+ /**
257
+ * 添加 ICE candidate
258
+ */
259
+ async addIceCandidate(candidate: RTCIceCandidateInit) {
260
+ if (this.pc) {
261
+ await this.pc.addIceCandidate(candidate).catch((e) => {
262
+ console.warn('[VideoPlayer] addIceCandidate 失败:', e);
263
+ });
264
+ }
265
+ }
266
+
267
+ getVideoElement(): HTMLVideoElement | null {
268
+ return this.video;
269
+ }
270
+
271
+ getCanvasElement(): HTMLCanvasElement | null {
272
+ return this.canvas;
273
+ }
274
+
275
+ isConnected(): boolean {
276
+ return this.pc?.connectionState === 'connected';
277
+ }
278
+
279
+ private destroyPeer() {
280
+ if (this.pc) {
281
+ this.pc.close();
282
+ this.pc = null;
283
+ }
284
+ this.stream = null;
285
+ if (this.animFrameId) {
286
+ cancelAnimationFrame(this.animFrameId);
287
+ this.animFrameId = 0;
288
+ }
289
+ }
290
+
291
+ destroy() {
292
+ this.destroyed = true;
293
+ this.destroyPeer();
294
+ if (this.video && this.video.parentNode) {
295
+ this.video.parentNode.removeChild(this.video);
296
+ }
297
+ if (this.canvas && this.canvas.parentNode) {
298
+ this.canvas.parentNode.removeChild(this.canvas);
299
+ }
300
+ this.video = null;
301
+ this.canvas = null;
302
+ this.canvasCtx = null;
303
+ this.tempCanvas = null;
304
+ this.tempCtx = null;
305
+ }
306
+ }