项目备份

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

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Build and Release Folders
bin-debug/
bin-release/
[Oo]bj/
[Bb]in/
# Other files and folders
.settings/
# Executables
*.swf
*.air
*.ipa
*.apk
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important
# information for Eclipse / Flash Builder.
.venv/
.logs/
dist/
build/
*.png
*.mp4
*.mp3
*.spec

2
.release/app.py Normal file
View File

@@ -0,0 +1,2 @@
import os
print(12)

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"editor.fontSize": 12,
"editor.formatOnSave": true,
"files.autoSave": "onFocusChange",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.wordWrap": "on",
"workbench.colorTheme": "VS Code Dark",
"editor.formatOnPaste": true
}

89
aiortcToJs.py Normal file
View File

@@ -0,0 +1,89 @@
"""
尝试通过aiortc获取屏幕传递到前端js
"""
import webview
import threading
import time,os
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
from module.record_screen_to_mp4 import ScreenVideoTrack,SystemAudioTrack
# ---------------------- 1. 定义供 JS 调用的 Python 函数 ----------------------
class Api:
"""暴露给 JS 的 Python 接口类(所有方法都会被 JS 访问)"""
def __init__(self):
self.video_track = None
self.audio_track = None
def set_window(self, window):
"""初始化时绑定窗口实例"""
self.window = window
async def fetch_media(self):
self.video_track = ScreenVideoTrack(fps=30, scale_factor=0.8)
self.audio_track = SystemAudioTrack(sample_rate=48000)
return self.video_track,self.audio_track
def python_hello(self, name):
"""JS 调用的同步函数:接收参数,返回结果"""
print(f"[Python] 收到 JS 调用,参数:{name}")
return f"Hello {name}! 这是 Python 返回的消息"
def python_async_task(self, duration):
"""JS 调用的异步函数:模拟耗时任务(不阻塞前端)"""
def async_task():
print(f"[Python] 开始异步任务,耗时 {duration}")
time.sleep(duration)
# 异步任务完成后,主动调用 JS 函数通知结果
self.window.evaluate_js(f'js_receive_python_msg("异步任务完成!耗时 {duration}")')
print(f"[Python] 异步任务结束")
# 启动子线程执行异步任务,避免阻塞 UI
threading.Thread(target=async_task, daemon=True).start()
return "异步任务已启动,请等待结果通知"
def python_close_window(self):
"""JS 调用关闭窗口"""
print("[Python] 收到关闭窗口请求")
self.window.destroy()
# ---------------------- 2. Python 主动调用 JS 函数 ----------------------
def python_call_js_periodically(window):
"""Python 定时调用 JS 函数(模拟主动推送数据)"""
count = 0
while window:
count += 1
# 执行 JS 函数,传递参数
window.evaluate_js(f'js_receive_python_msg("Python 主动推送:第 {count} 条消息")')
time.sleep(3) # 每 3 秒推送一次
if count >= 5:
break
# ---------------------- 3. 启动窗口 ----------------------
if __name__ == "__main__":
# 创建 API 实例
api = Api()
# 加载本地 HTML 文件(也可加载远程 URLurl="https://xxx.com"
window = webview.create_window(
title="PyWebView 双向通信示例",
# url="app_JsToPy.html", # 前端页面路径
url=f"file:///{CURRENT_DIR}/app.html",
resizable=True,
js_api=api # 关键:暴露 Python API 给 JS
)
# 绑定窗口实例到 API供异步任务调用 JS
api.set_window(window)
# 启动 Python 主动调用 JS 的线程(非阻塞)
threading.Thread(target=python_call_js_periodically, args=(window,), daemon=True).start()
# 运行窗口(阻塞主线程)
webview.start(
private_mode=False, # WebRTC必需关闭私有模式
debug=True, # 开发环境开启调试,生产环境关闭
http_server=True # 启用内置 HTTP 服务器(加载本地 HTML 必需)
)

52
app.html Normal file
View File

@@ -0,0 +1,52 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<title>测试应用</title>
<style>
body {
padding: 0;
margin: 0;
}
li {
display: block;
}
#main {
display: flex;
}
</style>
</head>
<body>
<header></header>
<p>1111111111111</p>
<main id="main">
<video id="localVideo" autoplay muted></video>
</main>
<footer></footer>
<script>
// 初始化MediaSource ;aiortc 采集的帧转换为 Base64 图片流;用 MediaSource 拼接帧数据
async function initMediaSource() {
mediaSource = await window.pywebview.api.fetch_media();
videoEl.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener("sourceopen", () => {
sourceBuffer = mediaSource.addSourceBuffer(
'video/mp4; codecs="avc1.42E01E"',
);
sourceBuffer.mode = "sequence";
});
}
const localVideo = document.getElementById("localVideo");
localVideo.srcObject = callPythonFetchMedia();
/**
* 供 Python 调用的 JS 函数
* window.evaluate_js(f'js_receive_python_msg("msg")')
*/
function js_receive_python_msg(msg) {
console.info(`[JS] 收到 Python 消息:${msg}`);
}
</script>
<script type="module" src="module/main.js"></script>
</body>
</html>

61
app.py Normal file
View File

@@ -0,0 +1,61 @@
# 关键确保Windows安装了WebView2运行时pywebview2必需
# 下载地址https://developer.microsoft.com/zh-CN/microsoft-edge/webview2/#download-section
import sys,os,time
import threading
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
'''定义供 JS 调用的 Python 函数'''
class Api:
"""暴露给 JS 的 Python 接口类(所有方法都会被 JS 访问)"""
def __init__(self):
self.window = None # 保存窗口实例,用于 Python 主动调用 JS
def set_window(self, window):
"""初始化时绑定窗口实例"""
self.window = window
def python_close_window(self):
"""JS 调用关闭窗口"""
print("[Python] 收到关闭窗口请求")
self.window.destroy()
'''Python 主动调用 JS 函数'''
def python_call_js_periodically(window):
"""Python 定时调用 JS 函数(模拟主动推送数据)"""
count = 0
while window:
count += 1
# 执行 JS 函数,传递参数
window.evaluate_js(f'js_receive_python_msg("Python主动推送:第{count}条消息")')
time.sleep(3) # 每 3 秒推送一次
if count >= 5:break
import webview
if __name__ == '__main__':
api = Api()
window = webview.create_window(
title='PyWebView', # 窗口标题
url=f"file:///{CURRENT_DIR}/app.html",
resizable=True, # 是否允许调整窗口大小(默认 True
transparent=True,
# frameless=True,
# easy_drag=True, # 窗口无边框
js_api=api
)
# 绑定窗口实例到 API供异步任务调用 JS
api.set_window(window)
threading.Thread(target=python_call_js_periodically, args=(window,), daemon=True).start()
webview.start(
private_mode=False, # WebRTC必需关闭私有模式
# debug=True,
http_server=True
)

8
assets/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

6016
assets/dexie.js Normal file

File diff suppressed because it is too large Load Diff

29
docs/README.md Normal file
View File

