Software Journal
Software Architecture Updated Aug 26, 2026 11 min read

Inside Advanced Screen Recorder: Building a Desktop Screen Capture App with Python, Tkinter, and OpenCV

A code walkthrough of azario0's Advanced Screen Recorder — a Python/Tkinter desktop app that lets you visually select a screen region and record it to MP4/AVI/MOV with OpenCV, threading, and pyautogui.

Portrait of Benmalek Zohir

Benmalek Zohir

Contributor

Share
Inside Advanced Screen Recorder: Building a Desktop Screen Capture App with Python, Tkinter, and OpenCV

Inside Advanced Screen Recorder: Building a Desktop Screen Capture App with Python, Tkinter, and OpenCV

Screen recorders look simple from the outside: pick an area, hit record, get a video file. Underneath, though, they touch a surprisingly wide slice of the stack — GUI event loops, image buffers, video codecs, and multithreading, all of which have to cooperate without freezing the interface. advanced-screen-recorder by azario0 is a compact, single-file Python project that does exactly this, and it’s a good specimen to dissect because every layer of the problem is visible in about 400 lines of code.

This article walks through the source (app.py) and explains what it’s doing, which libraries it leans on, and how the pieces fit together.

What the project is

At a high level, this is a desktop GUI application that:

  1. Shows a control panel window (frame rate, quality, format, output path).
  2. Lets you drag/resize a semi-transparent overlay window to define a rectangular screen region.
  3. Optionally live-previews that region inside the overlay before recording.
  4. Captures the chosen region frame-by-frame with pyautogui.screenshot(), converts each frame with OpenCV, and writes it to a video file with cv2.VideoWriter.
  5. Runs the capture loop on a background thread so the Tkinter UI stays responsive, complete with a live recording timer.

There’s no separate build system, packaging, or test suite — it’s meant to be cloned and run directly, per the README.

The stack

ConcernLibraryWhy it’s used
GUI / windowingtkinter, tkinter.ttkShips with Python, cross-platform, no extra install needed for the widgets
Screen capturepyautoguiSimple screenshot(region=...) API that grabs a region of the screen as a PIL.Image
Video encodingopencv-python (cv2)cv2.VideoWriter handles encoding frames into MP4/AVI/MOV with a chosen codec (fourcc)
Array/image mathnumpyConverts the PIL screenshot into a NumPy array that OpenCV can consume
Image handling for the previewPillow (PIL.Image, PIL.ImageTk)Resizes screenshots to fit the preview canvas and converts them into a Tk-displayable photo image
ConcurrencythreadingRuns the preview loop and the record loop off the main GUI thread
Miscos, datetime, timeFile paths, timestamped filenames, frame timing

All of this is declared right at the top of app.py:

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import cv2
import numpy as np
import threading
import time
from PIL import Image, ImageTk
import pyautogui
import os
from datetime import datetime

Nothing exotic — every dependency is either in the standard library or a well-known package, which is part of why the project is easy to read end to end.

Architecture: one class holding the state machine

The whole app is built around a single class, ScreenRecorder, instantiated once in __main__. Rather than scattering state across global variables, __init__ centralizes everything the app needs to track:

self.is_recording = False
self.is_previewing = False
self.video_writer = None
self.preview_thread = None
self.record_thread = None
self.preview_window = None

self.fps = tk.IntVar(value=30)
self.quality = tk.StringVar(value="High")
self.output_format = tk.StringVar(value=".mp4")
self.output_path = tk.StringVar(value=os.path.expanduser("~/Desktop"))

self.start_x = 100
self.start_y = 100
self.width = 800
self.height = 600

Two things stand out here for anyone coming from other GUI toolkits:

  • tk.IntVar / tk.StringVar are Tkinter’s data-binding primitives. Widgets like the FPS slider or the quality dropdown are wired directly to these variables (variable=self.fps, textvariable=self.quality), so reading a widget’s current value later is just self.fps.get() — no manual event wiring needed to keep UI and state in sync.
  • Booleans as thread control flags (is_recording, is_previewing) are the entire synchronization mechanism. There’s no threading.Event or lock; the background loops just poll these flags each iteration and exit when they flip to False. It’s simple and works fine here because only one thread ever writes to each flag, but it’s worth knowing this is a lightweight pattern, not a general-purpose concurrency primitive.

setup_ui(): building the control panel

