项目备份

This commit is contained in:
林觅
2026-04-09 10:37:51 +08:00
parent 7ed8d2dcb4
commit 4eab443148
32 changed files with 9468 additions and 0 deletions

287
module/aiortc_parse.py Normal file
View File

@@ -0,0 +1,287 @@
import asyncio
import json
import threading
import cv2
import numpy as np
import win32gui
import win32ui
import win32con
import win32api
import aiortc
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder
from aiortc.sdp import SessionDescription
import webview
# -------------------------- 2. 兼容VideoFrame导入 --------------------------
try:from aiortc.contrib.media import VideoFrame,AudioFrame
except ImportError:from aiortc import VideoFrame,AudioFrame
# -------------------------- 1. Windows 11高DPI适配 --------------------------
ctypes.windll.shcore.SetProcessDpiAwareness(2) # 解决高分屏采集偏移问题
# -------------------------- 2. 兼容VideoFrame导入 --------------------------
try:from aiortc.contrib.media import VideoFrame,AudioFrame
except ImportError:from aiortc import VideoFrame,AudioFrame
# -------------------------- 2. 屏幕视频采集轨道 --------------------------
class ScreenVideoTrack(MediaStreamTrack):
kind = "video"
def __init__(self, fps=30, scale_factor=1.0):
super().__init__()
self.fps = fps
self.scale_factor = scale_factor
self.stop_flag = False
self.pts = 0
self.frame_interval = 1.0 / fps
# 获取真实屏幕分辨率适配高DPI
self.user32 = ctypes.windll.user32
self.screen_width = self.user32.GetSystemMetrics(0)
self.screen_height = self.user32.GetSystemMetrics(1)
def capture_screen(self):
"""Windows 11原生API采集屏幕低延迟"""
left, top, width, height = 0, 0, self.screen_width, self.screen_height
# 1. 创建设备上下文DC
hdesktop = win32gui.GetDesktopWindow()
hwnd_dc = win32gui.GetWindowDC(hdesktop)
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
save_dc = mfc_dc.CreateCompatibleDC()
# 2. 复制屏幕内容到位图
save_bitmap = win32ui.CreateBitmap()
save_bitmap.CreateCompatibleBitmap(mfc_dc, width, height)
save_dc.SelectObject(save_bitmap)
save_dc.BitBlt((0, 0), (width, height), mfc_dc, (left, top), win32con.SRCCOPY)
# 3. 转换为numpy数组BGR格式
bmp_data = save_bitmap.GetBitmapBits(True)
frame = np.frombuffer(bmp_data, dtype=np.uint8).reshape((height, width, 4))
frame = frame[:, :, :3] # 去掉Alpha通道
frame = frame[:, :, ::-1] # BGRA → BGR适配aiortc
# 4. 缩放(可选,降低文件大小)
if self.scale_factor != 1.0:
new_width = int(width * self.scale_factor)
new_height = int(height * self.scale_factor)
frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_AREA)
# 5. 释放资源(避免内存泄漏)
win32gui.DeleteObject(save_bitmap.GetHandle())
save_dc.DeleteDC()
mfc_dc.DeleteDC()
win32gui.ReleaseDC(hdesktop, hwnd_dc)
return frame
async def recv(self):
"""aiortc核心方法持续返回视频帧"""
if self.stop_flag:
raise StopAsyncIteration
# 控制帧率
await asyncio.sleep(self.frame_interval)
# 获取windows11 原始的视频帧
frame_data = self.capture_screen()
# 转换为aiortc VideoFrame
video_frame = self._convert_to_video_frame(frame_data)
video_frame.pts = int(self.pts)
video_frame.time_base = np.array([1, self.fps])
self.pts += 1
return video_frame
def _convert_to_video_frame(self, img):
"""将numpy数组转换为aiortc的VideoFrame"""
return VideoFrame.from_ndarray(img, format="bgr24")
def stop(self):
"""停止屏幕采集"""
self.stop_flag = True
# -------------------------- 3. 系统音频采集轨道 --------------------------
class SystemAudioTrack(MediaStreamTrack):
kind = "audio"
def __init__(self, sample_rate=48000, channels=2):
super().__init__()
self.sample_rate = sample_rate
self.channels = channels
self.stop_flag = False
self.pts = 0
self.audio_queue = asyncio.Queue(maxsize=10) # 音频数据队列
# 启动麦克风/系统音频采集(这里默认采集麦克风,系统音频见备注)
self._start_audio_capture()
def _start_audio_capture(self):
"""启动音频采集(麦克风)"""
def audio_callback(indata, frames, time, status):
if status or self.stop_flag:
return
# 将音频数据转为float32格式aiortc要求
audio_data = indata.astype(np.float32)
self.audio_queue.put_nowait(audio_data)
# 打开音频输入流
self.audio_stream = sd.InputStream(
samplerate=self.sample_rate,
channels=self.channels,
callback=audio_callback,
blocksize=1024
)
self.audio_stream.start()
async def recv(self):
"""aiortc核心方法持续返回音频帧"""
if self.stop_flag and self.audio_queue.empty():
raise StopAsyncIteration
# 从队列获取音频数据
audio_data = await self.audio_queue.get()
sample_count = len(audio_data)
# 转换为aiortc AudioFrame
audio_frame = AudioFrame(
samples=audio_data.T, # 转置为 (channels, samples)
sample_rate=self.sample_rate,
channels=self.channels
)
audio_frame.pts = int(self.pts)
audio_frame.time_base = np.array([1, self.sample_rate])
self.pts += sample_count
return audio_frame
def stop(self):
"""停止音频采集"""
self.stop_flag = True
self.audio_stream.stop()
self.audio_stream.close()
class WebRTCService:
def __init__(self):
super().__init__()
self.peer=None
self.channel = None
self.screen_track = None
self.loop = asyncio.new_event_loop()
self.thread = None
def start_async_loop(self):
"""在子线程运行asyncio循环"""
asyncio.set_event_loop(self.loop)
self.loop.run_forever()
# 1. 初始化 RTCPeerConnection
async def init_connection(self):
# 初始化 RTCPeerConnection
if self.peer :self.peer =None
# 创建 RTCPeerConnection 实例
self.peer = RTCPeerConnection()
# 添加事件监听器
@self.peer.on("connectionstatechange")
async def on_connectionstatechange():
print(f"连接状态: {self.peer.connectionState}")
async def recv(self):
"""捕获屏幕帧并转换为 aiortc 可处理的 VideoFrame"""
if not self.running:
raise RuntimeError("Track stopped")
# 1. 捕获屏幕(返回 numpy 数组)
img = np.array(self.sct.grab(self.monitor))
# 2. 转换格式mss 捕获的是 BGRA转为 BGROpenCV 格式)
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
# 3. 转换为 VideoFrameaiortc 要求的格式)
frame = VideoFrame.from_ndarray(img, format="bgr24")
# 调整时间戳(保证流的时序)
frame.pts = self.pts
frame.time_base = self.time_base
self.pts += 1
return frame
# 2. 获取用户媒体
async def setup_media(self):
# 使用摄像头和麦克风
# player = MediaPlayer('/dev/video0') # Linux 摄像头
# 或者使用文件
media_flag = True
if media_flag:
player = MediaPlayer('screen_60fps.mp4')
return player.audio, player.video
else:
player_video = ScreenVideoTrack(fps=30)
player_audio = SystemAudioTrack()
return player_audio, player_video
# 3. 创建 Offer
async def create_offer(self):
# 添加音视频轨道
audio_track, video_track = await self.setup_media()
if audio_track:
self.peer.addTrack(audio_track)
if video_track:
self.peer.addTrack(video_track)
# 创建 offer
offer = await self.peer.createOffer()
await self.peer.setLocalDescription(offer)
return offer
# 3. 创建 接收 offer 并创建 Answer
async def handle_remote_offer(self, offer_sdp):
# 设置远程描述
offer = RTCSessionDescription(sdp=offer_sdp, type="offer")
await self.peer.setRemoteDescription(offer)
# 创建 answer
answer = await self.peer.createAnswer()
await self.peer.setLocalDescription(answer)
return answer
async def handle_remote_answer(self,answer_sdp):
await self.peer.setRemoteDescription(obj)
# 监听ICE候选发送到信令服务器
@self.peer.on("icecandidate")
async def on_icecandidate(candidate):
if candidate:
# await signaling.send(candidate)
pass
# 4. 数据通道使用
async def setup_data_channel(self,pc):
# 初始化 数据通道
if self.channel : self.channel = None
# 创建数据通道
self.channel = self.peer.createDataChannel("chat")
@self.channel.on("open")
def on_open():
print("数据通道已打开")
# 发送消息
self.channel.send("Hello from aiortc!")
@self.channel.on("message")
def on_message(message):
print(f"收到消息: {message}")
return self.channel
if __name__ == "__main__":
rtc=rtc()
rtc.setup_media()
pass