@@ -0,0 +1,29 @@
# deskflow
#### 介绍
deskflow
### 激活windows虚拟环境
- & .venv\Scripts\Activate.ps1
#### 软件架构
- [https://pywebview.idepy.com/]
#### 安装库
- docs/venv.md
#### 安装教程
1. xxxx
2. xxxx
3. xxxx
#### 使用说明
1. xxxx
2. xxxx
3. xxxx

108
docs/app_JsToPy.html Normal file
View File

@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PyWebView 双向通信</title>
<style>
body {
padding: 20px;
font-family: Arial;
}
.container {
max-width: 600px;
margin: 0 auto;
}
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
height: 300px;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="container">
<h2>Python ↔ JS 双向通信</h2>
<!-- JS 调用 Python 函数(同步) -->
<div>
<input type="text" id="nameInput" placeholder="输入姓名">
<button onclick="callPythonSync()">调用 Python 同步函数</button>
</div>
<!-- JS 调用 Python 异步函数 -->
<div>
<input type="number" id="durationInput" placeholder="异步任务耗时(秒)" value="2">
<button onclick="callPythonAsync()">调用 Python 异步函数</button>
</div>
<!-- 关闭窗口 -->
<button onclick="callPythonCloseWindow()">关闭窗口</button>
<!-- 日志显示区域 -->
<div id="log"></div>
</div>
<script>
// ---------------------- 1. JS 调用 Python 函数 ----------------------
// 同步调用
async function callPythonSync() {
const name = document.getElementById('nameInput').value || '匿名用户';
try {
// 核心:通过 window.pywebview.api 调用 Python 函数
const result = await window.pywebview.api.python_hello(name);
addLog(`[JS] 调用 Python 同步函数成功:${result}`);
} catch (e) {
addLog(`[JS] 调用 Python 同步函数失败:${e}`);
}
}
// 异步调用Python 执行完后主动回调 JS
async function callPythonAsync() {
const duration = document.getElementById('durationInput').value;
try {
const result = await window.pywebview.api.python_async_task(duration);
addLog(`[JS] 调用 Python 异步函数成功:${result}`);
} catch (e) {
addLog(`[JS] 调用 Python 异步函数失败:${e}`);
}
}
// 调用 Python 关闭窗口
async function callPythonCloseWindow() {
await window.pywebview.api.python_close_window();
}
// ---------------------- 2. 供 Python 调用的 JS 函数 ----------------------
function js_receive_python_msg(msg) {
addLog(`[JS] 收到 Python 消息:${msg}`);
}
// ---------------------- 辅助函数:添加日志 ----------------------
function addLog(text) {
const logDiv = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logDiv.innerHTML += `[${time}] ${text}<br>`;
// 自动滚动到底部
logDiv.scrollTop = logDiv.scrollHeight;
}
// 页面加载完成后提示
window.onload = () => {
addLog("页面加载完成,可开始双向通信测试");
};
</script>
</body>
</html>

77
docs/app_JsToPy.py Normal file
View File

@@ -0,0 +1,77 @@
import webview
import threading
import time,os
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
# ---------------------- 1. 定义供 JS 调用的 Python 函数 ----------------------
class Api:
"""暴露给 JS 的 Python 接口类(所有方法都会被 JS 访问)"""
def __init__(self):
self.window = None # 保存窗口实例,用于 Python 主动调用 JS
def set_window(self, window):
"""初始化时绑定窗口实例"""
self.window = window
def python_hello(self, name):
"""JS 调用的同步函数:接收参数,返回结果"""
print(f"[Python] 收到 JS 调用,参数:{name}")
return f"Hello {name}! 这是 Python 返回的消息"
def python_async_task(self, duration):
"""JS 调用的异步函数:模拟耗时任务(不阻塞前端)"""
def async_task():
print(f"[Python] 开始异步任务,耗时 {duration}")
time.sleep(duration)
# 异步任务完成后,主动调用 JS 函数通知结果
self.window.evaluate_js(f'js_receive_python_msg("异步任务完成!耗时 {duration}")')
print(f"[Python] 异步任务结束")
# 启动子线程执行异步任务,避免阻塞 UI
threading.Thread(target=async_task, daemon=True).start()
return "异步任务已启动,请等待结果通知"
def python_close_window(self):
"""JS 调用关闭窗口"""
print("[Python] 收到关闭窗口请求")
self.window.destroy()
# ---------------------- 2. Python 主动调用 JS 函数 ----------------------
def python_call_js_periodically(window):
"""Python 定时调用 JS 函数(模拟主动推送数据)"""
count = 0
while window.is_alive():
count += 1
# 执行 JS 函数,传递参数
window.evaluate_js(f'js_receive_python_msg("Python 主动推送:第 {count} 条消息")')
time.sleep(3) # 每 3 秒推送一次
if count >= 5:
break
# ---------------------- 3. 启动窗口 ----------------------
if __name__ == "__main__":
# 创建 API 实例
api = Api()
# 加载本地 HTML 文件(也可加载远程 URLurl="https://xxx.com"
window = webview.create_window(
title="PyWebView 双向通信示例",
# url="app_JsToPy.html", # 前端页面路径
url=f"file:///{CURRENT_DIR}/app_JsToPy.html",
resizable=True,
js_api=api # 关键:暴露 Python API 给 JS
)
# 绑定窗口实例到 API供异步任务调用 JS
api.set_window(window)
# 启动 Python 主动调用 JS 的线程(非阻塞)
threading.Thread(target=python_call_js_periodically, args=(window,), daemon=True).start()
# 运行窗口(阻塞主线程)
webview.start(
private_mode=False, # WebRTC必需关闭私有模式
debug=True, # 开发环境开启调试,生产环境关闭
http_server=True # 启用内置 HTTP 服务器(加载本地 HTML 必需)
)

17
docs/releaseNotes.md Normal file
View File

@@ -0,0 +1,17 @@
# 版本日志
## 2026.03.
## 2026.03.16
### 各端口规划
- exe:windows端口,作为被控端
- apk:安卓端,作为主控端
- web:websocket接口和主控端
### 功能
- 全局
- 三端都用手机号生成唯一的uuid, 已这个uuid作为控制唯一设备码
- 剪切板同步;
- 局部
- exe:用python获取windows屏幕和系统音频转化之后转换未webrtc媒体流
- web:web接口,生成 websocket通信/ice交换接口
- apk:用python获取Android屏幕和系统音频转化之后转换未webrtc媒体流

14
docs/venv.md Normal file
View File

@@ -0,0 +1,14 @@
# 环境安装
- pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --upgrade loguru pywebview aiortc fastapi pyinstaller uvicorn[standard] requests pystray pillow pywin32 buildozer opencv-python numpy mss
# 涉及文档
- [https://pywebview.idepy.com/guide/usage.html]
- [https://segmentfault.com/a/1190000046888066]

View File

@@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebRTC 接收方</title>
<style>
.video-container {
display: flex;
gap: 20px;
margin: 20px 0;
}
video {
width: 400px;
height: 300px;
border: 1px solid #ccc;
}
.controls {
margin: 20px 0;
}
button {
padding: 10px 20px;
margin-right: 10px;
}
#dataChannelLog {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
height: 240px;
overflow-y: auto;
}
</style>
</head>
<body>
<h1>WebRTC 接收方</h1>
<div class="video-container">
<div>
<h3>本地视频</h3>
<video id="localVideo" autoplay muted></video>
</div>
<div>
<h3>远程视频</h3>
<video id="remoteVideo" autoplay></video>
</div>
</div>
<div class="controls">
<button id="startBtn">3.获取媒体信息</button>
<button id="answerBtn" disabled>4.接听</button>
<button id="sendBtn" disabled>发送消息</button>
<input type="text" id="messageInput" placeholder="输入消息" disabled>
</div>
<div>
<h3>数据通道日志</h3>
<div id="dataChannelLog"></div>
</div>
<script>
// 全局变量
let localStream;
let pc;
let dataChannel;
let clientCandidates = [];
let ws; // 改为let方便重新创建
let isWsConnected = false; // WebSocket连接状态标记
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const startBtn = document.getElementById('startBtn');
const answerBtn = document.getElementById('answerBtn');
const sendBtn = document.getElementById('sendBtn');
const messageInput = document.getElementById('messageInput');
const dataChannelLog = document.getElementById('dataChannelLog');
let offerData = null;
// 初始化WebSocket连接
function initWebSocket() {
// 先关闭旧连接
if (ws) {
try { ws.close() }
catch (e) { }
}
const user_id = 1;
const wsService = `wss://api.vlos.net/wsSignaling/${user_id}/`
// 创建新连接
// ws = new WebSocket(`ws://localhost:8000/wsSignaling/1/`);
ws = new WebSocket(wsService);
// WebSocket连接成功
ws.onopen = () => {
console.info('WebSocket已连接');
isWsConnected = true;
addToLog('信令通道已建立');
};
// WebSocket 响应消息处理
ws.onmessage = async (event) => {
if (!isWsConnected) return;
const message = JSON.parse(event.data);
console.info('收到信令消息:', message.type);
try {
switch (message.type) {
case 'offer':
if (pc) {
offerData = message.data;
console.info("offerData", offerData)
// 如果已初始化,直接启用接听按钮
if (pc) { answerBtn.disabled = false; }
}
break;
case 'candidate':
if (message.data && pc) {
await pc.addIceCandidate(new RTCIceCandidate(message.data));
}
console.info(`17,发起方响应响应方发送过来的远程候选`)
break;
case 'hangup':
handleHangup();
break;
}
} catch (error) {
console.error('处理信令消息出错:', error);
addToLog(`处理信令错误: ${error.message}`);
}
};
// WebSocket关闭
ws.onclose = () => {
console.info('WebSocket已关闭');
isWsConnected = false;
addToLog('信令通道已关闭');
// 自动重连逻辑(可选)
setTimeout(() => {
if (!isWsConnected) {
addToLog('尝试重新连接信令服务器...');
initWebSocket();
}
}, 3000);
};
// WebSocket错误
ws.onerror = (error) => {
console.error('WebSocket错误:', error);
isWsConnected = false;
addToLog(`信令通道错误: ${error.message}`);
};
}
// 安全的WebSocket发送方法
function sendSignalingMessage(message) {
// 检查WebSocket状态
if (!ws || !isWsConnected || ws.readyState !== WebSocket.OPEN) {
addToLog('无法发送信令消息:信令通道未连接');
return false;
}
try {
ws.send(JSON.stringify(message));
return true;
} catch (error) {
console.error('发送信令消息失败:', error);
addToLog(`发送信令失败: ${error.message}`);
return false;
}
}
// 初始化WebSocket
initWebSocket();
// 开始按钮 - 获取本地媒体流
startBtn.addEventListener('click', async () => {
try {
// 获取本地音视频流
// localStream = await navigator.mediaDevices.getUserMedia({video: true,audio: true});
// 获取屏幕共享
localStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
// 创建 PeerConnection
createPeerConnection();
// 如果已有 offer启用接听按钮
if (offerData) { answerBtn.disabled = false; }
console.info(`17,响应方响应发起方过来的offer`, offerData)
startBtn.disabled = true;
} catch (error) {
console.error('获取媒体流失败:', error);
alert('无法访问摄像头/麦克风,请检查权限');
}
});
// 接听按钮 - 应答呼叫
answerBtn.addEventListener('click', async () => {
try {
if (!offerData) return;
addToLog(`初始化answer`)
// 设置远程描述
await pc.setRemoteDescription(new RTCSessionDescription(offerData));
console.info(`18,响应方将发起方发送过来的offer设置到本地pc中的远程描述setRemoteDescription`)
// 创建 answer
const answer = await pc.createAnswer();
console.info(`19,响应方创建anser`)
await pc.setLocalDescription(answer);
console.info(`20,响应方设置answer到本地pc中setLocalDescription`)
// 发送 answer 到信令服务器
ws.send(JSON.stringify({ type: 'answer', data: answer }));
console.info(`21,响应方发送answer到发起方`)
// answerBtn.disabled = true;
sendBtn.disabled = false;
messageInput.disabled = false;
} catch (error) {
console.error('应答呼叫失败:', error);
}
});
// 发送数据通道消息
sendBtn.addEventListener('click', () => {
const message = messageInput.value.trim();
if (!message || !dataChannel || dataChannel.readyState !== 'open') return;
dataChannel.send(message);
addToLog(`响应方: ${message}`);
messageInput.value = '';
});
// 创建 PeerConnection
function createPeerConnection() {
// 配置 ICE 服务器
const config = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
// 创建 PeerConnection
pc = new RTCPeerConnection(config);
console.info(`12,响应方创建自身PeerConnection`)
// 添加本地媒体流到 PeerConnection
localStream.getTracks().forEach(track => { pc.addTrack(track, localStream) });
console.info(`13,响应方添加本地媒体流到 PeerConnection`)
// 响应远程流
pc.ontrack = (event) => { remoteVideo.srcObject = event.streams[0] };
console.info(`14,响应方响应远程流`)
// 响应数据通道
pc.ondatachannel = (event) => {
dataChannel = event.channel;
setupDataChannel();
};
console.info(`15,响应方响应数据通道`)
// 响应 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
let ws_json = JSON.stringify({ type: 'candidate', data: event.candidate });
ws.send(ws_json);
console.info(`响应 ICE 候选->${ws_json}`)
}
};
console.info(`16,响应方响应响应方自身ICE候选,并发送到发起方`)
// 响应连接状态变化
pc.oniceconnectionstatechange = () => {
console.info('ICE 连接状态:', pc.iceConnectionState);
if (pc.iceConnectionState === 'disconnected') {
handleHangup();
}
};
}
// 设置数据通道
function setupDataChannel() {
// 数据通道打开
dataChannel.onopen = () => {
addToLog('数据通道已打开');
};
// 收到数据通道消息
dataChannel.onmessage = (event) => {
addToLog(`对方: ${event.data}`);
};
// 数据通道关闭
dataChannel.onclose = () => {
addToLog('数据通道已关闭');
sendBtn.disabled = true;
messageInput.disabled = true;
};
// 数据通道错误
dataChannel.onerror = (error) => {
console.error('数据通道错误:', error);
addToLog(`数据通道错误: ${error.message}`);
};
}
// 处理挂断
function handleHangup() {
if (pc) {
pc.close();
pc = null;
}
if (localStream) {
localStream.getTracks().forEach(
track => track.stop()
);
localStream = null;
}
localVideo.srcObject = null;
remoteVideo.srcObject = null;
startBtn.disabled = false;
answerBtn.disabled = true;
sendBtn.disabled = true;
messageInput.disabled = true;
offerData = null;
addToLog('连接已断开');
}
// 添加日志
function addToLog(message) {
const time = new Date().toLocaleTimeString();
dataChannelLog.innerHTML += `[${time}] ${message}<br>`;
dataChannelLog.scrollTop = dataChannelLog.scrollHeight;
}
// 页面关闭时清理
window.addEventListener('beforeunload', () => {
ws.send(JSON.stringify({ type: 'hangup' }));
if (pc) pc.close();
if (localStream) localStream.getTracks().forEach(track => track.stop());
});
</script>
</body>
</html>