setup_ui() constructs the main window using ttk (themed Tk widgets) and Tkinter’s grid geometry manager. It’s a good example of grid-based layout in practice:

  • A ttk.LabelFrame groups the “Recording Area” controls (the button that opens the region-picker, and a label showing the current width x height at (x, y)).
  • Another LabelFrame groups “Recording Settings”: an ttk.Scale (slider) bound to self.fps for 10–60 FPS, ttk.Combobox dropdowns for quality (Low/Medium/High/Ultra) and format (.mp4/.avi/.mov), and a folder browser tied to filedialog.askdirectory.
  • Buttons for “Start Recording” / “Stop Recording” call back into toggle_recording() and stop_recording().
  • columnconfigure(..., weight=1) calls at the end make specific columns stretch when the window resizes, which is how the layout stays responsive instead of clipping.

Nothing here does the actual capture work — this method’s only job is wiring UI elements to the state defined in __init__ and to the methods described next.

Selecting the recording area: the overlay-window trick

This is the project’s most interesting UI idea. Instead of drawing a custom selection rectangle over the desktop (which typically requires OS-specific screen-grab APIs), it repurposes an ordinary Tkinter window as the selection tool:

self.preview_window = tk.Toplevel(self.root)
self.preview_window.geometry(f"{self.width}x{self.height+50}+{self.start_x}+{self.start_y}")
self.preview_window.attributes('-alpha', 0.7)
self.preview_window.configure(bg='red')
  • tk.Toplevel creates a second, independent top-level window.
  • attributes('-alpha', 0.7) makes it semi-transparent, so you can see what’s underneath while positioning it.
  • Because it’s a real OS window, the user can drag and resize it using normal window-manager controls — no custom hit-testing code required.
  • A <Configure> event binding (self.preview_window.bind('<Configure>', self.on_preview_resize)) fires whenever the window moves or resizes, and on_preview_resize() reads the window’s new geometry back out (winfo_width(), winfo_x(), etc.) to update self.width, self.height, self.start_x, self.start_y — which are the exact coordinates later passed to pyautogui.screenshot(region=...).

It’s a clever reuse of window-manager behavior to avoid writing manual drag/resize/hit-testing logic.

Live preview loop

Clicking “Start Preview” spins up a background thread running preview_loop():

