项目备份
This commit is contained in:
BIN
module/__pycache__/record_screen_to_mp4.cpython-312.pyc
Normal file
BIN
module/__pycache__/record_screen_to_mp4.cpython-312.pyc
Normal file
Binary file not shown.
287
module/aiortc_parse.py
Normal file
287
module/aiortc_parse.py
Normal 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,转为 BGR(OpenCV 格式)
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||
# 3. 转换为 VideoFrame(aiortc 要求的格式)
|
||||
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
68
module/main.js
Normal 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
609
module/newWebRtcSync.js
Normal 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' });
|
||||
385
module/record_screen_to_mp4.py
Normal file
385
module/record_screen_to_mp4.py
Normal 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))
|
||||
# 第二步:录制系统声音为MP3(5秒)
|
||||
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-Cable):https://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()
|
||||
Reference in New Issue
Block a user