68
module/main.js Normal file
View File

@@ -0,0 +1,68 @@
// 导入类ES6 模块化)
import { webSocket_ModuleSimple } from "./newWebRtcSync.js";
class rtcCallee {
constructor(userIdentity, user_id) {
this.current_time = new Date();
this.identity = userIdentity;
// websocket对象
this.wsClient = null;
// WebRTC 核心对象
this.rtcPeer = null;
this.dataChannel = null;
this.state;
// webRTC 信令
this.clientCandidates = [];
this.remoteOffer;
this.localAnswer;
this.localStream;
this.remoteStream;
this._init_ws(user_id);
}
_init_ws(user_id) {
// websocket初始化
if (this.wsClient) {
this.wsClient = null;
}
console.info(`初始化websocket`);
// const wsService = `ws://localhost:8000/wsSignaling/${user_id}/`
const wsService = `wss://api.vlos.net/wsSignaling/${user_id}/`;
this.wsClient = new webSocket_ModuleSimple(wsService);
this.wsClient.connect();
this.handlerWsSignalingLisioner();
}
/**
* 监听websocket信息 回调函数方式监控websocket数据
*/
async handlerWsSignalingLisioner() {
this.wsClient.on({
onMessage: async (msg) => {
const res_dict = JSON.parse(msg);
console.info(
`[exportWebRtcCallee.js]->[handlerWsSignalingLisioner函数][${this.identity}][${this.current_time.toLocaleTimeString()}]>>>收到信息`,
"type:",
res_dict.type,
"长度",
msg.length,
);
switch (res_dict.type) {
case "candidate":
console.info(
`[exportWebRtcCallee.js][${this.identity}][${this.current_time.toLocaleTimeString()}]>>>监听candidate`,
res_dict.data,
);
break;
case "offer":
this.remoteOffer = res_dict.data;
console.info(
`[exportWebRtcCallee.js][${this.identity}][${this.current_time.toLocaleTimeString()}]>>>收到远程offer`,
);
console.warn(`收到的offer,`, this.remoteOffer);
break;
}
},
});
}
}
new rtcCallee("callee", 123);

