一行命令实现录屏,支持热键和鼠标操作,区域、帧率、格式任你选择

举报
天元浪子 发表于 2021/07/29 14:17:56 2021/07/29
【摘要】 这款命令行模式的录屏软件,可将屏幕指定区域的内容录制成GIF动画文件或MP4、AVI、WMV等格式的视频文件,录像区域、格式、帧率等参数,既可以由命令行传入,也可以通过鼠标和热键来调整。虽然只是实现了录屏功能,却涉及了键盘鼠标侦听、捕捉窗口句柄、动画和视频制作、定时器、线程、队列等技术,以及生产者-消费者模式,几乎就是一个Python技术博览馆。

       市面上的录屏工具软件有很多,基本都是窗口程序。毕竟,离开GUI的支持,设置参数、选择录像区域等操作都会变得非常困难。不过,窗口程序也并非无往不胜,即便是屏幕录像这样交互频繁的应用,控制台程序也同样可以大显身手,甚至比窗口程序的效率更高、操作更便捷。

       今天,我带给同学们的是一款命令行模式的录屏软件,可将屏幕指定区域的内容录制成GIF动画文件或MP4、AVI、WMV等格式的视频文件,录像区域、格式、帧率等参数,既可以由命令行传入,也可以通过鼠标和热键来调整。虽然只是实现了录屏功能,却涉及了以下诸多知识点:

  1. 使用pynput模块的keyboard和mouse侦听键盘和鼠标,实现热键机制和鼠标选取
  2. 使用pywin32模块的win32api、win32gui和win32con捕捉当前窗口句柄,实现窗口的隐藏和显示
  3. 使用pillow模块的ImageGrab实现屏幕截图
  4. 使用imageio模块生成GIF或MP4文件
  5. 使用Python标准模块optparse构造linux风格的使用界面,遵循GNU/POSIX语法规则设置参数选项
  6. 使用批处理命令编写批处理文件,最终生成桌面快捷方式

       除了上述知识点外,这款屏幕录像机还用到了定时器、线程、队列等技术,以及生产者-消费者模式,几乎就是一个Python技术博览馆。

1. 监听键盘和鼠标

       尽管pywin32也可以监听键盘和鼠标,但我选择是的pynput模块,因为它实在是太好用了,还可以跨平台。除了监听,pynput模块也可以用来操控键盘和鼠标。pynput模块的安装很简单,直接使用pip安装即可。

pip install pynput

       pynput模块提供了keyboard和mouse两个类用于监听键盘和鼠标,实例化时只需要提供相应的事件函数即可。通过下面的简单例子,新手也很容易掌握pynput的使用要点。友情提示:不要在运行这段代码的命令行窗口中测试鼠标左键,因为点击左键会影响程序执行,导致反应迟滞。

from pynput import keyboard, mouse

def on_click(x, y, button, pressed):
    """鼠标按键"""
    
    action = '按下' if pressed else '弹起'
    if button == mouse.Button.left:
        print('左键%s,(%d,%d)'%(action, x, y))
        
    elif button == mouse.Button.right:
        print('右键%s,(%d,%d)'%(action, x, y))

def on_press(key):
    """键按下"""
    
    if key == keyboard.Key.ctrl_l or key == keyboard.Key.ctrl_r:
        print('Ctr键按下')

def on_release(key):
    """键弹起"""
    
    if key == keyboard.Key.ctrl_l or key == keyboard.Key.ctrl_r:
        print('Ctr键弹起')
    elif key == keyboard.Key.esc:
        print('再见')
        return False

monitor_m = mouse.Listener(on_click=on_click)
monitor_m.start()

monitor_k = keyboard.Listener(on_press=on_press, on_release=on_release)
monitor_k.start()
monitor_k.join()

2. 隐藏或显示控制台窗口

       作为录屏软件,录制时需要将自身窗口隐藏,而开始录制前或录制结束后又需要显示自身窗口。虽然最小化、最大化窗口也可以满足使用要求,但我选择使用pywin32来隐藏或显示控制台窗口。pywin32模块的安装命令如下:

pip install pypiwin32

       下面的3行语句分别实现获取当前窗口句柄、隐藏和显示窗口句柄指定的窗口。

import win32gui, win32api, win32con

hwnd = win32gui.GetForegroundWindow() # 获取最前窗口句柄
win32gui.ShowWindow(hwnd, win32con.SW_HIDE) # 隐藏hwnd指定的窗口
win32gui.ShowWindow(hwnd, win32con.SW_SHOW) # 显示hwnd指定的窗口