View File

@@ -0,0 +1,339 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebRTC 请求方</title>
<style>
.video-container {
display: flex;
gap: 20px;
margin: 20px 0;
}
video {
width: 400px;
height: 300px;
border: 1px solid #ccc;
}
.controls {
margin: 20px 0;
}
button {
padding: 10px 20px;
margin-right: 10px;
}
#dataChannelLog {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
height: 240px;
overflow-y: auto;
}
</style>
</head>
<body>
<h1>WebRTC 请求方</h1>
<div class="video-container">
<div>
<h3>本地视频</h3>
<video id="localVideo" autoplay muted></video>
</div>
<div>
<h3>远程视频</h3>
<video id="remoteVideo" autoplay></video>
</div>
</div>
<div class="controls">
<button id="startBtn">1.获取媒体信息</button>
<button id="callBtn" disabled>2.呼叫</button>
<button id="sendBtn" disabled>发送消息</button>
<input type="text" id="messageInput" placeholder="输入消息">
</div>
<div>
<h3>数据通道日志</h3>
<div id="dataChannelLog"></div>
</div>
<script>
// 全局变量
let localStream;
let pc;
let dataChannel;
let clientCandidates = [];
let ws; // 改为let方便重新创建
let isWsConnected = false; // WebSocket连接状态标记
const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const startBtn = document.getElementById('startBtn');
const callBtn = document.getElementById('callBtn');
const sendBtn = document.getElementById('sendBtn');
const messageInput = document.getElementById('messageInput');
const dataChannelLog = document.getElementById('dataChannelLog');
// 初始化WebSocket连接
function initWebSocket() {
// 先关闭旧连接
if (ws) {
try { ws.close() }
catch (e) { }
isWsConnected = false;
}
const user_id = 0;
const wsService = `wss://api.vlos.net/wsSignaling/${user_id}/`
// 创建新连接
// ws = new WebSocket(`ws://localhost:8000/wsSignaling/0/`);
ws = new WebSocket(wsService);
// WebSocket连接成功
ws.onopen = () => {
console.info('WebSocket已连接');
isWsConnected = true;
addToLog('信令通道已建立');
};
// WebSocket消息处理
ws.onmessage = async (event) => {
if (!isWsConnected) return;
const message = JSON.parse(event.data);
console.info('收到信令消息:', message.type);
try {
switch (message.type) {
case 'answer':
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(message.data));
console.info(`22,请求方监听对方发送过来的answer到本地setRemoteDescription`)
}
break;
case 'candidate':
if (message.data && pc) {
await pc.addIceCandidate(new RTCIceCandidate(message.data));
}
console.info(`17,请求方监听监听方发送过来的远程候选`)
break;
case 'hangup':
handleHangup();
break;
}
} catch (error) {
console.error('处理信令消息出错:', error);
addToLog(`处理信令错误: ${error.message}`);
}
};
// WebSocket关闭
ws.onclose = () => {
console.info('WebSocket已关闭');
isWsConnected = false;
addToLog('信令通道已关闭');
// 自动重连逻辑(可选)
setTimeout(() => {
if (!isWsConnected) {
addToLog('尝试重新连接信令服务器...');
initWebSocket();
}
}, 3000);
};
// WebSocket错误
ws.onerror = (error) => {
console.error('WebSocket错误:', error);
isWsConnected = false;
addToLog(`信令通道错误: ${error.message}`);
};
}
// 安全的WebSocket发送方法
function sendSignalingMessage(message) {
// 检查WebSocket状态
if (!ws || !isWsConnected || ws.readyState !== WebSocket.OPEN) {
addToLog('无法发送信令消息:信令通道未连接');
return false;
}
try {
ws.send(JSON.stringify(message));
return true;
} catch (error) {
console.error('发送信令消息失败:', error);
addToLog(`发送信令失败: ${error.message}`);
return false;
}
}
// 初始化WebSocket
initWebSocket();
// 开始按钮 - 获取本地媒体流
startBtn.addEventListener('click', async () => {
try {
console.info(`2,请求方捕获媒体文件`)
// 获取本地音视频流
// localStream = await navigator.mediaDevices.getUserMedia({video: true,audio: true});
// 获取屏幕共享
localStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
localVideo.srcObject = localStream;
addToLog(`捕获本地媒体`);
// 创建 PeerConnection
createPeerConnection();
addToLog(`创建 PeerConnection`);
// 启用呼叫按钮
startBtn.disabled = true;
callBtn.disabled = false;
} catch (error) {
console.error('获取媒体流失败:', error);
alert('无法访问摄像头/麦克风,请检查权限');
}
});
// 呼叫按钮 - 请求呼叫
callBtn.addEventListener('click', async () => {
try {
// 创建数据通道
dataChannel = pc.createDataChannel('chat');
setupDataChannel();
addToLog(`创建dataChannel`);
console.info(`7,请求方创建dataChannel`)
// 创建 offer
const offer = await pc.createOffer();
console.info(`8,请求方创建offer`)
await pc.setLocalDescription(offer);
console.info(`9,请求方保存自己生成的信令`)
addToLog(`创建offer`);
// 发送 offer 到信令服务器
ws.send(JSON.stringify({ type: 'offer', data: offer }));
addToLog(`发送offer`);
console.info(`10,请求方发送信令(offer)到远程端`)
// callBtn.disabled = true;
sendBtn.disabled = false;
messageInput.disabled = false;
} catch (error) {
console.error('请求呼叫失败:', error);
}
});
// 发送数据通道消息
sendBtn.addEventListener('click', () => {
const message = messageInput.value.trim();
addToLog(`dataChannel.readyState-> ${dataChannel.readyState}`);
if (!message || !dataChannel || dataChannel.readyState !== 'open') {
console.info(`message:${message}`)
console.info(`dataChannel:${dataChannel}`)
console.info(`dataChannel.readyState:${dataChannel.readyState}`)
return
};
dataChannel.send(message);
addToLog(`请求方: ${message}`);
messageInput.value = '';
});
// 创建 PeerConnection
async function createPeerConnection() {
// 配置 ICE 服务器 (使用免费的谷歌服务器)
const config = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
// 创建 PeerConnection
pc = new RTCPeerConnection(config);
console.info(`3,创建请求方PeerConnection`)
// 监听 ICE 候选
pc.onicecandidate = (event) => {
if (event.candidate) {
// 使用安全发送方法
clientCandidates.push(event.candidate);
// sendSignalingMessage({type: 'candidate',data: event.candidate});
}
};
console.info(`监听ICE 候选 ->${clientCandidates}`);
console.info(`6,请求方监听请求方自己信令ice,并发送给监听方`)
// 添加本地媒体流到PeerConnection
localStream.getTracks().forEach(track => { pc.addTrack(track, localStream) });
console.info(`4,请求方添加本地媒体到PeerConnection`)
// 监听远程流
pc.ontrack = (event) => { remoteVideo.srcObject = event.streams[0] };
console.info(`5,请求方监听远程媒体remoteVideo`)
// 监听连接状态变化
pc.oniceconnectionstatechange = () => {
console.info('ICE 连接状态:', pc.iceConnectionState);
if (pc.iceConnectionState === 'disconnected') {
handleHangup();
}
};
}
// 设置数据通道
function setupDataChannel() {
// 数据通道打开
dataChannel.onopen = () => { addToLog('数据通道已打开') };
// 收到数据通道消息
dataChannel.onmessage = (event) => { addToLog(`对方: ${event.data}`) };
// 数据通道关闭
dataChannel.onclose = () => {
addToLog('数据通道已关闭');
sendBtn.disabled = true;
messageInput.disabled = true;
};
// 数据通道错误
dataChannel.onerror = (error) => {
console.error('数据通道错误:', error);
addToLog(`数据通道错误: ${error.message}`);
};
}
// 处理挂断
function handleHangup() {
if (pc) {
pc.close();
pc = null;
}
if (localStream) {
localStream.getTracks().forEach(track => track.stop());
localStream = null;
}
localVideo.srcObject = null;
remoteVideo.srcObject = null;
startBtn.disabled = false;
callBtn.disabled = true;
sendBtn.disabled = true;
messageInput.disabled = true;
addToLog('连接已断开');
}
// 添加日志
function addToLog(message) {
const time = new Date().toLocaleTimeString();
dataChannelLog.innerHTML += `[${time}] ${message}<br>`;
dataChannelLog.scrollTop = dataChannelLog.scrollHeight;
}
// 页面关闭时清理
window.addEventListener('beforeunload', () => {
ws.send(JSON.stringify({ type: 'hangup' }));
if (pc) pc.close();
if (localStream) localStream.getTracks().forEach(track => track.stop());
});
</script>
</body>
</html>

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

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()