609
module/newWebRtcSync.js Normal file
View File

@@ -0,0 +1,609 @@
/**
* 【发起方】
1. 创建 PeerConnection
2. 采集本地流并添加到 PeerConnection
3. 调用 createOffer() → 生成Offer
4. 调用 setLocalDescription(offer) → 开始收集ICE候选
5. 通过信令发送Offer给接收方
6. 监听 onicecandidate → 发送ICE候选给接收方
7. 接收接收方的Answer → 调用 setRemoteDescription(answer)
8. 接收接收方的ICE候选 → 调用 addIceCandidate(candidate)
【接收方】
1. 创建 PeerConnection
2. 接收发起方的Offer → 调用 setRemoteDescription(offer)
3. 调用 createAnswer() → 生成Answer
4. 调用 setLocalDescription(answer) → 开始收集ICE候选
5. 通过信令发送Answer给发起方
6. 监听 onicecandidate → 发送ICE候选给发起方
7. 接收发起方的ICE候选 → 调用 addIceCandidate(candidate)
*/
/**
* WebRTC 核心封装类(ES6 模块化)
* 支持音视频互通、自定义数据传输、外部流管理
* 开箱即用,无需额外依赖
*/
export class WebRTC_ModuleSimple {
/**
* 构造函数
* @param {Object} options 初始化配置
* @param {HTMLElement} options.localVideo 本地视频播放元素
* @param {HTMLElement} options.remoteVideo 远端视频播放元素
* @param {string} options.dataChannelLabel 数据通道标签(默认 'chat')
* @param {Function} receivepeerCallback 回调函数
*/
constructor(options = {}) {
this.current_time = new Date();
// 基础配置
this.config = {
localVideo: options.localVideo,
remoteVideo: options.remoteVideo,
dataChannelLabel: options.dataChannelLabel || "chat",
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun.cloudflare.com:3478" },
],
};
// WebRTC 核心对象
this.peerConnection = null;
this.dataChannel = null;
// 媒体流管理
this.localStream = null; // 本地流(可外部传入/获取)
this.remoteStream = null; // 远端流(可外部获取)
// 事件回调(外部可自定义)
this.callbacks = {
onIceCandidate: (candidate) => {}, // ICE 候选生成
onConnectSuccess: () => {}, // 连接成功
onConnectFailed: () => {}, // 连接失败
onRemoteStream: (stream) => {}, // 收到远端流
onMessage: (data) => {}, // 收到自定义消息
onClose: () => {}, // 连接关闭
onState: (state) => {},
};
// 初始化 PeerConnection
this._initPeerConnection();
}
/**
* 初始化 RTCPeerConnection 核心对象
* @private
*/
_initPeerConnection() {
try {
// 销毁旧实例
if (this.peerConnection) {
this.peerConnection.close();
}
// 浏览器兼容处理
const RTCPeerConnection =
window.RTCPeerConnection ||
window.mozRTCPeerConnection ||
window.webkitRTCPeerConnection;
this.peerConnection = new RTCPeerConnection({
iceServers: this.config.iceServers,
});
// 监听信令协商状态
this.signalingStateListener();
// 监听远程信息
this.peerConnectionLisioner();
} catch (error) {
console.error("WebRTC 初始化失败:", error);
this.callbacks.onConnectFailed();
}
}
// 监听信令状态变化(Offer/Answer/Ice 协商)
signalingStateListener() {
// 初始状态打印
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}初始信令状态:`,
this.peerConnection.signalingState,
);
// 监听信令状态变化(Offer/Answer 协商)
this.peerConnection.onsignalingstatechange = () => {
/**
* 信令状态枚举值(全阶段)
* new 初始状态
* have-local-offer 本地已设置 Offer 发起者:已调用 createOffer() + setLocalDescription(offer)
* have-remote-offer 远端已设置 Offer 应答者:已调用 setRemoteDescription(offer)
* have-local-answer 本地已设置 Answer 应答者:已调用 createAnswer() + setLocalDescription(answer)
* have-remote-answer 远端已设置 Answer 发起者:已调用 setRemoteDescription(answer)
* stable 协商完成,稳定状态
* closed PeerConnection 已关闭
*/
const state = this.peerConnection.signalingState;
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}初始信令变更:`,
state,
);
state && this.callbacks.onState(state);
// 根据状态执行不同逻辑
switch (state) {
case "new":
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【状态】未开始协商,可创建 Offer`,
);
break;
case "have-local-offer":
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【状态】发起者已发 Offer,等待应答者 Answer`,
);
// 可触发 ICE 候选者发送逻辑
break;
case "have-remote-offer":
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【状态】应答者已收 Offer,可创建 Answer`,
);
break;
case "stable":
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【状态】Offer/Answer 协商完成,进入 ICE 配对阶段`,
);
break;
case "closed":
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【状态】PeerConnection 已关闭,协商终止`,
);
break;
default:
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【状态】协商中:`,
state,
);
}
};
// ICE 候选者收集 / 配对 / 连接
this.peerConnection.onicegatheringstatechange = () => {
/**
* ICE 连接状态iceConnectionState
* new 初始状态,未开始连接
* checking 正在检查候选者配对
* connected 已建立连接P2P 成功)
* completed 连接完成(所有候选者检查完毕)
* failed ICE 协商失败
* disconnected 连接断开(临时)
* closed PeerConnection 已关闭
*/
const state = this.peerConnection.iceGatheringState;
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}ICE 收集状态变更:`,
state,
);
state && this.callbacks.onState(state);
switch (state) {
case "gathering":
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【ICE】开始收集候选者(host/srflx/relay)`,
state,
);
break;
case "complete":
console.warn(
`[webRTC]${this.current_time.toLocaleTimeString()}【ICE】候选者收集完成`,
state,
);
break;
}
};
}
peerConnectionLisioner() {
if (!this.peerConnection) {
return;
}
// 监听远端媒体流
this.peerConnection.ontrack = (e) => {
this.remoteStream = e.streams[0];
// 绑定远端视频
if (this.config.remoteVideo) {
this.config.remoteVideo.srcObject = this.remoteStream;
this.config.remoteVideo.play().catch(() => {});
}
this.callbacks.onRemoteStream(this.remoteStream);
};
// 监听peer连接状态
this.peerConnection.onconnectionstatechange = () => {
const state = this.peerConnection.connectionState;
if (state === "connected") {
this.callbacks.onConnectSuccess();
} else if (state === "failed") {
this.callbacks.onConnectFailed();
} else if (state === "closed") {
this.callbacks.onClose();
}
};
// 监听远端创建的数据通道
this.peerConnection.ondatachannel = (e) => {
this.dataChannel = e.channel;
this.dataChannelLisioner();
};
// 监听 ICE 候选(需通过信令服务器发给对方)
this.peerConnection.onicecandidate = (e) => {
e.candidate && this.callbacks.onIceCandidate(e.candidate);
};
// 监听 ICE 连接状态,排查失败原因
this.peerConnection.oniceconnectionstatechange = () => {
const state = this.peerConnection.iceConnectionState;
console.info("ICE 连接状态:", state);
// 捕获 ICE failed 状态,给出明确提示
if (state === "failed") {
console.error("ICE 协商失败!请检查 TURN 服务器配置或网络环境");
// 可选:触发重新协商
this.createOffer();
}
};
// 新监听 ICE 候选者生成错误
this.peerConnection.onicecandidateerror = (event) => {
console.error(
"ICE 候选者生成失败:",
event.errorText,
"错误码:",
event.errorCode,
);
};
}
/**
* 初始化数据通道(自定义消息传输)
* @private
*/
dataChannelLisioner() {
if (!this.dataChannel) return;
// 数据通道打开
this.dataChannel.onopen = () => console.info("数据通道已就绪");
// 接收自定义消息(自动解析JSON)
this.dataChannel.onmessage = (e) => {
let data = e.data;
try {
data = JSON.parse(data);
} catch (err) {}
this.callbacks.onMessage(data);
this.dataChannelSendMsg(data);
};
// 数据通道错误
this.dataChannel.onerror = (error) => console.error("数据通道异常:", error);
// 数据通道关闭
this.dataChannel.onclose = () => console.info("数据通道关闭");
}
/**
* 采集本地音视频流(一键调用)
* @param {Object} constraints 媒体约束(默认开启音视频)
* @returns {Promise<MediaStream>} 本地媒体流
*/
async captureLocalStream(constraints = { video: true, audio: true }) {
try {
// getUserMedia 设备硬件:摄像头(视频)、麦克风(音频)
// getDisplayMedia 屏幕 / 窗口 / 标签页:显示器内容(视频),可选采集系统音频
this.localStream =
await navigator.mediaDevices.getDisplayMedia(constraints);
// 绑定本地视频
if (this.config.localVideo) {
this.config.localVideo.srcObject = this.localStream;
this.config.localVideo.muted = true; // 静音避免回声
this.config.localVideo.play().catch(() => {});
}
// 将流添加到连接
this.localStream.getTracks().forEach((track) => {
this.peerConnection.addTrack(track, this.localStream);
});
return this.localStream;
} catch (error) {
console.error("采集本地流失败:", error);
throw new Error(`媒体权限不足或设备不可用: ${error.message}`);
}
}
/**
* 外部传入本地媒体流(支持屏幕共享等自定义流)
* @param {MediaStream} stream 本地媒体流
*/
setLocalStream(stream) {
if (!stream) throw new Error("媒体流不能为空");
this.localStream = stream;
// 绑定视频(可选)
if (this.config.localVideo) {
this.config.localVideo.srcObject = stream;
this.config.localVideo.muted = true;
this.config.localVideo.play().catch(() => {});
}
// 添加到连接
// stream.getTracks().forEach(track => {
// this.peerConnection.addTrack(track, stream);
// });
}
/**
* 获取本地媒体流
* @returns {MediaStream|null} 本地流
*/
getLocalStream() {
return this.localStream;
}
/**
* 获取远端媒体流
* @returns {MediaStream|null} 远端流
*/
getRemoteStream() {
return this.remoteStream;
}
/**
* 发起方:创建 Offer 并开启数据通道
* @returns {Promise<RTCSessionDescription>} Offer 对象
*/
async createOffer() {
// 重置数据通道
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
}
// 创建数据通道(发起方主动创建)
this.dataChannel = this.peerConnection.createDataChannel(
this.config.dataChannelLabel,
);
// 监听数据通道
this.dataChannelLisioner();
const offer = await this.peerConnection.createOffer();
// 设置本地offer
await this.peerConnection.setLocalDescription(offer);
return offer;
}
/**
* 接收方:处理远端 Offer 并创建 Answer
* @param {RTCSessionDescription} offer 远端 Offer
* @returns {Promise<RTCSessionDescription>} Answer 对象
*/
async handleRemoteOffer(offer) {
await this.peerConnection.setRemoteDescription(
new RTCSessionDescription(offer),
);
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
return answer;
}
/**
* 发起方:处理远端 Answer
* @param {RTCSessionDescription} answer 远端 Answer
*/
async handleAnswer(answer) {
try {
// 检查当前信令状态,仅在非 stable 时设置
if (this.peerConnection.signalingState !== "stable") {
await this.peerConnection.setRemoteDescription(
new RTCSessionDescription(answer),
);
console.info("远端 Answer 设置成功");
} else {
console.warn("PeerConnection 已处于稳定状态,无需重复设置 Answer");
}
} catch (error) {
console.error("设置远端 Answer 失败:", error);
}
}
/**
* 添加远端 ICE 候选
* @param {RTCIceCandidate} candidate ICE 候选对象
*/
async addIceCandidate(candidate) {
if (!candidate) return;
try {
if (this.peerConnection && candidate) {
await this.peerConnection.addIceCandidate(
new RTCIceCandidate(candidate),
);
console.info("ICE 候选者添加成功", candidate);
} else return;
} catch (error) {
console.info(
"操作失败,需要先设置【远端的SDP】:步骤1先设置远端 Offer(发起者的 SDP)步骤2再添加 ICE 候选者(此时已有 remoteDescription)步骤3创建并设置本地 Answer(依赖 remoteDescription)",
);
console.error(error);
if (error.message.includes("Unknown ufrag")) {
// ufrag 不匹配,说明是旧会话候选者,直接丢弃
console.warn("ICE 候选者 ufrag 不匹配,丢弃:", candidate);
} else {
// 其他错误(如未设置 remoteDescription),缓存候选者
console.info("缓存 ICE 候选者,待会话就绪后处理");
}
}
}
/**
* 发送自定义数据(支持字符串/对象)
* @param {string|Object} data 要发送的数据
*/
dataChannelSendMsg(data) {
if (!this.dataChannel || this.dataChannel.readyState !== "open") {
throw new Error("数据通道未连接,无法发送消息");
}
const sendData = typeof data === "object" ? JSON.stringify(data) : data;
this.dataChannel.send(sendData);
}
/**
* 关闭连接并清理所有资源
*/
close() {
// 关闭数据通道
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
}
// 关闭 PeerConnection
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
// 停止媒体流
if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop());
this.localStream = null;
}
// 清空视频
if (this.config.localVideo) this.config.localVideo.srcObject = null;
if (this.config.remoteVideo) this.config.remoteVideo.srcObject = null;
this.callbacks.onClose();
console.log("WebRTC 连接已完全关闭");
}
/**
* 同步暂停指定毫秒数(阻塞主线程) 延迟一段时间运行 await this.delay(100);
* @param {number} ms 暂停毫秒数
*/
delayAsync(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 同步暂停指定毫秒数(阻塞主线程)
* @param {number} ms 暂停毫秒数
*/
delaySync(ms) {
const start = Date.now();
// 循环等待,直到时间差达到指定毫秒数
while (Date.now() - start < ms) {
// 空循环,消耗时间
}
}
/**
* 注册事件回调
* @param {Object} handlers 回调函数集合
* 示例rtc.on({ onMessage: (data) => console.log(data) })
*/
on(handlers) {
Object.keys(handlers).forEach((key) => {
if (this.callbacks[key] && typeof handlers[key] === "function") {
this.callbacks[key] = handlers[key];
}
});
}
}
// 封装websocket
export class webSocket_ModuleSimple {
constructor(url, receiveMessageCallback = null) {
this.url = url;
this.socket = null;
this.heartbeatInterval = 30000; // 心跳间隔 30s
this.reconnectInterval = 10000; // 重连间隔 10s
this.maxReconnectAttempts = 5;
this.reconnectAttempts = 0;
this.heartbeatTimer = null;
this.stopWs = false;
this.receiveMessageCallback = receiveMessageCallback; // 接收消息回调函数
this.callbacks = {
onMessage: (data) => {}, // 收到自定义消息
};
}
connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) return;
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
console.log("连接成功", this.url);
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.socket.onmessage = (event) => {
// console.log('收到消息:', event.data);
this.receiveMessage(event);
this.startHeartbeat(); // 收到消息重置心跳
};
this.socket.onclose = () => {
console.log("连接关闭", this.url);
if (!this.stopWs) this.handleReconnect();
};
this.socket.onerror = () => {
console.log("连接错误", this.url);
this.closeHeartbeat();
};
}
/**
* 同步暂停指定毫秒数(阻塞主线程) 延迟一段时间运行 await this.delay(100);
* @param {number} ms 暂停毫秒数
*/
delayAsync(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 同步暂停指定毫秒数(阻塞主线程)
* @param {number} ms 暂停毫秒数
*/
delaySync(ms) {
const start = Date.now();
// 循环等待,直到时间差达到指定毫秒数
while (Date.now() - start < ms) {
// 空循环,消耗时间
}
}
async send(message) {
if (this.socket?.readyState === WebSocket.OPEN) {
await this.delayAsync(500);
this.socket.send(JSON.stringify(message));
} else {
console.error("WebSocket 未连接");
}
}
startHeartbeat() {
this.closeHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "heartBeat" }));
// console.log('发送心跳包');
}
}, this.heartbeatInterval);
}
closeHeartbeat() {
clearInterval(this.heartbeatTimer);
}
handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
setTimeout(() => {
console.log(`尝试重连 (${++this.reconnectAttempts})`);
this.connect();
}, this.reconnectInterval);
} else {
console.warn("达到最大重连次数,停止重连");
}
}
close() {
this.stopWs = true;
this.socket?.close();
this.closeHeartbeat();
}
/**
* 接收到消息
*/
receiveMessage(event) {
// 根据业务自行处理
// console.info('[webSocket_ModuleSimple]receiveMessage:', event.data)
this.receiveMessageCallback && this.receiveMessageCallback(event.data);
this.callbacks.onMessage(event.data);
}
/**
* 注册事件回调
* @param {Object} handlers 回调函数集合
* 示例rtc.on({ onMessage: (data) => console.log(data) })
*/
on(handlers) {
Object.keys(handlers).forEach((key) => {
if (this.callbacks[key] && typeof handlers[key] === "function") {
this.callbacks[key] = handlers[key];
}
});
}
}
// ---------------------------------------------->
// 验证与使用
// const receiveMessage = (res) => {
// console.log('接收消息回调:', res)
// }
// const wsClient = new WebSocketClient('`ws://localhost:8000/ws/0/`', receiveMessage);
// wsClient.connect();
// 发送业务消息
// wsClient.send({ type: 'message', data: 'Hello Server' });