3. 屏幕截图

       无所不能的pywin32也可以截屏,据说速度很快。为此我专门测试了全屏幕(1902x1080)截取,发现pywin32和pillow的ImageGrab在速度上堪堪打了个平手,但pywin32截图需要从构建DC开始,大约十几行代码,而ImageGrab只需要一行代码。既然代码简洁,比速度也不逊色,还有什么理由不选择ImageGrab呢?

       ImageGrab子模块提供了一个截屏的函数grab,返回一个PIL图像对象。grab函数接受一个四元组参数用以指定截图区域的左上角和右下角在屏幕上的坐标,若省略参数,grab函数将截取整个屏幕。

from PIL import ImageGrab

im = ImageGrab.grab((1200,600,1920,1080)) # 截取大小为720×480的屏幕区域
im.show()
im = ImageGrab.grab() # 截取整个屏幕
im.show()

       虽然代码中看起来ImageGrab是从PIL模块导入的,但实际上ImageGrab来自pillow模块,这是由于版本历史的原因造成的困惑。如果你还没有安装pillow模块,请使用如下的命令安装:

pip install pillow

4. 生成动画或视频文件

       Python图像库有很多,imageio是后起之秀,也是其中的佼佼者,尤其在动画和视频领域,更是独领风骚。在imageio诞生之前,生成GIF动画需要几百行代码,而且因为依赖库升级频繁,几乎每隔一段时间就需要重写一次。现在有了imageio,一切都变得云淡风轻了。安装imageio时,请一并安装imageio-ffmpeg,这是imageio生成视频文件的依赖库。

pip install imageio
pip install imageio-ffmpeg

       imageio提供了两种生成动画或视频文件的方法:imageio.mimsave函数和imageio.get_writer函数。imageio.mimsave函数接收一个由PIL对象组成的列表作为参数,生成文件前需要将每一帧图像转成PIL对象并存入列表——这意味着生成文件必须在最后一帧图像捕捉完成之后才能开始。

# imageio.mimsave(out_file, pil_list, format='GIF', fps=fps, loop=loop)
# out_file      - 输出文件名
# pil_list      - 列表,元素类型为PIL对象
# format        - 输出格式
# fps           - 帧率(每秒播放的帧数)
# loop          - 循环次数,0表示无限循环(视频格式不支持该参数)

       imageio.get_writer函数类似于open函数,返回了一个文件对象,该对象提供append_data方法,可以将单帧的PIL对象对写入输出文件。写入完成后,不要忘记使用close方法关闭文件对象。使用imageio.get_writer函数生成动画或视频文件,可以很好得支持生产者-消费者模式,捕捉一帧写入一帧,停止录屏后文件即告生成。

# writer = imageio.get_writer(out_file, fps=fps) # gif格式可增加loop参数
# writer.append_data(im_pil) # im_pil为PIL对象
# writer.close()

5. 定时器

       录屏依赖于精准的定时器,遗憾的是Python并没有提供一个像样的定时器,因此只能自己写一个了。关于定时器的介绍,请参考我昨天写的博文《无所不能的Python竟然没有一个像样的定时器?试试这个!》,这里就不再赘述了。

6. 完整代码

       源文件名为ScreenRecorder.py,全部代码不足300行,代码中用到的各个模块和技术要点都已介绍过了,关键之处均有注释。

# -*- coding:utf-8 -*-

import os, time
import optparse
import threading
import imageio
import queue
import numpy as np
from PIL import Image, ImageGrab
import win32gui, win32api, win32con
from pynput import keyboard, mouse

class PyTimer:
    """定时器类"""
    
    def __init__(self, func, *args, **kwargs):
        """构造函数"""
        
        self.func = func
        self.args = args
        self.kwargs = kwargs
        
        self.running = False
    
    def _run_func(self):
        """运行定时事件函数"""
        
        th = threading.Thread(target=self.func, args=self.args, kwargs=self.kwargs)
        th.setDaemon(True)
        th.start()
    
    def _start(self, interval, once):
        """启动定时器的线程函数"""
        
        if interval < 0.010:
            interval = 0.010
        
        if interval < 0.050:
            dt = interval/10
        else:
            dt = 0.005
        
        if once:
            deadline = time.time() + interval
            while time.time() < deadline:
                time.sleep(dt)
            
            # 定时时间到,调用定时事件函数
            self._run_func()
        else:
            self.running = True
            deadline = time.time() + interval
            while self.running:
                while time.time() < deadline:
                    time.sleep(dt)
                
                deadline += interval # 更新下一次定时时间
                if self.running: # 定时时间到,调用定时事件函数
                    self._run_func()
    
    def start(self, interval, once=False):
        """启动定时器
        
        interval    - 定时间隔,浮点型,以秒为单位,最高精度10毫秒
        once        - 是否仅启动一次,默认是连续的
        """
        
        th = threading.Thread(target=self._start, args=(interval, once))
        th.setDaemon(True)
        th.start()
    
    def stop(self):
        """停止定时器"""
        
        self.running = False