373
myRtc.py Normal file
View File

@@ -0,0 +1,373 @@
'''
通过python获取win屏幕媒体流传递给js
'''
import asyncio
import ctypes
import threading
import numpy as np
import win32gui
import win32ui
import win32con
import webview
from aiortc import MediaStreamTrack, RTCPeerConnection
from aiortc import MediaStreamTrack, RTCPeerConnection, RTCSessionDescription
# -------------------------- 1. Windows高DPI适配 --------------------------
ctypes.windll.shcore.SetProcessDpiAwareness(2)
# -------------------------- 2. 兼容VideoFrame导入 --------------------------
try:from aiortc.contrib.media import VideoFrame,AudioFrame
except ImportError:from aiortc import VideoFrame,AudioFrame
# -------------------------- 2. AIORTC屏幕采集轨道 --------------------------
class ScreenVideoTrack(MediaStreamTrack):
kind = "video"
def __init__(self, fps=30):
super().__init__()
self.fps = fps
self.stop_flag = False
self.pts = 0
self.frame_interval = 1.0 / fps
# 获取真实屏幕分辨率适配高DPI
self.user32 = ctypes.windll.user32
self.width = self.user32.GetSystemMetrics(0)
self.height = self.user32.GetSystemMetrics(1)
def capture_screen(self):
"""Windows原生GDI采集屏幕低延迟"""
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, self.width, self.height)
save_dc.SelectObject(save_bitmap)
save_dc.BitBlt((0, 0), (self.width, self.height), mfc_dc, (0, 0), win32con.SRCCOPY)
# 转换为numpy数组BGR格式适配aiortc
bmp_data = save_bitmap.GetBitmapBits(True)
frame = np.frombuffer(bmp_data, dtype=np.uint8).reshape((self.height, self.width, 4))
frame = frame[:, :, :3][:, :, ::-1] # BGRA → BGR
# 释放资源,避免内存泄漏
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)
frame_data = self.capture_screen()
# 转换为aiortc VideoFrame
video_frame = VideoFrame.from_ndarray(frame_data, format="bgr24")
video_frame.pts = int(self.pts)
video_frame.time_base = np.array([1, self.fps])
self.pts += 1
return video_frame
def stop(self):
"""停止屏幕采集"""
self.stop_flag = True
# -------------------------- 3. WebRTC服务异步线程运行 --------------------------
class WebRTCService:
def __init__(self):
self.pc = None
self.screen_track = None
self.loop = asyncio.new_event_loop()
self.thread = None
def start_async_loop(self):
"""在子线程启动asyncio循环避免阻塞pywebview UI"""
asyncio.set_event_loop(self.loop)
self.loop.run_forever()
async def create_offer(self):
"""生成Offer信令供前端调用"""
# 初始化PeerConnection配置STUN服务器解决NAT穿透
self.pc = RTCPeerConnection()
self.pc.addIceServers([{"urls": "stun:stun.l.google.com:19302"}])
# 添加屏幕采集轨道
self.screen_track = ScreenVideoTrack(fps=30)
self.pc.addTrack(self.screen_track)
print("✅ Python侧已添加屏幕采集轨道")
# 监听连接状态
@self.pc.on("connectionstatechange")
def on_connectionstatechange():
print(f"🔌 WebRTC连接状态{self.pc.connectionState}")
if self.pc.connectionState in ["failed", "closed", "disconnected"]:
if self.screen_track:
self.screen_track.stop()
# 生成Offer并设置本地SDP
offer = await self.pc.createOffer()
await self.pc.setLocalDescription(offer)
# 返回序列化的Offer供前端解析
return {
"sdp": self.pc.localDescription.sdp,
"type": self.pc.localDescription.type
}
async def set_answer(self, answer_data):
"""接收前端的Answer信令并应用"""
if not self.pc:
raise Exception("PeerConnection未初始化")
# 反序列化Answer
answer = RTCSessionDescription(
sdp=answer_data["sdp"],
type=answer_data["type"]
)
await self.pc.setRemoteDescription(answer)
print("✅ Python侧已应用前端Answer信令P2P连接建立")
def start(self):
"""启动WebRTC服务子线程"""
self.thread = threading.Thread(target=self.start_async_loop, daemon=True)
self.thread.start()
def stop(self):
"""停止服务并清理资源"""
if self.screen_track:
self.screen_track.stop()
if self.pc:
asyncio.run_coroutine_threadsafe(self.pc.close(), self.loop)
self.loop.stop()
if self.thread:
self.thread.join()
# -------------------------- 4. PyWebView API通信层 --------------------------
class PyWebViewAPI:
def __init__(self, webrtc_service):
self.webrtc_service = webrtc_service
# 供前端JS调用获取Offer信令
async def get_offer(self):
return await asyncio.wrap_future(
asyncio.run_coroutine_threadsafe(self.webrtc_service.create_offer(), self.webrtc_service.loop)
)
# 供前端JS调用发送Answer信令到Python
async def send_answer(self, answer_data):
await asyncio.wrap_future(
asyncio.run_coroutine_threadsafe(self.webrtc_service.set_answer(answer_data), self.webrtc_service.loop)
)
# -------------------------- 5. 前端页面(向日葵风格) --------------------------
HTML_CONTENT = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Windows屏幕共享</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box; font-family: "Microsoft YaHei", Arial;}
body {background: #f5f7fa; color: #333;}
/* 顶部导航 */
.header {
background: #2d8cf0; color: white; padding: 12px 24px;
display: flex; justify-content: space-between; align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); position: sticky; top: 0; z-index: 100;
}
.status-indicator {display: flex; align-items: center; gap: 6px;}
.status-dot {
width: 10px; height: 10px; border-radius: 50%; background: #ff4d4f;
animation: pulse 2s infinite;
}
.status-dot.connected {background: #52c41a;}
@keyframes pulse {0% {opacity: 1;} 50% {opacity: 0.5;} 100% {opacity: 1;}}
/* 主容器 */
.container {max-width: 1600px; margin: 20px auto; padding: 0 20px;}
/* 控制栏 */
.control-bar {
background: white; padding: 12px 20px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 20px;
display: flex; gap: 15px; align-items: center;
}
.btn {
padding: 8px 16px; border: none; border-radius: 4px;
cursor: pointer; font-size: 14px; transition: all 0.2s;
}
.btn-primary {background: #2d8cf0; color: white;}
.btn-primary:hover {background: #2b85e4;}
.btn-danger {background: #ff4d4f; color: white;}
.btn-danger:hover {background: #ff7875;}
/* 屏幕显示区 */
.screen-container {
background: white; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1);
overflow: hidden; min-height: 600px; display: flex;
align-items: center; justify-content: center; position: relative;
}
.loading {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255,255,255,0.8); display: flex;
flex-direction: column; align-items: center; justify-content: center;
gap: 15px; z-index: 10;
}
.spinner {
width: 40px; height: 40px; border: 4px solid #f0f2f5;
border-top: 4px solid #2d8cf0; border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);}}
#screen-video {width: 100%; height: auto; display: block;}
</style>
</head>
<body>
<header class="header">
<h2>Windows屏幕共享</h2>
<div class="status-indicator">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">未连接</span>
</div>
</header>
<div class="container">
<div class="control-bar">
<button class="btn btn-primary" id="connect-btn">开始共享屏幕</button>
<button class="btn btn-danger" id="disconnect-btn" disabled>停止共享</button>
</div>
<div class="screen-container">
<div class="loading" id="loading">
<div class="spinner"></div>
<div>正在建立连接...</div>
</div>
<video id="screen-video" autoplay muted playsinline></video>
</div>
</div>
<script>
// 全局变量
let pc = null; // 浏览器端PeerConnection
const videoEl = document.getElementById('screen-video');
const loadingEl = document.getElementById('loading');
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
// 更新连接状态
function updateStatus(connected) {
statusDot.className = connected ? 'status-dot connected' : 'status-dot';
statusText.textContent = connected ? '已连接' : '未连接';
loadingEl.style.display = connected ? 'none' : 'flex';
connectBtn.disabled = connected;
disconnectBtn.disabled = !connected;
}
// 连接屏幕共享
async function connectScreen() {
try {
updateStatus(false);
// 1. 调用Python API获取Offer信令
const offer = await window.pywebview.api.get_offer();
// 2. 初始化浏览器端WebRTC
pc = new RTCPeerConnection({
iceServers: [{urls: 'stun:stun.l.google.com:19302'}]
});
// 3. 监听媒体流渲染到video标签
pc.ontrack = (e) => {
videoEl.srcObject = e.streams[0];
updateStatus(true);
};
// 4. 监听连接状态
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'disconnected') {
disconnectScreen();
}
};
// 5. 应用Offer并生成Answer
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 6. 发送Answer到Python侧
await window.pywebview.api.send_answer({
sdp: pc.localDescription.sdp,
type: pc.localDescription.type
});
} catch (e) {
alert('连接失败:' + e.message);
updateStatus(false);
console.error('连接错误:', e);
}
}
// 断开屏幕共享
function disconnectScreen() {
if (pc) {
pc.close();
pc = null;
}
videoEl.srcObject = null;
updateStatus(false);
}
// 绑定事件
connectBtn.addEventListener('click', connectScreen);
disconnectBtn.addEventListener('click', disconnectScreen);
// 页面关闭时清理
window.addEventListener('unload', disconnectScreen);
// 初始状态
updateStatus(false);
</script>
</body>
</html>
"""
# -------------------------- 6. 主程序入口 --------------------------
if __name__ == "__main__":
# 初始化WebRTC服务并启动异步线程
webrtc_service = WebRTCService()
webrtc_service.start()
# 初始化PyWebView API
api = PyWebViewAPI(webrtc_service)
# 创建PyWebView窗口必须用Edge Chromium引擎支持WebRTC
window = webview.create_window(
title="Windows屏幕共享",
html=HTML_CONTENT,
width=1400,
height=900,
resizable=True,
confirm_close=True,
easy_drag=False,
js_api=api
)
# 启动PyWebView关键指定edgechromium引擎
try:
webview.start(
gui='edgechromium', # 强制使用WebView2引擎
private_mode=False, # WebRTC必需关闭私有模式
)
except KeyboardInterrupt:
print("\n🛑 程序被手动终止")
finally:
# 清理资源
webrtc_service.stop()
print("✅ 资源已清理完成")

24
py.code-workspace Normal file
View File

@@ -0,0 +1,24 @@
{
"folders": [
{
"path": ".",
},
],
"settings": {
"files.autoSave": "onFocusChange",
"editor.wordWrap": "on",
"[python]": {
"diffEditor.ignoreTrimWhitespace": false,
"editor.formatOnType": true,
"editor.wordBasedSuggestions": "off",
},
"git.ignoreLimitWarning": true,
"editor.fontSize": 12,
"editor.formatOnSave": true,
// "python.defaultInterpreterPath": "C:/v_floder/_wPy64/winpy312/python/python.exe",
// "python.defaultInterpreterPath": "E:/vFiles/.WPython64/wpy312/python/python.exe",
// "python.defaultInterpreterPath": "D:/vbox_files/.wpy64/wpy312/python/python.exe",
"python.envFile": "${workspaceFolder}/.venv",
"editor.minimap.enabled": true,
},
}

44
py.ipynb Normal file
View File

@@ -0,0 +1,44 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 1,
"id": "86b87380",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"1\n"
]
}
],
"source": [
"if __name__ == \"__main__\":\n",
" print(1)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.4"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

46
py2exe.py Normal file
View File

@@ -0,0 +1,46 @@
import time
import os
from PyInstaller import __main__ as pyi
def create_executable(script, output_name, icon, data_files,module_args_files):
data_args = []
for src, dest in data_files:
sep = ';' if os.name == 'nt' else ':' # 根据操作系统选择分隔符
data_args.extend(['--add-data', f'{src}{sep}{dest}'])
module_args = []
for moduls_file in module_args_files:
module_args.extend(['--hidden-import', f'{moduls_file}'])
# module_args.extend(['excludes', f'PyQt5'])
pyi.run([
*data_args, # 包含的静态文件
*module_args, #包含的库
'--onefile', #将所有依赖打包为单个可执行文件
# '--onedir', #打包为文件夹(默认方式),包含 exe + 依赖库 / 文件夹
'--clean',
# '--windowed', # 隐藏控制台窗口GUI 应用必备)
'--console', # 显示控制台窗口
# f"--exclude-module=PyQt5", # 排除指定库
f'--icon={icon}', # 设置程序icon
f'--name={output_name}',
script,
])
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if __name__ == "__main__":
# 示例数据文件列表
data_files = [
("static", "static"),
("templates", "templates"),
("assets", "assets"),
("module", "module"),
]
import_modules = [
"pystray","pillow","pywebview","aiortc","opencv-python","numpy","mss"
]
create_executable(f"app.py", f"fooDesk_{int(time.time())}", "icon.ico", data_files,import_modules)

148
screenCaptureNoConfirm.py Normal file
View File

@@ -0,0 +1,148 @@
"""screenCaptureNoConfirm
pywebview 通过js跳过确认直接获取屏幕媒体流
"""
import webview
import threading
import time
# -------------------------- 1. PyWebView配置 --------------------------
class ScreenCaptureAPI:
def __init__(self):
self.window = None
def set_window(self, window):
self.window = window
def auto_trigger_capture(self):
"""后台自动触发屏幕采集(模拟用户点击)"""
# 延迟1秒等待页面加载完成
time.sleep(1)
# 执行JS自动点击按钮触发屏幕采集
self.window.evaluate_js("document.getElementById('autoCaptureBtn').click();")
# -------------------------- 2. 前端HTML核心自动触发+无感采集) --------------------------
HTML_CONTENT = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>自动获取屏幕流</title>
<style>
* {margin: 0; padding: 0; box-sizing: border-box;}
body {padding: 20px; background: #f5f7fa; font-family: Arial;}
.video-container {
width: 100%; max-width: 1600px;
border: 1px solid #ddd; border-radius: 8px;
overflow: hidden; background: #000;
margin-top: 20px;
}
#localVideo {
width: 100%; height: auto;
display: block; object-fit: contain;
}
/* 隐藏触发按钮(仅用于自动点击) */
#autoCaptureBtn {
position: absolute; top: -9999px; left: -9999px;
opacity: 0; pointer-events: none;
}
.status {
margin: 10px 0; color: #666; font-size: 14px;
}
</style>
</head>
<body>
<!-- 隐藏的自动触发按钮(用于模拟用户点击) -->
<button id="autoCaptureBtn" onclick="captureScreen()"></button>
<div class="status" id="status">状态:等待自动采集...</div>
<div class="video-container">
<video id="localVideo" autoplay muted playsinline></video>
</div>
<script>
const videoEl = document.getElementById('localVideo');
const statusEl = document.getElementById('status');
let screenStream = null;
// 核心:获取屏幕媒体流(必须由用户手势触发)
async function captureScreen() {
try {
statusEl.textContent = "状态:请求屏幕权限...";
// 调用Screen Capture API系统级弹窗无法跳过
screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: "always", // 显示鼠标光标
displaySurface: "monitor" // 优先选择整个显示器
},
audio: false // 可选:是否采集音频
});
// 将流绑定到video标签
videoEl.srcObject = screenStream;
statusEl.textContent = "状态:屏幕采集成功!";
// 监听流结束事件(用户停止共享)
screenStream.getVideoTracks()[0].addEventListener('ended', () => {
statusEl.textContent = "状态:屏幕采集已停止";
videoEl.srcObject = null;
});
} catch (error) {
statusEl.textContent = `状态:采集失败 - ${error.message}`;
console.error("屏幕采集错误:", error);
}
}
// 页面加载完成后通知Python端可以自动触发
window.onload = async () => {
// 告知Python页面已加载可触发自动点击
await window.pywebview.api.page_loaded();
};
</script>
</body>
</html>
"""
# -------------------------- 3. 主程序入口 --------------------------
if __name__ == "__main__":
# 初始化API
api = ScreenCaptureAPI()
# 创建pywebview窗口关键配置WebView2引擎+权限豁免)
window = webview.create_window(
title="自动获取屏幕流",
html=HTML_CONTENT,
width=1400,
height=900,
resizable=True,
# confirm_close=True,
# 额外权限配置WebView2
# webview_settings={
# "web_security": False, # 关闭跨域限制(本地运行需开启)
# "allow_displaying_insecure_content": True,
# "allow_running_insecure_content": True
# },
js_api=api
)
api.set_window(window)
# 定义页面加载完成后的回调(自动触发采集)
def on_page_loaded():
# 启动线程自动触发点击
trigger_thread = threading.Thread(target=api.auto_trigger_capture, daemon=True)
trigger_thread.start()
# 暴露API给前端
api.page_loaded = on_page_loaded
# 启动pywebview
try:
webview.start(
private_mode=False, # 关键:关闭私有模式,授予本地权限
debug=True,
http_server=True
)
except KeyboardInterrupt:
print("程序已终止")

49
static/dexieParse.js Normal file
View File

@@ -0,0 +1,49 @@
// dexie文档https://dexie.org/docs/API-Reference https://www.npmrc.cn/en/Dexie-js.html
class dexieDemo {
dbName = "jafar";
dbVersion = 1;
constructor(dbName, dbVersion) {
if (dbName) {
this.dbName = dbName;
}
if (dbVersion) {
this.dbVersion = dbVersion;
}
this.db = new Dexie(this.dbName);
this.db.version(this.dbVersion).stores(
{ tabName: "++id,nKey,nValue,nTime" }, // 创建表已有表则建立链接id为自增主键仅需要列举需要索引的字段即可
);
}
async get() {
const getLastData = await this.db.tabName.orderBy("id").last();
return getLastData;
}
async post(nKey, nValue) {
await this.db.tabName.add({
nKey: nKey,
nValue: nValue,
nTime: Date.now(),
});
const getLastData = await this.db.tabName.orderBy("id").last();
}
async put(id) {
await this.db.tabName.put({
id: 1,
userName: "zhangsan",
});
}
async del(id) {
await this.db.tabName.delete(id);
}
}
export const myDexie = new dexieDemo();
// document.querySelector("#goto_indexedDB_post").addEventListener("click",async () => {
// let nKey = Math.random()
// let nValue = new Date().getTime();
// await myDexieDemo.post( nKey,nValue );
// })
// document.querySelector("#goto_indexedDB_get").addEventListener("click",async () => {
// console.log( await myDexieDemo.get() )
// })

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

18
static/script.js Normal file
View File

@@ -0,0 +1,18 @@
// addEventListener 能添加事件
// 鼠标事件 : 如 click点击、dblclick双击、mousedown按下鼠标键、mouseup释放鼠标键、mousemove鼠标移动、mouseenter鼠标进入元素、mouseleave鼠标离开元素、mouseover鼠标悬停和 mouseout鼠标移出
// 键盘事件 : 如 keydown按下键盘键、keyup释放键盘键和 keypress按下字符键已废弃
// 触摸与指针事件 : 如 touchstart触摸开始、touchmove触摸移动、touchend触摸结束、pointerdown指针按下和 pointerup指针释放适用于触屏设备和笔输入。
// 表单事件 : 如 submit表单提交、change值改变、input输入内容、focus获得焦点和 blur失去焦点
// 窗口与页面事件 : 如 load页面加载完成、resize窗口大小改变、scroll滚动、beforeunload页面即将卸载和 unload页面卸载
// 媒体事件 : 如 play媒体开始播放、pause媒体暂停、ended媒体播放结束和 loadeddata媒体数据加载完成
// 自定义事件 : 可以通过 Event 构造函数创建并派发自定义事件,用于应用内部组件通信。
// 监听DOM加载完成事件
document.addEventListener("DOMContentLoaded", function () {
console.log("document加载完成!!!");
});
document.querySelector("#appChildEleButton").addEventListener("click", () => {
const resultDiv = document.getElementById("appChildEle");
resultDiv.innerHTML += `<li>点击添加元素完成</li>`;
});

39
static/styles.css Normal file
View File

@@ -0,0 +1,39 @@
.myFontWenDao {
font-family: myFontWenDao, sans-serif;
}
#app {
border: 1px dotted blueviolet;
border-radius: 3rem;
padding: 2rem;
margin: 1rem;
}
#app .title {
text-align: center;
}
#appChildEleButton {
background-color: whitesmoke;
border: 2px dotted grey;
border-radius: 0.5rem;
}
#appChildEleButton :hover {
opacity: 0.5;
background-color: black;
}
.con {
padding: 0.5rem;
margin: 1rem auto;
border: 1px dotted grey;
border-radius: 1rem;
text-align: center;
width: fit-content;
}
li {
display: block;
}
#localVideo {
display: none;
}