View File

@@ -0,0 +1,385 @@
"""功能
python 获取window11电脑的屏幕的视频/音频流并保存为mp4格式文件
"""
"""安装核心依赖
& .venv/Scripts/python.exe -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --upgrade aiortc pywin32 numpy opencv-python sounddevice pycaw comtypes
"""
import os
import asyncio
import ctypes
import numpy as np
import win32gui
import win32ui
import win32con
import win32api
import sounddevice as sd
from aiortc import MediaStreamTrack, RTCPeerConnection
from aiortc.contrib.media import MediaRecorder
# -------------------------- 1. Windows 11高DPI适配 --------------------------
ctypes.windll.shcore.SetProcessDpiAwareness(2) # 解决高分屏采集偏移问题
# -------------------------- 2. 兼容VideoFrame导入 --------------------------
try:from aiortc.contrib.media import VideoFrame,AudioFrame
except ImportError:from aiortc import VideoFrame,AudioFrame
# -------------------------- 2. 屏幕视频采集轨道 --------------------------
class ScreenVideoTrack(MediaStreamTrack):
kind = "video"
def __init__(self, fps=30, scale_factor=1.0):
super().__init__()
self.fps = fps
self.scale_factor = scale_factor
self.stop_flag = False
self.pts = 0
self.frame_interval = 1.0 / fps
# 获取真实屏幕分辨率适配高DPI
self.user32 = ctypes.windll.user32
self.screen_width = self.user32.GetSystemMetrics(0)
self.screen_height = self.user32.GetSystemMetrics(1)
def capture_screen(self):
"""Windows 11原生API采集屏幕低延迟"""
left, top, width, height = 0, 0, self.screen_width, self.screen_height
# 1. 创建设备上下文DC
hdesktop = win32gui.GetDesktopWindow()
hwnd_dc = win32gui.GetWindowDC(hdesktop)
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
save_dc = mfc_dc.CreateCompatibleDC()
# 2. 复制屏幕内容到位图
save_bitmap = win32ui.CreateBitmap()
save_bitmap.CreateCompatibleBitmap(mfc_dc, width, height)
save_dc.SelectObject(save_bitmap)
save_dc.BitBlt((0, 0), (width, height), mfc_dc, (left, top), win32con.SRCCOPY)
# 3. 转换为numpy数组BGR格式
bmp_data = save_bitmap.GetBitmapBits(True)
frame = np.frombuffer(bmp_data, dtype=np.uint8).reshape((height, width, 4))
frame = frame[:, :, :3] # 去掉Alpha通道
frame = frame[:, :, ::-1] # BGRA → BGR适配aiortc
# 4. 缩放(可选,降低文件大小)
if self.scale_factor != 1.0:
new_width = int(width * self.scale_factor)
new_height = int(height * self.scale_factor)
frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_AREA)
# 5. 释放资源(避免内存泄漏)
win32gui.DeleteObject(save_bitmap.GetHandle())
save_dc.DeleteDC()
mfc_dc.DeleteDC()
win32gui.ReleaseDC(hdesktop, hwnd_dc)
return frame
async def recv(self):
"""aiortc核心方法持续返回视频帧"""
if self.stop_flag:
raise StopAsyncIteration
# 控制帧率
await asyncio.sleep(self.frame_interval)
# 获取windows11 原始的视频帧
frame_data = self.capture_screen()
# 转换为aiortc VideoFrame
video_frame = self._convert_to_video_frame(frame_data)
video_frame.pts = int(self.pts)
video_frame.time_base = np.array([1, self.fps])
self.pts += 1
return video_frame
def _convert_to_video_frame(self, img):
"""将numpy数组转换为aiortc的VideoFrame"""
return VideoFrame.from_ndarray(img, format="bgr24")
def stop(self):
"""停止屏幕采集"""
self.stop_flag = True
# -------------------------- 3. 系统音频采集轨道 --------------------------
class SystemAudioTrack(MediaStreamTrack):
kind = "audio"
def __init__(self, sample_rate=48000, channels=2):
super().__init__()
self.sample_rate = sample_rate
self.channels = channels
self.stop_flag = False
self.pts = 0
self.audio_queue = asyncio.Queue(maxsize=10) # 音频数据队列
# 启动麦克风/系统音频采集(这里默认采集麦克风,系统音频见备注)
self._start_audio_capture()
def _start_audio_capture(self):
"""启动音频采集(麦克风)"""
def audio_callback(indata, frames, time, status):
if status or self.stop_flag:
return
# 将音频数据转为float32格式aiortc要求
audio_data = indata.astype(np.float32)
self.audio_queue.put_nowait(audio_data)
# 打开音频输入流
self.audio_stream = sd.InputStream(
samplerate=self.sample_rate,
channels=self.channels,
callback=audio_callback,
blocksize=1024
)
self.audio_stream.start()
async def recv(self):
"""aiortc核心方法持续返回音频帧"""
if self.stop_flag and self.audio_queue.empty():
raise StopAsyncIteration
# 从队列获取音频数据
audio_data = await self.audio_queue.get()
sample_count = len(audio_data)
# 转换为aiortc AudioFrame
audio_frame = AudioFrame(
samples=audio_data.T, # 转置为 (channels, samples)
sample_rate=self.sample_rate,
channels=self.channels
)
audio_frame.pts = int(self.pts)
audio_frame.time_base = np.array([1, self.sample_rate])
self.pts += sample_count
return audio_frame
def stop(self):
"""停止音频采集"""
self.stop_flag = True
self.audio_stream.stop()
self.audio_stream.close()
# -------------------------- 2. 工具函数枚举音频设备解决Error querying device -1错误 --------------------------
def list_audio_devices():
"""枚举所有音频设备输出ID和名称方便手动选择"""
print("📢 可用音频设备列表:")
devices = sd.query_devices()
for idx, dev in enumerate(devices):
print(f"ID {idx}: {dev['name']} | 输入通道:{dev['max_input_channels']} | 输出通道:{dev['max_output_channels']}")
return devices
def get_vb_cable_device_id():
"""查找VB-Cable虚拟音频设备ID系统声音采集必需"""
devices = sd.query_devices()
for idx, dev in enumerate(devices):
if "Cable" in dev['name'] and dev['max_input_channels'] > 0:
return idx
raise Exception("❌ 未找到VB-Cable虚拟音频设备\n 请先安装https://vb-audio.com/Cable/")
# -------------------------- 3. 系统声音录制为MP3 --------------------------
def record_system_audio_to_mp3(output_mp3="system_audio.mp3", duration=5, sample_rate=48000):
"""
录制系统声音为MP3需先安装VB-Cable并设置系统音频输出到VB-Cable
:param output_mp3: 输出MP3文件名
:param duration: 录制时长(秒)
:param sample_rate: 采样率
"""
# 修复device -1错误手动指定VB-Cable设备ID
vb_cable_id = get_vb_cable_device_id()
print(f"✅ 已找到VB-Cable设备ID{vb_cable_id}")
# 1. 录制音频为WAV先录WAV再转MP3避免编码问题
wav_file = "temp_audio.wav"
print(f"🎙️ 开始录制系统声音({duration}秒)...")
audio_data = sd.rec(
int(duration * sample_rate),
samplerate=sample_rate,
channels=2,
dtype='float32',
device=vb_cable_id # 关键指定有效设备ID解决-1错误
)
sd.wait() # 等待录制完成
# 2. 保存为WAV临时文件
with wave.open(wav_file, 'wb') as wf:
wf.setnchannels(2)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes((audio_data * 32767).astype(np.int16).tobytes())
# 3. WAV转MP3用OpenCV/ffmpeg无需额外依赖
print("🔄 转换WAV到MP3...")
os.system(f"ffmpeg -y -i {wav_file} -codec:a libmp3lame -b:a 192k {output_mp3}")
# 4. 删除临时WAV文件
if os.path.exists(wav_file):
os.remove(wav_file)
print(f"✅ 系统声音已保存:{output_mp3}")
# -------------------------- 4. 屏幕60帧/秒截图5秒并合成MP4 --------------------------
def capture_screen_60fps(output_dir="screen_shots", duration=5, fps=60):
"""
5秒内每秒60张屏幕截图共300张并合成高帧率MP4
:param output_dir: 截图保存目录
:param duration: 录制时长(秒)
:param fps: 帧率60帧/秒)
"""
# 1. 创建截图目录
if not os.path.exists(output_dir):
os.makedirs(output_dir)
print(f"🖥️ 开始屏幕截图({duration}秒,{fps}帧/秒)...")
# 2. 获取屏幕真实分辨率适配高DPI
user32 = ctypes.windll.user32
screen_width = user32.GetSystemMetrics(0)
screen_height = user32.GetSystemMetrics(1)
total_frames = duration * fps # 总帧数5*60=300帧
frame_interval = 1.0 / fps # 每帧间隔约16.67ms
# 3. 循环截图60帧/秒)
for frame_idx in range(total_frames):
# Windows原生API采集屏幕低延迟适配60帧
hdesktop = win32gui.GetDesktopWindow()
hwnd_dc = win32gui.GetWindowDC(hdesktop)
mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
save_dc = mfc_dc.CreateCompatibleDC()
save_bitmap = win32ui.CreateBitmap()
save_bitmap.CreateCompatibleBitmap(mfc_dc, screen_width, screen_height)
save_dc.SelectObject(save_bitmap)
save_dc.BitBlt((0, 0), (screen_width, screen_height), mfc_dc, (0, 0), win32con.SRCCOPY)
# 转换为numpy数组并保存为PNG无损
bmp_data = save_bitmap.GetBitmapBits(True)
frame = np.frombuffer(bmp_data, dtype=np.uint8).reshape((screen_height, screen_width, 4))
frame = frame[:, :, :3][:, :, ::-1] # BGRA→BGR
# 保存截图命名frame_0001.png ~ frame_0300.png
frame_filename = os.path.join(output_dir, f"frame_{frame_idx+1:04d}.png")
cv2.imwrite(frame_filename, frame, [cv2.IMWRITE_PNG_COMPRESSION, 0])
# 释放资源
win32gui.DeleteObject(save_bitmap.GetHandle())
save_dc.DeleteDC()
mfc_dc.DeleteDC()
win32gui.ReleaseDC(hdesktop, hwnd_dc)
# 控制帧率确保每秒60帧
asyncio.run(
asyncio.sleep(frame_interval)
)
# 进度提示
if (frame_idx + 1) % 60 == 0:
print(f" 已截图:{frame_idx+1}/{total_frames}帧({int((frame_idx+1)/total_frames*100)}%")
# 4. 将截图合成60帧/秒的MP4
print("🔄 将截图合成60帧/秒MP4...")
output_mp4 = "screen_60fps.mp4"
# FFmpeg合成命令H.264编码60帧/秒)
ffmpeg_cmd = (
f'ffmpeg -y -framerate {fps} -i {output_dir}/frame_%04d.png '
f'-c:v libx264 -r {fps} -pix_fmt yuv420p {output_mp4}'
)
os.system(ffmpeg_cmd)
print(f"✅ 60帧屏幕视频已保存{output_mp4}")
print(f"📁 原始截图保存在:{output_dir}")
# -------------------------- 4. 主逻辑采集并保存为MP4 --------------------------
async def record_screen_to_mp4(output_file="screen_record.mp4", duration=10):
"""
录制屏幕+音频到MP4
:param output_file: 输出MP4文件名
:param duration: 录制时长(秒)
"""
# 1. 创建音视频轨道
video_track = ScreenVideoTrack(fps=30, scale_factor=0.8) # 0.8倍缩放,减小文件
audio_track = SystemAudioTrack(sample_rate=48000)
# 2. 创建MediaRecorder封装为MP4
recorder = MediaRecorder(
output_file,
format="mp4", # 指定输出格式
options={
"video_codec": "h264", # H.264编码,兼容性最好
"audio_codec": "aac", # AAC音频编码
"video_bitrate": "2000k" # 视频码率(可调整)
}
)
# 3. 添加音视频轨道到录制器
recorder.addTrack(video_track)
recorder.addTrack(audio_track)
# 4. 开始录制
print(f"✅ 开始录制屏幕,时长{duration}秒,输出文件:{output_file}")
await recorder.start()
# 5. 录制指定时长
await asyncio.sleep(duration)
# 6. 停止录制并清理资源
print("🔚 录制结束,正在保存文件...")
video_track.stop()
audio_track.stop()
await recorder.stop()
print(f"✅ 文件已保存:{output_file}")
# -------------------------- 5. 运行入口 --------------------------
if __name__ == "__main__":
import cv2 # 延迟导入,避免启动报错
# 列出所有 音频设备
all_audio_devices = list_audio_devices()
# 运行录制录制10秒输出screen_record.mp4
try:
# asyncio.run(record_screen_to_mp4(output_file="screen_record.mp4", duration=10))
# 第二步录制系统声音为MP35秒
if all_audio_devices:
record_system_audio_to_mp3(output_mp3="system_audio.mp3", duration=5)
# 第三步屏幕60帧/秒截图5秒并合成MP4
capture_screen_60fps(output_dir="screen_shots", duration=5, fps=60)
pass
except KeyboardInterrupt:
print("\n🛑 录制被手动终止")
except Exception as e:
print(f"❌ 录制出错:{str(e)}")
print("💡 排查步骤:")
print(" 1. 确认FFmpeg已添加到系统PATH")
print(" 2. 确认依赖包已安装pip install -r requirements.txt")
print(" 3. 以管理员身份运行脚本")
# -------------------------- 备注:系统音频采集(进阶) --------------------------
# 如需采集系统播放的音频而非麦克风需替换SystemAudioTrack为以下逻辑
# 1. 安装虚拟音频线VB-Cablehttps://vb-audio.com/Cable/
# 2. 将系统音频输出设置为VB-Cable
# 3. 将SystemAudioTrack的设备ID指定为VB-Cable的输入设备ID
# 示例:
# def _start_audio_capture(self):
# # 列出所有音频设备找到VB-Cable的ID
# devices = sd.query_devices()
# cable_id = None
# for idx, dev in enumerate(devices):
# if "Cable" in dev['name'] and dev['max_input_channels'] > 0:
# cable_id = idx
# break
# if cable_id is None:
# raise Exception("未找到VB-Cable虚拟音频设备")
# # 用VB-Cable ID启动采集
# self.audio_stream = sd.InputStream(
# device=cable_id,
# samplerate=self.sample_rate,
# channels=self.channels,
# callback=self.audio_callback,
# blocksize=1024
# )
# self.audio_stream.start()