class ScreenRecorder:
    """屏幕记录器"""
    
    def __init__(self, out, fps=10, nfs=1000, loop=0):
        """构造函数"""
        
        self.format = ('.gif', '.mp4', '.avi', '.wmv')
        
        ext = os.path.splitext(out)[1].lower()
        if not ext in self.format:
            raise ValueError('不支持的文件格式:%s'%ext)
        
        self.out = out
        self.ext = ext
        self.fps = fps
        self.nfs = nfs
        self.loop = loop
        
        self.cw = win32api.GetSystemMetrics(win32con.SM_CXSCREEN)
        self.ch = win32api.GetSystemMetrics(win32con.SM_CYSCREEN)
        self.set_box((0, 0, self.cw, self.ch))
        
        self.ctr_is_pressed = False
        self.hidding = False
        self.recording = False
        self.pos_click = (0,0)
        self.q = None
        
        self.hwnd = self._find_self()
        self.info = None
        self.help()
        self.status()
    
    def _find_self(self):
        """找到当前Python解释器的窗口句柄"""
        
        return win32gui.GetForegroundWindow() # 获取最前窗口句柄
    
    def set_box(self, box):
        """设置记录区域"""
        
        x0, y0, x1, y1 = box
        dx, dy = (x1-x0)%16, (y1-y0)%16
        dx0, dx1 = dx//2, dx-dx//2
        dy0, dy1 = dy//2, dy-dy//2
        
        self.box = (x0+dx0, y0+dy0, x1-dx1, y1-dy1)
    
    def help(self):
        """热键提示"""
        
        print('---------------------------------------------')
        print('Ctr + 回车键:隐藏/显示窗口')
        print('Ctr + 鼠标左键或右键拖拽:设置记录区域')
        print('Ctr + PageUp/PageDown:更改记录格式')
        print('Ctr + Up/Down:调整帧率')
        print('Ctr + 空格键:开始/停止记录')
        print('Esc:退出')
        print()
    
    def status(self):
        """当前状态"""
        
        if self.info:
            print('\r%s'%(' '*len(self.info.encode('gbk')),), end='', flush=True)
        
        recording_text = '正在记录' if self.recording else '准备就绪'
        if self.ext == 'gif':
            loop_str = '循环%d次'%self.loop if self.loop > 0 else '循环'
        else:
            loop_str = '不循环'
        
        self.info = '\r输出文件:%s | 帧率:%d | 区域:%s'%(self.out, self.fps, str(self.box))
        print(self.info, end='', flush=True)
    
    def start(self):
        """开始记录"""
        
        self.q = queue.Queue(100)
        self.timer = PyTimer(self.capture)
        self.timer.start(1/self.fps)
        
        th = threading.Thread(target=self.produce)
        th.setDaemon(True)
        th.start()
    
    def stop(self):
        """停止记录"""
        
        self.timer.stop()
    
    def capture(self):
        """截屏"""
        
        if not self.q.full():
            im = ImageGrab.grab(self.box)
            self.q.put(im)
    
    def produce(self):
        """生成动画或视频文件"""
        
        if self.ext == '.gif':
            writer = imageio.get_writer(self.out, fps=self.fps, loop=self.loop)
        else:
            writer = imageio.get_writer(self.out, fps=self.fps)
        
        n = 0
        while self.recording and n < self.nfs:
            if self.q.empty():
                time.sleep(0.01)
            else:
                im = np.array(self.q.get())
                writer.append_data(im)
                n += 1
        
        writer.close()
    
    def on_press(self, key):
        """键按下"""
        
        if key == keyboard.Key.ctrl_l or key == keyboard.Key.ctrl_r:
            self.ctr_is_pressed = True
    
    def on_release(self, key):
        """键释放"""
        
        if key == keyboard.Key.ctrl_l or key == keyboard.Key.ctrl_r:
            self.ctr_is_pressed = False
        elif key == keyboard.Key.space and self.ctr_is_pressed:
            if self.recording: # 停止记录
                self.stop()
                self.recording = False
                if self.hidding:
                    win32gui.ShowWindow(self.hwnd, win32con.SW_SHOW) # 显示窗口
                    self.hidding = False
            else: # 开始记录
                self.start()
                self.recording = True
                if not self.hidding:
                    win32gui.ShowWindow(self.hwnd, win32con.SW_HIDE) # 隐藏窗口
                    self.hidding = True
        elif key == keyboard.Key.enter and self.ctr_is_pressed:
            if self.hidding: # 显示窗口
                win32gui.ShowWindow(self.hwnd, win32con.SW_SHOW) # 显示窗口
                self.hidding = False
                self.status()
            else: # 隐藏窗口
                win32gui.ShowWindow(self.hwnd, win32con.SW_HIDE) # 隐藏窗口
                self.hidding = True
        elif (key == keyboard.Key.page_down or key == keyboard.Key.page_up) and self.ctr_is_pressed:
            i = self.format.index(self.ext)
            if key == keyboard.Key.page_down:
                self.ext = self.format[(i+1)%len(self.format)]
            else:
                self.ext = self.format[(i-1)%len(self.format)]
            
            folder = os.path.split(self.out)[0]
            dt_str = time.strftime('%Y%m%d%H%M%S')
            self.out = os.path.join(folder, '%s%s'%(dt_str, self.ext))
            self.status()
        elif key == keyboard.Key.left and self.ctr_is_pressed:
            if self.fps > 1:
                self.fps -= 1
                self.status()
        elif key == keyboard.Key.right and self.ctr_is_pressed:
            if self.fps < 40:
                self.fps += 1
                self.status()
        elif key == keyboard.Key.esc:
            print('\n程序已结束')
            return False
    
    def on_click(self, x, y, button, pressed):
        """鼠标按键"""
        
        if (button == mouse.Button.left or button == mouse.Button.right) and self.ctr_is_pressed:
            if pressed:
                self.pos_click = (x, y)
            elif self.pos_click != (x, y):
                x0, y0 = self.pos_click
                self.set_box((min(x0,x), min(y0,y), max(x0,x), max(y0,y)))
                self.status()