111
templates/app_JsToPy.html Normal file
View File

@@ -0,0 +1,111 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>PyWebView 双向通信</title>
<style>
body {
padding: 20px;
font-family: Arial;
}
.container {
max-width: 600px;
margin: 0 auto;
}
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
height: 300px;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="container">
<h2>Python ↔ JS 双向通信</h2>
<!-- JS 调用 Python 函数(同步) -->
<div>
<input type="text" id="nameInput" placeholder="输入姓名" />
<button onclick="callPythonSync()">调用 Python 同步函数</button>
</div>
<!-- JS 调用 Python 异步函数 -->
<div>
<input
type="number"
id="durationInput"
placeholder="异步任务耗时(秒)"
value="2"
/>
<button onclick="callPythonAsync()">调用 Python 异步函数</button>
</div>
<!-- 关闭窗口 -->
<button onclick="callPythonCloseWindow()">关闭窗口</button>
<!-- 日志显示区域 -->
<div id="log"></div>
</div>
<script>
// ---------------------- 1. JS 调用 Python 函数 ----------------------
// 同步调用
async function callPythonSync() {
const name = document.getElementById("nameInput").value || "匿名用户";
try {
// 核心:通过 window.pywebview.api 调用 Python 函数
const result = await window.pywebview.api.python_hello(name);
addLog(`[JS] 调用 Python 同步函数成功:${result}`);
} catch (e) {
addLog(`[JS] 调用 Python 同步函数失败:${e}`);
}
}
// 异步调用Python 执行完后主动回调 JS
async function callPythonAsync() {
const duration = document.getElementById("durationInput").value;
try {
const result = await window.pywebview.api.python_async_task(duration);
addLog(`[JS] 调用 Python 异步函数成功:${result}`);
} catch (e) {
addLog(`[JS] 调用 Python 异步函数失败:${e}`);
}
}
// 调用 Python 关闭窗口
async function callPythonCloseWindow() {
await window.pywebview.api.python_close_window();
}
// ---------------------- 2. 供 Python 调用的 JS 函数 ----------------------
function js_receive_python_msg(msg) {
addLog(`[JS] 收到 Python 消息:${msg}`);
}
// ---------------------- 辅助函数:添加日志 ----------------------
function addLog(text) {
const logDiv = document.getElementById("log");
const time = new Date().toLocaleTimeString();
logDiv.innerHTML += `[${time}] ${text}<br>`;
// 自动滚动到底部
logDiv.scrollTop = logDiv.scrollHeight;
}
// 页面加载完成后提示
window.onload = () => {
addLog("页面加载完成,可开始双向通信测试");
};
</script>
</body>
</html>

