录屏原理与鼠标可视化 ------ 会话记录
整理日期:2026-08-04
主题:屏幕/声音捕获原理、DRM 限制、鼠标可视化、多屏适配
问答模型:deepseek-v4-flash
一、录屏软件的工作原理
录屏的本质是"在不打扰系统正常工作的情况下,拿到系统的最终画面和声音"。
1. 画面捕获(屏幕输出)
核心矛盾:画面已被系统**合成器(compositor)**画好,录屏软件要的是"拿一份副本"。效率高低取决于"副本是从 GPU 拿还是从 CPU 拿"。
| 方式 | 平台 | 特点 |
|---|---|---|
| GDI / BitBlt | Windows(老式) | 走 CPU 截图,慢,有撕裂,基本是上古方案 |
| Desktop Duplication API | Windows 8+(主流) | 底层走 GPU,对合成器最终纹理做复制,支持变化检测跳过,OBS 等主流方案 |
| ScreenCaptureKit / CGDisplayStream | macOS | 通过 IOSurface 从 WindowServer 取帧,支持按窗口/应用捕获、HDR |
| X11 / Wayland | Linux | X11 用 XShm;Wayland 需通过 xdg-desktop-portal(PipeWire)申请权限 |
鼠标光标是单独处理的:不随桌面帧一起取,而是单独取形状和位置再叠加合成,这样即使帧率低光标也流畅。
2. 声音捕获(声音输出)
关键点:正常程序的声音发给声卡,录屏软件必须"在半路截胡"------在声音到达扬声器前拷贝一份。
| 平台 | 机制 |
|---|---|
| Windows | WASAPI loopback:订阅"所有程序混音后的输出流" |
| macOS | AudioUnit Tap / process tap:钩住系统音频进程的输出流 |
| Linux | PulseAudio / PipeWire 的 monitor sink:每个输出设备有镜像源 |
拿到原始音频后还要做重采样(对齐采样率)、和麦克风混音、用时间戳与视频帧对齐(A/V 同步)。
3. 编码与封装
- 用高精度时钟(Windows QPC、macOS mach_absolute_time)给每帧打时间戳;
- 用硬件编码器压缩:NVIDIA NVENC / AMD AMF / Intel QuickSync / macOS VideoToolbox;
- 把视频流 + 音频流按时间戳交错写进 MP4 / MKV / FLV。
4. 常见坑与应对
- DRM 内容:捕获 API 拒绝或加水印;
- 被遮挡的窗口:Desktop Duplication 只能拿整个屏幕,需要窗口级捕获 API(Windows Graphics Capture);
- CPU 往返开销:现代方案尽量让数据留在 GPU,最后只在编码器走一遍。
二、遇到数字版权保护(DRM)时还能工作吗
遇到真正的 DRM 内容,录屏软件会直接失效------录出来是黑屏。 不是软件不够强,而是被硬件层 + 系统层一起掐断。
1. 传输保护(HDCP,管"信号线")
保护的是显卡 → 显示器/采集卡这条物理链路,双方握手协商密钥。
- 采集卡声明不支持 HDCP → 源设备拒绝输出或只输出低分辨率;
- "HDCP 剥离器"本质是欺骗源设备,但流媒体会检查输出设备等级,检测到不合规直接降级或拒播;
- 主机/PC 自己录屏走第二层,不走 HDMI 线,HDCP 管不着。
2. 内容保护(PlayReady / Widevine / FairPlay,管"解码路径")
这才是 Netflix、B 站、浏览器看片的机制:
- 加密流在安全环境里解密(PlayReady / Widevine CDM / FairPlay),底层依赖 TEE 或 GPU 安全路径;
- 解密后的帧标记为"受保护表面",放进 GPU 专用显存区,合成器只拿到占位符;
- 录屏 API 能拷到的本来就是占位符,拷出来自然是黑块。
为什么"再先进的采集方式都没用":问题不在采集方式,而在于解密和显示被锁在安全的硬件路径里,任何用户态捕获 API 都碰不到真像素。绕过 DRM 本身触犯反规避法律(DMCA 类条款),正规录屏软件绝不会做。
3. 物理漏洞(analog hole)
DRM 加密的是数字链路,但屏幕发出的光是任何加密都管不了的------用相机对着显示器拍理论上可行。但现代流媒体有分辨率限制 + 水印追踪,实际效果差且法律上违规,只作概念存在。
三、录屏软件如何改变/显示鼠标形状
结论:是浮层,但加在"合成到帧之前",不是加在屏幕上。
1. 鼠标光标不是屏幕画面的一部分
系统画光标用的是 hardware cursor(硬件光标层) :独立的小精灵位图,由 GPU 专用图层叠加,不写进桌面帧缓冲。
所以捕获 API 默认拿到的帧里根本没有鼠标,而是把光标当"元数据"单独提供:
- 当前位置(每帧)
- 当前形状位图
- 是否可见 / 有没有改变
2. 录屏软件"画自己的光标"
- 捕获不带光标的画面帧;
- 读取同刻光标位置 + 形状;
- 在编码流水线里用自己的图叠加到这一帧上(高亮圆圈、放大镜、更大箭头、自定义图案)。
发生在 GPU 上的合成/编码阶段,浮层是加在每一帧的画面数据上,不是屏幕上弹窗口。
3. 位置为什么流畅
光标刷新率比帧率高得多(鼠标回报率 125Hz / 1000Hz)。
- 帧率不够时每帧是"采样点",位置跳变;
- 高级软件(OBS 光标滤镜)以远高于帧率的频率轮询光标位置,画最新位置;
- 可画拖尾:记录最近几次采样位置画在同一帧上形成轨迹。
4. 两种改法对比
| 改法 | 机制 | 效果 |
|---|---|---|
| 录屏层浮层 | 捕获时隐藏真光标,画自己的图 | 只改视频,屏幕上看不到变化 |
| 改系统光标 | SetSystemCursor 替换系统光标位图 |
屏幕实时生效,录的也是新的 |
四、Python 模拟鼠标点击的库
1. 常用库
- PyAutoGUI(最常见)
- pynput
- mouse(boppreh 的库)
能模拟鼠标移动/点击/拖拽、键盘输入、截图识别颜色定位目标。
2. 底层原理
最终都调用系统事件注入接口:
| 平台 | 接口 |
|---|---|
| Windows | SendInput / mouse_event / SetCursorPos |
| macOS | CGEventPost |
| Linux | XTest |
关键细节 :Windows 上这类事件会被打 LLMHF_INJECTED 标志标记"程序注入"。真实物理输入没有这个标志。游戏反作弊、银行控件会检查它------所以这类库在游戏/安全场景容易失效或触发风控,这是系统故意留下的指纹。
另外 pynput 和 mouse 还能监听/钩住 全局鼠标事件,用系统钩子实现(Windows SetWindowsHookEx / 全局事件监听),与注入是相反方向。
五、让鼠标点击位置在屏幕上显示出来
思想与录屏浮层一致但方向相反:录屏软件画进视频帧,这里要画在真实屏幕上。
1. 两个独立部分
- 监听点击 :
pynput.Listener(底层是全局鼠标钩子WH_MOUSE_LL),拿到坐标; - 显示点击:全屏、置顶、无边框、透明的"装饰窗口",在点击坐标处画波纹/圆圈并淡出。
2. 装饰窗口三个关键属性
-topmost:永远盖在所有窗口上面;-transparentcolor(颜色键):某颜色当透明,只有画上去的圆圈可见;- 鼠标穿透:透明区域不接收鼠标消息,真实点击穿透到下面程序,否则自己挡住自己点的按钮。
3. 现成工具
- Carnac / KeyCastOW:显示按键 + 鼠标点击
- MouseClickShow:专门做点击波纹
- OBS:录制时用插件(如 Mouse Highlight)把波纹加进视频
4. 最小实现(Tkinter + pynput)
python
import queue
import tkinter as tk
from pynput import mouse
clicks = queue.Queue()
class Overlay:
def __init__(self):
self.root = tk.Tk()
self.root.overrideredirect(True) # 无边框
self.root.attributes('-topmost', True) # 永远最前
self.root.attributes('-transparentcolor', '#010101') # 颜色键当透明(鼠标穿透)
self.root.geometry(f"{self.root.winfo_screenwidth()}x{self.root.winfo_screenheight()}+0+0")
self.canvas = tk.Canvas(self.root, bg='#010101', highlightthickness=0)
self.canvas.pack(fill='both', expand=True)
self.ripples = []
def tick(self): # 30fps 动画循环
while not clicks.empty():
x, y = clicks.get()
self.ripples.append({'x': x, 'y': y, 'r': 6, 'life': 30})
self.canvas.delete('all')
for rp in list(self.ripples):
rp['r'] += 3
rp['life'] -= 1
if rp['life'] <= 0:
self.ripples.remove(rp)
continue
a = int(rp['life'] / 30 * 255)
self.canvas.create_oval(rp['x']-rp['r'], rp['y']-rp['r'],
rp['x']+rp['r'], rp['y']+rp['r'],
outline=f'#{a:02x}0000', width=3)
self.root.after(33, self.tick)
def run(self):
self.root.after(33, self.tick)
self.root.mainloop()
o = Overlay()
# 监听线程只往队列塞坐标,UI 在主线程安全地画(避免跨线程碰 tkinter)
mouse.Listener(on_click=lambda x, y, button, pressed: pressed and clicks.put((x, y))).start()
o.run()
5. 实现细节
- 线程安全 :钩子回调跑后台线程,tkinter 不能在别的线程画,用队列 +
after()轮询,主线程统一处理; - 点击 vs 按下 :回调里
pressed区分按下/抬起,一般只画按下,否则一次点击出两圈; - 拖尾/轨迹:多记历史点即可。
六、扩展屏幕(多显示器)时的适配
默认这套代码在扩展屏上会失灵------不是监听问题,是显示层问题。
1. 两个坑
- 浮层窗口只盖了主屏 :
winfo_screenwidth()/height()只返回主显示器尺寸,+0+0是主屏原点; - 坐标空间对不上 :pynput 返回的是虚拟桌面坐标 (所有显示器合起来的大画布),副屏在左侧时坐标是负数,可能落在浮层窗口外。
2. 修复思路
把浮层铺满"虚拟桌面",再平移坐标 。Windows 用 GetSystemMetrics 拿整个虚拟桌面矩形:
python
user32 = ctypes.windll.user32
vx = user32.GetSystemMetrics(76) # 虚拟桌面左边界(可能是负)
vy = user32.GetSystemMetrics(77) # 虚拟桌面上边界
vw = user32.GetSystemMetrics(78) # 虚拟桌面总宽
vh = user32.GetSystemMetrics(79) # 虚拟桌面总高
- 窗口铺满整个虚拟桌面(不能用
-fullscreen True,它只盖主屏); - 画圆圈时平移:
画布坐标 = 事件坐标 - (vx, vy)。
3. 必须一起处理的坑:DPI 缩放
副屏 125%/150% 而主屏 100% 时,pynput 和 Tkinter 的坐标可能是物理像素 vs 逻辑像素,位置系统性偏移。解决:进程启动时声明按显示器感知 DPI:
python
ctypes.windll.shcore.SetProcessDpiAwareness(2) # 必须在创建窗口前调用
4. 修正后的关键部分
python
import ctypes, queue, tkinter as tk
from pynput import mouse
clicks = queue.Queue()
ctypes.windll.shcore.SetProcessDpiAwareness(2)
u = ctypes.windll.user32
vx, vy = u.GetSystemMetrics(76), u.GetSystemMetrics(77)
vw, vh = u.GetSystemMetrics(78), u.GetSystemMetrics(79)
class Overlay:
def __init__(self):
self.root = tk.Tk()
self.root.overrideredirect(True)
self.root.attributes('-topmost', True)
self.root.attributes('-transparentcolor', '#010101')
self.root.geometry(f"{vw}x{vh}+{vx}+{vy}") # 覆盖整个虚拟桌面
self.canvas = tk.Canvas(self.root, bg='#010101', highlightthickness=0)
self.canvas.pack(fill='both', expand=True)
self.ripples = []
self.ox, self.oy = vx, vy
def tick(self):
while not clicks.empty():
x, y = clicks.get()
# 虚拟坐标 -> 窗口本地坐标
self.ripples.append({'x': x - self.ox, 'y': y - self.oy, 'r': 6, 'life': 30})
self.canvas.delete('all')
for rp in list(self.ripples):
rp['r'] += 3; rp['life'] -= 1
if rp['life'] <= 0:
self.ripples.remove(rp); continue
a = int(rp['life'] / 30 * 255)
self.canvas.create_oval(rp['x']-rp['r'], rp['y']-rp['r'],
rp['x']+rp['r'], rp['y']+rp['r'],
outline=f'#{a:02x}0000', width=3)
self.root.after(33, self.tick)
o = Overlay()
mouse.Listener(on_click=lambda x, y, b, p: p and clicks.put((x, y))).start()
o.root.after(33, o.tick)
o.root.mainloop()
注意 :GetSystemMetrics 方案是 Windows 专用的。macOS 要拿全屏空间走别的 API,跨屏浮层更麻烦。极少数情况下单个跨屏透明窗口有显示 bug,兜底方案是用 EnumDisplayMonitors 给每块屏各开一个浮层窗口。