diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5d63635..216d41f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -27,14 +27,10 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install flake8 - python -m pip install mypy - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + python -m pip install . - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Type checking with mypy - run: | - mypy --ignore-missing-imports pyscreenrec/__init__.py \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG index 10ee5b0..886d75e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,3 +28,14 @@ Change Log - Temporary screenshots are now stored in `~/.pyscreenrec_data` folder. - Internal refactors. -------------- + + +0.6 (09/11/24) +-------------- +- Write screenshots directly to the video stream instead of the disk. +- Delegate image writing to a separate thread. +- Use mss library instead of pyscreeze for capturing screenshots. +- Capture a part of the screen. +- Performance improvements. +- Internal refactors. +-------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62143a3..7207d94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,38 +4,54 @@ The following is a set of guidelines for contributing to *pyscreenrec*, which is hosted on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. -## Repository structure -``` -. -├── .github # issue templates and workflows -│ ├── ISSUE_TEMPLATE # issue templates - ├── bug_report.md - ├── custom.md - ├── feature_request.md - ├── workflows # CI/CD workflows - ├── .python-package.yml -└── pyscreenrec - ├── __init__.py # main code -├── .gitignore -├── CHANGELOG -├── CONTRIBUTING.md -├── LICENSE -├── README.md -├── requirements.txt -└── setup.py # setup config for pypi - -``` - -## Setup environment for contribution +## Setup local development environment + This section shows how you can setup your development environment to contribute to *pyscreenrec*. 1. Fork the repository. + 2. Clone it using Git (`git clone https://github.com//pyscreenrec.git`). -3. Install requirements using `pip install -r requirements.txt`. -4. Make changes. -5. Stage and commit (`git add .` and `git commit -m "COMMIT MESSAGE"`). -6. Push it your remote repository (`git push`). -7. Open a pull request by clicking [here](https://github.com/shravanasati/pyscreenrec/compare). + +3. Create a virtual environment. + +``` +python -m venv venv +``` + +Activate it using + +``` +./venv/Scripts/Activate.ps1 +``` +on Windows + +``` +source ./venv/bin/activate +``` + +on unix-based systems (make sure to choose the activation script according to your shell) + +4. Install dependencies. + +We recommend using [poetry](https://python-poetry.org) for dependency management. + +``` +poetry install --no-root +``` + +Otherwise, using pip: + +``` +pip install . +``` + +5. Make your changes. + +6. Stage and commit (`git add .` and `git commit -m "COMMIT MESSAGE"`). + +7. Push it your remote repository (`git push`). + +8. Open a pull request by clicking [here](https://github.com/shravanasati/pyscreenrec/compare). ## Reporting Issues diff --git a/README.md b/README.md index f02fd39..2321162 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,26 @@ Install on Linux/macOS:
## Example usage + + ``` python >>> import pyscreenrec >>> recorder = pyscreenrec.ScreenRecorder() >>> # to start recording ->>> recorder.start_recording("recording.mp4", 10) +>>> recorder.start_recording("recording.mp4", 30, { + "mon": 1, + "left": 100, + "top": 100, + "width": 1000, + "height": 1000 +}) >>> # 'recording.mp4' is the name of the output video file, may also contain full path like 'C:/Users//Videos/video.mp4' ->>> # the second parameter(10) is the FPS. You can specify the FPS for the screen recording using the second parameter. +>>> # the second parameter is the FPS for the recording +>>> # the third parameter (optional) is the monitor and the dimensions that needs to be recorded, +# here we're capturing the first monitor, 100px from left, 100px from right, and then 1000px each in resp. axes +# refer https://python-mss.readthedocs.io/examples.html#part-of-the-screen-of-the-2nd-monitor for more information + >>> # to pause recording >>> recorder.pause_recording() @@ -36,6 +48,8 @@ Install on Linux/macOS: >>> recorder.stop_recording() ``` +> Take a look at the GUI screen recorder [here](examples/gui_recorder.py) for more information. + Keep in mind that the `start_recording` method is non-blocking, it will start a thread in the background to capture the screenshots. The `stop_recording` saves the video and deletes all screenshots used in the session. @@ -45,29 +59,35 @@ If a screen recording session is already running, calling the `start_recording` Similarly, if a screen recording session is not running, calling the `stop_recording` and `pause_recording` methods raises a `NoScreenRecodingInProgress` warning. +
## Known limitations + *pyscreenrec* is not able to: - capture the system sound during screen recording -- capture only a certain part of the screen
## Change Log -Changes made in the latest version (*v0.5*) are: -- Remove the `HighFPSWarning` and `InvalidFPS` exception classes. -- Raise frame count by almost 2 times. -- Calling start and resume recording methods on an already running recorder instance raises a warning instead of printing, and vice versa. -- Temporary screenshots are now stored in `~/.pyscreenrec_data` folder. + +Changes made in the latest version (*v0.6*) are: + +- Write screenshots directly to the video stream instead of the disk. +- Delegate image writing to a separate thread. +- Use mss library instead of pyscreeze for capturing screenshots. +- Capture a part of the screen. +- Performance improvements. - Internal refactors. + View [CHANGELOG](https://github.com/shravanasati/pyscreenrec/blob/master/CHANGELOG) for more details.
## Contribution + Pull requests are welcome. If you want to make a major change, open an issue first to discuss about the change. For further details, view [CONTRIBUTING.md](https://github.com/shravanasati/pyscreenrec/blob/master/CONTRIBUTING.md). \ No newline at end of file diff --git a/examples/gui_recorder.py b/examples/gui_recorder.py new file mode 100644 index 0000000..99bbe6c --- /dev/null +++ b/examples/gui_recorder.py @@ -0,0 +1,140 @@ +import tkinter as tk +from tkinter import messagebox +from warnings import filterwarnings + +from pyscreenrec import ScreenRecorder, ScreenRecordingInProgress, NoScreenRecordingInProgress + +filterwarnings("error") + +COORDINATES = None + +class RegionSelector(tk.Toplevel): + def __init__(self, parent): + super().__init__(parent) + self.title("Select Recording Region") + self.geometry("800x600") + self.attributes('-alpha', 0.3) + self.attributes('-fullscreen', True) + self.configure(background='grey') + + self.start_x = None + self.start_y = None + self.end_x = None + self.end_y = None + + self.canvas = tk.Canvas(self, highlightthickness=0) + self.canvas.pack(fill='both', expand=True) + + self.canvas.bind('', self.start_selection) + self.canvas.bind('', self.update_selection) + self.canvas.bind('', self.end_selection) + + self.selection_rect = None + + def start_selection(self, event): + self.start_x = event.x + self.start_y = event.y + if self.selection_rect: + self.canvas.delete(self.selection_rect) + + def update_selection(self, event): + if self.selection_rect: + self.canvas.delete(self.selection_rect) + self.selection_rect = self.canvas.create_rectangle( + self.start_x, self.start_y, event.x, event.y, outline='red' + ) + + def end_selection(self, event): + self.end_x = event.x + self.end_y = event.y + global COORDINATES + COORDINATES = self.get_coordinates() + self.destroy() + + def get_coordinates(self): + # Ensure coordinates are in the correct order + left = min(self.start_x, self.end_x) + top = min(self.start_y, self.end_y) + x2 = max(self.start_x, self.end_x) + y2 = max(self.start_y, self.end_y) + width = x2 - left + height = y2 - top + return { + "top": top, + "left": left, + "height": height, + "width": width, + } + + +class GUIScreenRecorder(tk.Tk): + def __init__(self): + super().__init__() + self.title("Screen Recorder") + self.geometry("500x400") + + self.recorder = ScreenRecorder() + + self.start_button = tk.Button(self, text="Start Recording", command=self.start_recording) + self.start_button.pack(pady=10) + + self.select_region_button = tk.Button(self, text="Select Region", command=self.select_recording_region) + self.select_region_button.pack(pady=10) + + self.pause_button = tk.Button(self, text="Pause Recording", command=self.pause_recording) + self.pause_button.pack(pady=10) + + self.resume_button = tk.Button(self, text="Resume Recording", command=self.resume_recording) + self.resume_button.pack(pady=10) + + self.stop_button = tk.Button(self, text="Stop Recording", command=self.stop_recording) + self.stop_button.pack(pady=10) + + self.filename_entry = tk.Entry(self, width=40) + self.filename_entry.pack(pady=10) + self.filename_entry.insert(0, "Recording.mp4") + + self.fps_entry = tk.Entry(self, width=10) + self.fps_entry.pack(pady=10) + self.fps_entry.insert(0, "30") + + def select_recording_region(self): + selector = RegionSelector(self) + selector.mainloop() + + def start_recording(self): + try: + filename = self.filename_entry.get() + fps = int(self.fps_entry.get()) + self.recorder.start_recording(filename, fps, COORDINATES) + messagebox.showinfo("Recording Started", "Screen recording has started.") + except (ValueError, SyntaxError): + messagebox.showerror("Invalid Input", "Please enter valid values for the filename and FPS.") + except ScreenRecordingInProgress: + messagebox.showerror("Recording in Progress", "Screen recording is already in progress.") + + def pause_recording(self): + try: + self.recorder.pause_recording() + messagebox.showinfo("Recording Paused", "Screen recording has been paused.") + except NoScreenRecordingInProgress: + messagebox.showerror("No Recording", "No screen recording is in progress.") + + def resume_recording(self): + try: + self.recorder.resume_recording() + messagebox.showinfo("Recording Resumed", "Screen recording has been resumed.") + except ScreenRecordingInProgress: + messagebox.showerror("Recording in Progress", "Screen recording is already in progress.") + + def stop_recording(self): + try: + self.recorder.stop_recording() + messagebox.showinfo("Recording Stopped", "Screen recording has stopped.") + except NoScreenRecordingInProgress: + messagebox.showerror("No Recording", "No screen recording is in progress.") + + +if __name__ == "__main__": + app = GUIScreenRecorder() + app.mainloop() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..1b15fc6 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,92 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "mss" +version = "9.0.2" +description = "An ultra fast cross-platform multiple screenshots module in pure python using ctypes." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mss-9.0.2-py3-none-any.whl", hash = "sha256:685fa442cc96d8d88b4eb7aadbcccca7b858e789c9259b603e1ef0e435b60425"}, + {file = "mss-9.0.2.tar.gz", hash = "sha256:c96a4ec73224da7db22bc07ef3cfaa18f8b86900d1872e29113bbcef0093a21e"}, +] + +[package.extras] +dev = ["build (==1.2.1)", "mypy (==1.11.2)", "ruff (==0.6.3)", "twine (==5.1.1)", "wheel (==0.44.0)"] +test = ["numpy (==2.1.0)", "pillow (==10.4.0)", "pytest (==8.3.2)", "pytest-cov (==5.0.0)", "pytest-rerunfailures (==14.0.0)", "pyvirtualdisplay (==3.0)", "sphinx (==8.0.2)"] + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + +[[package]] +name = "opencv-python" +version = "4.10.0.84" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +files = [ + {file = "opencv-python-4.10.0.84.tar.gz", hash = "sha256:72d234e4582e9658ffea8e9cae5b63d488ad06994ef12d81dc303b17472f3526"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:fc182f8f4cda51b45f01c64e4cbedfc2f00aff799debebc305d8d0210c43f251"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", hash = "sha256:71e575744f1d23f79741450254660442785f45a0797212852ee5199ef12eed98"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a332b50488e2dda866a6c5573ee192fe3583239fb26ff2f7f9ceb0bc119ea6"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ace140fc6d647fbe1c692bcb2abce768973491222c067c131d80957c595b71f"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win32.whl", hash = "sha256:2db02bb7e50b703f0a2d50c50ced72e95c574e1e5a0bb35a8a86d0b35c98c236"}, + {file = "opencv_python-4.10.0.84-cp37-abi3-win_amd64.whl", hash = "sha256:32dbbd94c26f611dc5cc6979e6b7aa1f55a64d6b463cc1dcd3c95505a63e48fe"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.21.0", markers = "python_version == \"3.9\" and platform_system == \"Darwin\" and platform_machine == \"arm64\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, + {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.19.3", markers = "platform_system == \"Linux\" and platform_machine == \"aarch64\" and python_version >= \"3.8\" and python_version < \"3.10\" or python_version > \"3.9\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_system != \"Darwin\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_machine != \"arm64\" and python_version < \"3.10\""}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "da98010c115dfdbeb52a8092d27d4a55b49ae0bb30fe4b3dab830b1f5735e9a4" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..16fa90d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.poetry] +name = "pyscreenrec" +version = "0.6" +description = "A small and cross-platform python library for recording screen." +authors = ["Shravan Asati "] +license = "MIT" +readme = "README.md" +keywords = ["python", "screen recording", "screen", "recording", "screenshots"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Operating System :: Unix", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries", +] + +[tool.poetry.dependencies] +python = "^3.9" +numpy = "^1.26.4" +opencv-python = "^4.9" +mss = "^9.0.2" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/pyscreenrec/__init__.py b/pyscreenrec/__init__.py index d2fcea5..607a0ea 100644 --- a/pyscreenrec/__init__.py +++ b/pyscreenrec/__init__.py @@ -1,19 +1,13 @@ -import os +from multiprocessing import Queue, Value from threading import Thread import time from warnings import warn import cv2 -from natsort import natsorted -from pyscreeze import screenshot - - -class InvalidCodec(Exception): - pass - - -class InvalidStartMode(Exception): - pass +import mss +from mss.models import Monitor +from mss.screenshot import ScreenShot +import numpy as np class ScreenRecordingInProgress(Warning): @@ -43,176 +37,190 @@ def __init__(self) -> None: """ Constructor. """ - self.__running = False - self.__start_mode = "start" - self.screenshot_folder = os.path.join( - os.path.expanduser("~"), ".pyscreenrec_data" - ) - os.makedirs(self.screenshot_folder, exist_ok=True) - - # clearing all the previous data if last session ended unsuccessfully - self._clear_data() - - # used for maintaining screenshot count - self.__count = 1 - - def _screenshot(self, filename: str) -> float: - """ - A helper function which saves a screenshot to `self.screenshot_folder` with the - given filename, and returns the duration it took to perform the operation. - """ - st_start = time.perf_counter() - screenshot(os.path.join(self.screenshot_folder, filename)) - st_end = time.perf_counter() - return st_end - st_start + # 0 -> False, 1 -> True + self.__running = Value("i", 0) + self.queue: Queue[ScreenShot | None] = Queue() def _start_recording(self) -> None: """ (Protected) Starts screen recording. + This function is meant to be run in a separate thread. + It continuosly grabs screenshots using mss and sends them + to the saver thread, which is responsible for writing these + images to the video stream. + """ + # ! not instantiating this in the constructor because mss has issues + # ! with using instances from main thread in sub-threads + # ! AttributeError: '_thread._local' object has no attribute 'srcdc' + with mss.mss() as sct: + # checking if screen is already being recorded + if self.__running.value != 0: + warn("Screen recording is already running.", ScreenRecordingInProgress) + + else: + self.__running.value = 1 + + while self.__running.value != 0: + # not sleeping for exactly 1/self.fps seconds because + # otherwise time is lost in sleeping which could be used in + # capturing frames + # since due to thread context-switching, this screenshotter + # thread doesn't get all the time that it needs + # thus, if more than required time has been spent just on + # screenshotting, don't sleep at all + st_start = time.perf_counter() + self.queue.put(sct.grab(self.mon)) + st_total = time.perf_counter() - st_start + time.sleep(max(0, 1 / self.fps - st_total)) + + @staticmethod + def _get_monitor(mon: Monitor | None): + if mon is None: + with mss.mss() as sct: + return sct.monitors[0] + + return mon + + def start_recording(self, video_name: str, fps: int, monitor: Monitor | None = None) -> None: + """ + Starts screen recording. It is a non-blocking call. + The `stop_recording` method must be called after this + function call for the video to be rendered. + + Raises a warning `ScreenRecordingInProgress` if this method is called while + the screen recording is already running. + @params - video_name (str) --> The name of the screen recording video. + video_name (str) --> The name of the output screen recording. Must end with `.mp4`. fps (int) --> The Frames Per Second for the screen recording. Implies how much screenshots will be taken in a second. - """ - # checking if screen is already being recorded - if self.__running: - warn("Screen recording is already running.", ScreenRecordingInProgress) - - else: - if self.__start_mode not in ("start", "resume"): - raise InvalidStartMode( - "The `self.__start_mode` can only be 'start' or 'resume'." - ) - - self.__running = True - - while self.__running: - # not sleeping for exactly 1/self.fps seconds because - # otherwise time is lost in sleeping which could be used in - # capturing frames - # since due to thread context-switching, this screenshotter - # thread doesn't get all the time that it needs - # thus, if more than required time has been spent just on - # screenshotting, don't sleep at all - st_total = self._screenshot(f"s{self.__count}.jpg") - time.sleep(max(0, 1 / self.fps - st_total)) - self.__count += 1 - - def start_recording(self, video_name: str = "Recording.mp4", fps: int = 15) -> None: - """ - Starts screen recording. - @params + monitor (Monitor, optional) -> The monitor that needs to be captured, here you can specify the region of the screen you want to record. It must be dictionary with these fields: - video_name (str) --> The name of the output screen recording. + { + "mon": 1, + "left": 100, + "top": 100, + "width": 1000, + "height": 1000 + } - fps (int) --> The Frames Per Second for the screen recording. Implies how much screenshots will be taken in a second. + If this parameter is not provided, pyscreenrec captures the entire screen. """ self.fps = fps self.video_name = video_name + self.mon = self._get_monitor(monitor) + # checking for video extension if not self.video_name.endswith(".mp4"): - raise InvalidCodec("The video's extension can only be '.mp4'.") + raise ValueError("The video's extension can only be '.mp4'.") + + self.recorder_thread = Thread(target=self._start_recording) + self.recorder_thread.start() + self.saver_proc = Thread(target=self._write_img_to_stream) + self.saver_proc.start() - t = Thread(target=self._start_recording) - t.start() + def _write_img_to_stream(self): + """ + (Protected) This function is also meant to be run in a separate thread. + + It creates a video writer object and listens on the queue for images that + need to written to the video, and also releases the video when `stop_recording` + is called. + """ + width, height = self.mon["width"], self.mon["height"] + + video = cv2.VideoWriter( + self.video_name, cv2.VideoWriter_fourcc(*"mp4v"), self.fps, (width, height) + ) + + while True: + img = self.queue.get() + if img is None: + break + video.write(cv2.cvtColor(np.array(img), cv2.COLOR_BGRA2BGR)) + + video.release() def stop_recording(self) -> None: """ Stops screen recording. + + Raises a warning `NoScreenRecordingInProgress` if this method is called while + no screen recording is already running. """ - if not self.__running: + if self.__running.value == 0: warn( "No screen recording session is going on.", NoScreenRecordingInProgress ) return - self.__running = False - # reset screenshot count and start_mode - self.__count = 1 - self.__start_mode = "start" + # stop both the processes + self.__running.value = 0 + self.recorder_thread.join() - # saving the video and clearing all screenshots - self._save_video(self.video_name) - self._clear_data() + # signal _save_image process to quit + self.queue.put(None) + self.saver_proc.join() def pause_recording(self) -> None: """ Pauses screen recording. + + Raises a warning `NoScreenRecordingInProgress` if this method is called while + no screen recording is already running. """ - if not self.__running: + if self.__running.value == 0: warn( "No screen recording session is going on.", NoScreenRecordingInProgress ) return - self.__running = False + self.__running.value = 0 def resume_recording(self) -> None: """ Resumes screen recording. + + Raises a warning `ScreenRecordingInProgress` if this method is called while + the screen recording is already running. """ - if self.__running: + if self.__running.value != 0: warn("Screen recording is already running.", ScreenRecordingInProgress) return - self.__start_mode = "resume" - self.start_recording(self.video_name) - - def _save_video(self, video_name: str) -> None: - """ - (Protected) Makes a video out of the screenshots. - - @params - - video_name (str) --> Name or path to the output video. - """ - # fetching image info - images = natsorted( - [img for img in os.listdir(self.screenshot_folder) if img.endswith(".jpg")] - ) - # print(f"{len(images)=}") - frame = cv2.imread(os.path.join(self.screenshot_folder, images[0])) - height, width, _ = frame.shape - - # making a videowriter object - video = cv2.VideoWriter( - video_name, cv2.VideoWriter_fourcc(*"mp4v"), self.fps, (width, height) - ) - - # writing all the images to a video - for image in images: - video.write(cv2.imread(os.path.join(self.screenshot_folder, image))) - - # releasing video - cv2.destroyAllWindows() - video.release() - - def _clear_data(self) -> None: - """ - (Protected) Deletes all screenshots present in the screenshot folder taken during screen recording. - """ - # deleting all screenshots present in the screenshot directory - for image in os.listdir(self.screenshot_folder): - os.remove(os.path.join(self.screenshot_folder, image)) + # the first call to start_recording already sets up saver process + # so we dont need to start it again + self.recorder_thread = Thread(target=self._start_recording) + self.recorder_thread.start() def __repr__(self) -> str: - return "pyscreenrec is a small and cross-platform python library that can be used to record screen. \nFor more info, visit https://github.com/shravanasati/pyscreenrec#readme." + return f"ScreenRecorder " if __name__ == "__main__": rec = ScreenRecorder() + print("recording started") - rec.start_recording(fps=30) + rec.start_recording("Recording.mp4", fps=30, monitor={ + "mon": 1, + "left": 100, + "top": 100, + "width": 1000, + "height": 1000 + }) time.sleep(5) + print("pausing") rec.pause_recording() time.sleep(2) + print("resuming") rec.resume_recording() time.sleep(5) + print("recording ended") rec.stop_recording() diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8aea735..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -natsort==8.4.0 -numpy==1.26.4 -opencv-python==4.9.0.80 -pillow==10.4.0 -PyScreeze==0.1.30 diff --git a/setup.py b/setup.py deleted file mode 100644 index bae1368..0000000 --- a/setup.py +++ /dev/null @@ -1,33 +0,0 @@ -from setuptools import find_packages, setup - -VERSION = "0.5" -with open("README.md") as f: - README = f.read() - -setup( - name="pyscreenrec", - version=VERSION, - description="A small and cross-platform python library for recording screen.", - long_description_content_type="text/markdown", - long_description=README, - url="https://github.com/shravanasati/pyscreenrec", - author="Shravan Asati", - author_email="dev.shravan@protonmail.com", - packages=find_packages(), - install_requires=["pyscreeze", "opencv-python", "natsort"], - license="MIT", - keywords=["python", "screen recording", "screen", "recording", "screenshots"], - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Operating System :: Unix", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - "Topic :: Software Development :: Libraries", - ], -)