76
templates/app_JsToPy.py Normal file
View File

@@ -0,0 +1,76 @@
import webview
import threading
import time,os
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
# ---------------------- 1. 定义供 JS 调用的 Python 函数 ----------------------
class Api:
"""暴露给 JS 的 Python 接口类(所有方法都会被 JS 访问)"""
def __init__(self):
self.window = None # 保存窗口实例,用于 Python 主动调用 JS
def set_window(self, window):
"""初始化时绑定窗口实例"""
self.window = window
def python_hello(self, name):
"""JS 调用的同步函数:接收参数,返回结果"""
print(f"[Python] 收到 JS 调用,参数:{name}")
return f"Hello {name}! 这是 Python 返回的消息"
def python_async_task(self, duration):
"""JS 调用的异步函数:模拟耗时任务(不阻塞前端)"""
def async_task():
print(f"[Python] 开始异步任务,耗时 {duration}")
time.sleep(int(duration))
# 异步任务完成后,主动调用 JS 函数通知结果
self.window.evaluate_js(f'js_receive_python_msg("异步任务完成!耗时 {duration}")')
print(f"[Python] 异步任务结束")
# 启动子线程执行异步任务,避免阻塞 UI
threading.Thread(target=async_task, daemon=True).start()
return "异步任务已启动,请等待结果通知"
def python_close_window(self):
"""JS 调用关闭窗口"""
print("[Python] 收到关闭窗口请求")
self.window.destroy()
# ---------------------- 2. Python 主动调用 JS 函数 ----------------------
def python_call_js_periodically(window):
"""Python 定时调用 JS 函数(模拟主动推送数据)"""
count = 0
while window.is_alive():
count += 1
# 执行 JS 函数,传递参数
window.evaluate_js(f'js_receive_python_msg("Python 主动推送:第 {count} 条消息")')
time.sleep(3) # 每 3 秒推送一次
if count >= 5:
break
# ---------------------- 3. 启动窗口 ----------------------
if __name__ == "__main__":
# 创建 API 实例
api = Api()
# 加载本地 HTML 文件(也可加载远程 URLurl="https://xxx.com"
window = webview.create_window(
title="PyWebView 双向通信示例",
url=f"file:///{CURRENT_DIR}/templates/app_JsToPy.html",
resizable=True,
js_api=api # 关键:暴露 Python API 给 JS
)
# 绑定窗口实例到 API供异步任务调用 JS
api.set_window(window)
# 启动 Python 主动调用 JS 的线程(非阻塞)
t = threading.Thread(target=python_call_js_periodically, args=(window,), daemon=True)
t.start()
# 运行窗口(阻塞主线程)
webview.start(
debug=True, # 开发环境开启调试,生产环境关闭
http_server=True # 启用内置 HTTP 服务器(加载本地 HTML 必需)
)