def preview_loop(self):
    while self.is_previewing and self.preview_window and self.preview_window.winfo_exists():
        screenshot = pyautogui.screenshot(region=(self.start_x, self.start_y, self.width, self.height))
        ...
        screenshot = screenshot.resize((canvas_width, canvas_height), Image.LANCZOS)
        photo = ImageTk.PhotoImage(screenshot)
        self.preview_canvas.delete("all")
        self.preview_canvas.create_image(canvas_width//2, canvas_height//2, image=photo, anchor=tk.CENTER)
        self.preview_canvas.image = photo  # Keep a reference
        time.sleep(1/30)

A few implementation details worth calling out:

  • self.preview_canvas.image = photo is a well-known Tkinter gotcha: PhotoImage objects get garbage-collected if nothing in Python holds a reference to them, even though the canvas is displaying them. Stashing it as an attribute keeps it alive.
  • The preview is capped at roughly 30 FPS via time.sleep(1/30), independent of the FPS setting used for actual recording — it’s just for visual feedback while positioning the overlay.
  • Image.LANCZOS is a high-quality resampling filter used to fit the captured region into the (usually smaller) preview canvas.

Note that this method takes a screenshot of the region including the overlay window itself sitting on top of it, since the overlay is a real window on the desktop — a subtlety worth knowing if you build on this code.

The actual recording pipeline

This is the core of the app, and it’s a textbook producer loop for screen capture:

def record_loop(self):
    frame_duration = 1.0 / self.fps.get()
    while self.is_recording:
        frame_start = time.time()
        screenshot = pyautogui.screenshot(region=(self.start_x, self.start_y, self.width, self.height))
        frame = cv2.cvtColor(np.array(screenshot), cv2.COLOR_RGB2BGR)
        if self.video_writer:
            self.video_writer.write(frame)
        elapsed = time.time() - frame_start
        sleep_time = max(0, frame_duration - elapsed)
        if sleep_time > 0:
            time.sleep(sleep_time)

Step by step:

  1. pyautogui.screenshot(region=...) grabs the selected region as a PIL.Image in RGB order.
  2. np.array(screenshot) converts it into a NumPy array — this is the “bridge” between PIL’s image representation and OpenCV’s, since OpenCV operates on NumPy arrays.
  3. cv2.cvtColor(..., cv2.COLOR_RGB2BGR) swaps channel order. This matters because PIL/most image libraries use RGB, but OpenCV internally expects BGR — skipping this step is a classic bug that makes recordings look color-inverted.
  4. self.video_writer.write(frame) hands the frame to OpenCV’s video writer, which encodes it into the output file incrementally, frame by frame, rather than buffering the whole recording in memory.
  5. Frame-rate pacing: rather than sleeping a fixed amount, it measures how long the capture+encode actually took (elapsed) and only sleeps the remainder of the target frame interval (frame_duration - elapsed). This keeps the effective frame rate close to the target even though capturing and encoding aren’t instantaneous — a small but important correctness detail. If a frame takes longer than the budget, max(0, ...) prevents a negative sleep.

Setting up the writer and choosing a codec

start_recording() prepares everything before the loop starts:

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"screen_recording_{timestamp}{self.output_format.get()}"
self.output_file = os.path.join(self.output_path.get(), filename)

quality_factor, fourcc = self.get_codec_and_quality()

self.video_writer = cv2.VideoWriter(
    self.output_file, fourcc, self.fps.get(), (self.width, self.height)
)
  • Filenames are auto-timestamped (screen_recording_20260826_143012.mp4), so recordings never collide or overwrite each other.
  • get_codec_and_quality() maps the quality dropdown and format dropdown to a fourcc — a 4-character code (like mp4v or XVID) that tells OpenCV/FFmpeg’s underlying codec which compression algorithm to use. This is where “Quality” and “Format” settings actually translate into encoder behavior. Worth noting: the returned “quality_factor” values (15/20/25/30) are computed but not currently passed into the VideoWriter call — the codec choice is what really drives output differences today, so quality is mostly determined by format/codec rather than a tunable bitrate.
  • If video_writer.isOpened() returns False (e.g., an unsupported codec on the current OS), it raises an exception that’s caught and shown via messagebox.showerror.

Once initialized, start_recording() launches record_loop() on a daemon thread and calls update_timer(), which recursively reschedules itself with self.root.after(1000, self.update_timer) — Tkinter’s idiomatic way to run periodic UI updates without blocking the event loop or needing a separate thread for something UI-bound.

Stopping cleanly

stop_recording() flips is_recording to False, joins the recording thread with a timeout (self.record_thread.join(timeout=2)) so the app doesn’t hang waiting forever, then calls self.video_writer.release() to flush and finalize the video file — an essential step, since video containers typically need proper finalization to be playable.

Why threading matters here

Tkinter’s mainloop() runs on the main thread and is single-threaded by design: any long-running operation performed directly inside a button callback would freeze the whole UI (no repainting, no responding to clicks) until it finishes. Screen capture and video encoding are exactly this kind of blocking work if done frame-by-frame in a loop.

The project sidesteps this by running preview_loop() and record_loop() as daemon threads (daemon=True), which:

  • Let the UI remain interactive (drag the overlay, change settings, click Stop) while capture is happening.
  • Automatically get killed when the main program exits, so they don’t prevent the app from closing.

The trade-off is that the background threads never touch Tkinter widgets directly except through the after()-scheduled update_timer, which is the safer pattern — mutating Tk widgets from a non-main thread is generally unsafe in Tkinter.

Shutdown handling

on_closing() is bound to the window’s close event (WM_DELETE_WINDOW protocol) and makes sure recording and preview loops are stopped, and any writer resources released, before the root window is destroyed — avoiding orphaned threads or unflushed video files if the user just closes the window mid-recording.

An unused piece: PreviewWindow

The file also defines a small PreviewWindow class that creates its own semi-transparent overlay with an instructional label. It’s never instantiated anywhere in __main__ or ScreenRecorder — functionally, ScreenRecorder.open_preview_window() already does this job inline. It reads like an earlier iteration of the area-selection UI that was superseded but left in the file, which is a useful reminder when reading someone else’s codebase: not every class present is necessarily wired into the live code path.

Taking it further

The README’s roadmap lists natural next steps — audio capture, multi-monitor selection, hotkeys, pause/resume — and the current architecture makes several of them approachable:

  • Pause/resume would mainly mean adding an is_paused flag that record_loop() checks before capturing a frame, without touching the writer.
  • Hotkeys could hook in via a library like keyboard or pynput, calling the same toggle_recording()/stop_recording() methods already exposed.
  • Audio is the biggest structural change, since cv2.VideoWriter only handles video — audio would need a separate capture stream (e.g., via sounddevice or pyaudio) muxed together with the video afterward, typically with ffmpeg.

Wrapping up

What makes this repository a good learning example isn’t novelty — screen recorders are a well-trodden idea — it’s that the whole pipeline, from “user drags a window” to “bytes land in an MP4,” is visible in one readable file: Tkinter for windowing and event binding, PyAutoGUI for the raw pixel grab, NumPy/OpenCV for the color-space conversion and encoding, and plain threading with polled boolean flags to keep it all off the UI thread. If you want to understand how a desktop capture tool is put together without wading through a large codebase, app.py is a compact, complete tour of the problem.

Repository: github.com/azario0/advanced-screen-recorder License: MIT Author: azario0

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.