项目备份
This commit is contained in:
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
2
.release/app.py
Normal file
@@ -0,0 +1,2 @@
|
||||
import os
|
||||
print(12)
|
||||
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal 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
89
aiortcToJs.py
Normal 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 文件(也可加载远程 URL:url="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
52
app.html
Normal 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
61
app.py
Normal 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
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
6016
assets/dexie.js
Normal file
File diff suppressed because it is too large
Load Diff
29
docs/README.md
Normal file
29
docs/README.md
Normal 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
108
docs/app_JsToPy.html
Normal 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
77
docs/app_JsToPy.py
Normal 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 文件(也可加载远程 URL:url="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
17
docs/releaseNotes.md
Normal 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
14
docs/venv.md
Normal 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]
|
||||
340
docs/webrtc_demo_callee.html
Normal file
340
docs/webrtc_demo_callee.html
Normal 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>
|
||||
339
docs/webrtc_demo_caller.html
Normal file
339
docs/webrtc_demo_caller.html
Normal 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
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()
|
||||
373
myRtc.py
Normal file
373
myRtc.py
Normal 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
24
py.code-workspace
Normal 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
44
py.ipynb
Normal 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
46
py2exe.py
Normal 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
148
screenCaptureNoConfirm.py
Normal 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
49
static/dexieParse.js
Normal 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
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
18
static/script.js
Normal file
18
static/script.js
Normal 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
39
static/styles.css
Normal 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
111
templates/app_JsToPy.html
Normal 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
76
templates/app_JsToPy.py
Normal 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 文件(也可加载远程 URL:url="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 必需)
|
||||
)
|
||||
Reference in New Issue
Block a user