def parse_args():
    """获取参数"""

    parser = optparse.OptionParser()
    
    parser.add_option('-o', '--out', action='store', type='string', dest='out', default='', help='输出文件名')
    parser.add_option('-f', '--fps', action='store', type='int', dest='fps', default='10', help='帧率')
    parser.add_option('-n', '--nfs', action='store', type='int', dest='nfs', default='1000', help='最大帧数')
    parser.add_option('-l', '--loop', action='store', type='int', dest='loop', default=0, help='循环')
    
    return parser.parse_args()

if __name__ == '__main__':

    options, args = parse_args()
    
    if options.out:
        out = options.out
        folder = os.path.split(out)[0]
        if folder and not os.path.isdir(folder):
            raise ValueError('路径不存在:%s'%folder)
    else:
        dt_str = time.strftime('%Y%m%d%H%M%S')
        out = os.path.join(os.getcwd(), '%s.mp4'%(dt_str,))
    
    sr = ScreenRecorder(out, fps=options.fps, nfs=options.nfs, loop=options.loop)
    
    monitor_m = mouse.Listener(on_click=sr.on_click)
    monitor_m.start()
    
    monitor_k = keyboard.Listener(on_press=sr.on_press, on_release=sr.on_release)
    monitor_k.start()
    monitor_k.join()

7. linux风格的使用界面

       通过sys模块的sys.argv接收命令行参数,是很多Python程序员的首选。不过,sys.argv无法处理默认参数、关键字参数,当参数较多时也极易发生张冠李戴的错误。熟悉linux的程序员,更喜欢使用GNU/POSIX语法设置参数选项。

       ScreenRecorder.py借助于Python的标准模块optparse,可以很容易地提供linux风格的使用界面。下图中-h或者–help选项是optparse模块自动生成的。

在这里插入图片描述
       假如要录屏到文件d:\demo.mp4,帧率为25,下面的两种写法是等价的。

python .\ScreenRecorder.py -o d:\demp.mp4 -f 25
python .\ScreenRecorder.py --out=d:\demp.mp4 --fps=25

       录屏程序启动后,界面如下图所示。现在可以使用热键配合鼠标,尽情体验这个新玩具了。

在这里插入图片描述
       细心的同学很快就会发现,界面上的参数信息会实时更新,但屏幕却没有滚动。这是怎么实现的呢?有兴趣的同学可以去读一下代码,或者在我的博客首页搜索“必杀技”,就会找到答案。

8. 生成桌面快捷方式

       先打开一个命令行窗口,再运行命令,还要输入参数(其实不输入参数也可以录屏),有些同学就会觉得很麻烦。没关系,下面我们再搞一个批处理,然后将批处理做成桌面快捷方式,就可以化繁为简一键操作了。

       随便打开一个文本编辑器,生成如下的文件,并以ScreenRecorder.bat命名,保存在和ScreenRecorder.py同级的路径下。如果需要改变录屏的默认参数,也可以写在这个文件中。

@echo off
cd /d d:\XufiveGithub\ScreenRecorder
python ScreenRecorder.py

       有了bat文件之后,就可以在Windows桌面上生成该文件的快捷方式了。不知道如何生成快捷方式的同学,请自行搜索吧。最后附上一张我录制的全球风场GIF动画图(局部)。

在这里插入图片描述

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。