-
Notifications
You must be signed in to change notification settings - Fork 29
Feature/multi window capture 6354641966890463151 #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
ae2897b
556cbed
2b6cccd
cacb637
f05b785
34bb8fe
465ed2b
74b5d1d
54b974c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Chronolapse Multi-Window Capture Architecture | ||
|
|
||
| ## Overview | ||
| This document outlines the architecture and roadmap for the OBS-style multi-window targeting system in this Chronolapse fork. By default, Chronolapse only recorded the entire screen. We are expanding its core functionality to capture specific, individual software windows simultaneously based on their window name or handle, completely ignoring the desktop background. | ||
|
|
||
| This fork is strictly targeting Windows. | ||
|
|
||
| ## Target Applications | ||
| Our initial target applications for multi-window capture include: | ||
| - Jules | ||
| - GitHub Desktop | ||
| - RPG Maker | ||
|
|
||
| ## Roadmap | ||
|
|
||
| ### Phase 1: Core Logic via Configuration (Complete) | ||
| - **Objective:** Implement core multi-window capture logic driven strictly by the `chronolapse.config` file. | ||
| - **Technology Stack:** Python, `pywin32` (provides `win32gui`), and `mss`. | ||
| - **Dependencies:** `pip install pywin32 mss` | ||
| - **Functionality:** | ||
| - Read a user-defined list of target window names via the `target_windows` parameter in the configuration file. | ||
| - Fetch exact coordinates and bounding boxes of specific open windows dynamically. *Implemented `win32gui.GetWindowRect()` to isolate captures to specific window coordinates.* | ||
| - Iterate through the target list in the core capture loop. | ||
| - Save captures as parallel image sequences with the window name appended to the filename (e.g., `prefix_WindowName_0001.jpg`). | ||
| - **UI:** No changes to the GUI in this phase. | ||
|
|
||
| ### Phase 2: GUI Integration (Active) | ||
| - **Objective:** Integrate the new configuration options directly into the wxPython GUI (`chronolapsegui.py` / `chronolapsegui.wxg`). | ||
| - **Functionality:** | ||
| - Add UI elements (checkboxes, text fields, or lists) to allow users to add, edit, and remove target windows from within the application. | ||
| - Expose any relevant compositing or output formatting options visually. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,8 @@ | |
| import threading | ||
|
|
||
| from PIL import Image, ImageDraw, ImageFont | ||
| import mss | ||
| import mss.tools | ||
|
|
||
| logging.basicConfig(level=logging.ERROR) | ||
|
|
||
|
|
@@ -44,11 +46,32 @@ | |
| except: | ||
| pass | ||
|
|
||
| has_win32gui = False | ||
| try: | ||
| import win32gui | ||
| import win32con | ||
| import win32ui | ||
| has_win32gui = True | ||
| except Exception as e: | ||
| logging.error("Failed to import win32gui/win32ui: %s" % e) | ||
|
|
||
| import ctypes | ||
| try: | ||
| from ctypes.wintypes import RECT | ||
| except: | ||
| pass | ||
|
|
||
| from chronolapsegui import * | ||
|
|
||
| ON_WINDOWS = sys.platform.startswith('win') | ||
|
|
||
| if ON_WINDOWS: | ||
| try: | ||
| ctypes.windll.user32.SetProcessDPIAware() | ||
| except Exception as e: | ||
| logging.error("Failed to set DPI awareness: %s" % e) | ||
|
|
||
|
|
||
| ON_WINDOWS = sys.platform.startswith('win') | ||
|
|
||
|
|
||
| class ChronoFrame(chronoFrame): | ||
|
|
@@ -191,6 +214,8 @@ def loadConfiguration(self): | |
| 'screenshot_subsection_width': '800', | ||
| 'screenshot_subsection_height': '600', | ||
|
|
||
| 'target_windows': 'Jules, GitHub Desktop, RPG Maker', | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this shouldn't be in the default config |
||
|
|
||
| 'use_webcam': False, | ||
| 'webcam_timestamp': True, | ||
| 'webcam_timestamp_format': '%Y-%m-%d %H:%M:%S', | ||
|
|
@@ -250,6 +275,10 @@ def loadConfiguration(self): | |
| self._bindUI(self.frequencytext, 'frequency') | ||
| self._bindUI(self.screenshotcheck, 'use_screenshot') | ||
| self._bindUI(self.webcamcheck, 'use_webcam') | ||
|
|
||
| # Populate the window list on load | ||
| if ON_WINDOWS: | ||
| self.onRefreshWindowsPressed(None) | ||
| self._bindUI(self.ignoreidlecheck, 'skip_if_idle') | ||
|
|
||
| self._bindUI(self.pipmainimagefoldertext, 'pip_main_folder') | ||
|
|
@@ -529,7 +558,17 @@ def capture(self, force=False): | |
| # if screenshots | ||
| if self.getConfig('use_screenshot'): | ||
| # take screenshot | ||
| self.saveScreenshot(filename) | ||
| target_windows_str = self.getConfig('target_windows') | ||
| target_windows = [w.strip() for w in target_windows_str.split(',') if w.strip()] | ||
|
|
||
| if ON_WINDOWS and target_windows: | ||
| try: | ||
| self.saveTargetWindows(filename, target_windows) | ||
| except Exception as e: | ||
| logging.error("Failed to capture target windows: %s" % e) | ||
| self.saveScreenshot(filename) | ||
| else: | ||
| self.saveScreenshot(filename) | ||
|
|
||
| # if webcam | ||
| if self.getConfig('use_webcam'): | ||
|
|
@@ -539,6 +578,114 @@ def capture(self, force=False): | |
|
|
||
| return filename | ||
|
|
||
| def saveTargetWindows(self, filename, target_windows): | ||
| if not has_win32gui: | ||
| return self.saveScreenshot(filename) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is a silent fallback to behavior the user isnt actually expecting which seems confusing and frustrating |
||
|
|
||
| timestamp = self.getConfig('screenshot_timestamp') | ||
| folder = self.getConfig('screenshot_save_folder') | ||
| prefix = self.getConfig('screenshot_prefix') | ||
| file_format = self.getConfig('screenshot_format') | ||
|
|
||
| def callback(hwnd, windows): | ||
| if win32gui.IsWindowVisible(hwnd): | ||
| title = win32gui.GetWindowText(hwnd) | ||
| if title: | ||
| windows.append((hwnd, title)) | ||
| return True | ||
|
|
||
| all_windows = [] | ||
| win32gui.EnumWindows(callback, all_windows) | ||
|
|
||
| for target_name in target_windows: | ||
| for hwnd, title in all_windows: | ||
| if target_name.lower() in title.lower(): | ||
| try: | ||
| # Try to get the window rect using DwmGetWindowAttribute to ignore drop shadows | ||
| try: | ||
| rect = RECT() | ||
| DWMWA_EXTENDED_FRAME_BOUNDS = 9 | ||
| ctypes.windll.dwmapi.DwmGetWindowAttribute(hwnd, ctypes.wintypes.DWORD(DWMWA_EXTENDED_FRAME_BOUNDS), ctypes.byref(rect), ctypes.sizeof(rect)) | ||
| left, top, right, bottom = rect.left, rect.top, rect.right, rect.bottom | ||
| except Exception as e: | ||
| # Fallback if DWM fails | ||
| rect = win32gui.GetWindowRect(hwnd) | ||
| left, top, right, bottom = rect | ||
|
|
||
| width = right - left | ||
| height = bottom - top | ||
|
|
||
| # Ignore invisible/minimized windows properly | ||
| if width <= 0 or height <= 0: | ||
| continue | ||
|
|
||
| # Use PrintWindow to capture the window directly even if it's in the background | ||
| hwndDC = win32gui.GetWindowDC(hwnd) | ||
| mfcDC = win32ui.CreateDCFromHandle(hwndDC) | ||
| saveDC = mfcDC.CreateCompatibleDC() | ||
|
|
||
| saveBitMap = win32ui.CreateBitmap() | ||
| saveBitMap.CreateCompatibleBitmap(mfcDC, width, height) | ||
|
|
||
| saveDC.SelectObject(saveBitMap) | ||
|
|
||
| # PW_RENDERFULLCONTENT = 2 to capture hardware accelerated windows correctly on Win 8.1+ | ||
| # Using 3 (PW_CLIENTONLY | PW_RENDERFULLCONTENT) captures client, using 2 captures full frame. | ||
| result = ctypes.windll.user32.PrintWindow(hwnd, saveDC.GetSafeHdc(), 2) | ||
|
|
||
| bmpinfo = saveBitMap.GetInfo() | ||
| bmpstr = saveBitMap.GetBitmapBits(True) | ||
|
|
||
| img = Image.frombuffer( | ||
| 'RGB', | ||
| (bmpinfo['bmWidth'], bmpinfo['bmHeight']), | ||
| bmpstr, 'raw', 'BGRX', 0, 1) | ||
|
|
||
| # Clean up DC objects to prevent GDI leaks | ||
| win32gui.DeleteObject(saveBitMap.GetHandle()) | ||
| saveDC.DeleteDC() | ||
| mfcDC.DeleteDC() | ||
| win32gui.ReleaseDC(hwnd, hwndDC) | ||
|
|
||
| if result != 1: | ||
| logging.error("PrintWindow failed for window %s" % title) | ||
| continue | ||
|
|
||
| if timestamp: | ||
| stamp = time.strftime(self.getConfig('screenshot_timestamp_format')) | ||
| if self.countdown < 1: | ||
| now = time.time() | ||
| micro = str(now - math.floor(now))[0:4] | ||
| stamp = stamp + micro | ||
|
|
||
| draw = ImageDraw.Draw(img) | ||
| font = ImageFont.load_default() | ||
| draw.text((20, img.height - 30), stamp, fill=(255, 255, 255), font=font) | ||
|
|
||
| # Save with window name appended | ||
| safe_title = "".join([c for c in title if c.isalpha() or c.isdigit() or c==' ']).rstrip() | ||
| if not safe_title: | ||
| safe_title = target_name.replace(" ", "") | ||
|
|
||
| safe_name = safe_title.replace(" ", "_") | ||
|
|
||
| # Construct new filename | ||
| name, ext = os.path.splitext(filename) | ||
| new_filename = f"{name}_{safe_name}{ext}" | ||
|
|
||
| if file_format == 'gif': | ||
| fileName = os.path.join(folder, "%s%s.gif" % (prefix, new_filename)) | ||
| img.save(fileName, "GIF") | ||
| elif file_format == 'png': | ||
| fileName = os.path.join(folder, "%s%s.png" % (prefix, new_filename)) | ||
| img.save(fileName, "PNG") | ||
| else: | ||
| fileName = os.path.join(folder, "%s%s.jpg" % (prefix, new_filename)) | ||
| img.save(fileName, "JPEG") | ||
|
|
||
| except Exception as e: | ||
| logging.error("Could not capture window %s: %s" % (title, e)) | ||
|
|
||
| def saveScreenshot(self, filename): | ||
| timestamp = self.getConfig('screenshot_timestamp') | ||
| folder = self.getConfig('screenshot_save_folder') | ||
|
|
@@ -1748,6 +1895,42 @@ def subsectionchecked(self, event=None): | |
| def exitMenuClicked(self, event): # wxGlade: chronoFrame.<event_handler> | ||
| self.Close() | ||
|
|
||
| def onRefreshWindowsPressed(self, event): | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looks like this fails silently on unsupported platforms or if something is configured incorrectly. |
||
| if not ON_WINDOWS or not has_win32gui: | ||
| return | ||
|
|
||
| all_windows = [] | ||
| def callback(hwnd, windows): | ||
| if win32gui.IsWindowVisible(hwnd): | ||
| title = win32gui.GetWindowText(hwnd) | ||
| if title and title.strip(): | ||
| windows.append(title) | ||
| return True | ||
|
|
||
| win32gui.EnumWindows(callback, all_windows) | ||
|
|
||
| # Sort and remove duplicates | ||
| all_windows = sorted(list(set(all_windows))) | ||
|
|
||
| # Get currently checked items | ||
| target_str = self.getConfig('target_windows') | ||
| current_targets = [w.strip() for w in target_str.split(',') if w.strip()] | ||
|
|
||
| # Update listbox | ||
| self.multiwindow_listbox.Clear() | ||
| if all_windows: | ||
| self.multiwindow_listbox.AppendItems(all_windows) | ||
|
|
||
| # Check items that are in config | ||
| for i, title in enumerate(all_windows): | ||
| if any(target.lower() in title.lower() for target in current_targets): | ||
| self.multiwindow_listbox.Check(i, True) | ||
|
|
||
| def onMultiWindowCheck(self, event): | ||
| checked_items = self.multiwindow_listbox.GetCheckedStrings() | ||
| target_str = ", ".join(checked_items) | ||
| self.updateConfig({'target_windows': target_str}, from_ui=True) | ||
|
|
||
|
|
||
| class ScreenshotConfigDialog(screenshotConfigDialog): | ||
| def __init__(self, *args, **kwargs): | ||
|
|
@@ -1943,10 +2126,13 @@ def set_window_visible_on(self, event): | |
| self.set_icon_action_text(False) | ||
|
|
||
| def set_icon_action_text(self, minimized=True): | ||
| if minimized: | ||
| self.menu.FindItemById(self.wx_id).SetText("Restore") | ||
| else: | ||
| self.menu.FindItemById(self.wx_id).SetText("Minimize") | ||
| if hasattr(self, 'menu') and self.menu: | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does menu no longer exist? why is this change needed? |
||
| item = self.menu.FindItemById(self.wx_id) | ||
| if item: | ||
| if minimized: | ||
| item.SetText("Restore") | ||
| else: | ||
| item.SetText("Minimize") | ||
|
|
||
| def iconized(self, event): | ||
| # bound on non-windows only | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import sys | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I dont think this file should become part of the codebase |
||
| has_win32gui = False | ||
| try: | ||
| import win32gui | ||
| import win32con | ||
| has_win32gui = True | ||
| except: | ||
| pass | ||
|
|
||
| def run(): | ||
| if not has_win32gui: | ||
| print("no win32gui") | ||
| return | ||
| all_windows = [] | ||
| def callback(hwnd, windows): | ||
| if win32gui.IsWindowVisible(hwnd): | ||
| title = win32gui.GetWindowText(hwnd) | ||
| if title and title.strip(): | ||
| windows.append(title) | ||
| return True | ||
|
|
||
| win32gui.EnumWindows(callback, all_windows) | ||
| print(all_windows) | ||
| run() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I dont think this file should become part of the codebase
ALSO this is a plan for a very specific feature not general advice for agents.