diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..58ec909 --- /dev/null +++ b/AGENTS.md @@ -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. \ No newline at end of file diff --git a/chronolapse.py b/chronolapse.py index 49d7afc..4df8bc5 100644 --- a/chronolapse.py +++ b/chronolapse.py @@ -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', + '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) + + 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. self.Close() + def onRefreshWindowsPressed(self, event): + 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: + 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 diff --git a/chronolapsegui.py b/chronolapsegui.py index 3a60269..9f0a19c 100644 --- a/chronolapsegui.py +++ b/chronolapsegui.py @@ -89,6 +89,12 @@ def __init__(self, *args, **kwds): self.ignoreidlecheck = wx.CheckBox(self.notebook_1_capturepane, wx.ID_ANY, _("Skip Capture if Idle")) self.startbutton = wx.Button(self.notebook_1_capturepane, wx.ID_ANY, _("Start Capture")) self.forcecapturebutton = wx.Button(self.notebook_1_capturepane, wx.ID_ANY, _("Force Capture")) + + # Multi-Window Capture UI elements + self.multiwindow_label = wx.StaticText(self.notebook_1_capturepane, wx.ID_ANY, _("Multi-Window Capture Targets:")) + self.multiwindow_listbox = wx.CheckListBox(self.notebook_1_capturepane, wx.ID_ANY, choices=[]) + self.multiwindow_refreshbutton = wx.Button(self.notebook_1_capturepane, wx.ID_ANY, _("Refresh Open Windows")) + self.progresspanel = ProgressPanel(self.notebook_1_capturepane, wx.ID_ANY) self.notebook_1_pippane = wx.Panel(self.notebook_1, wx.ID_ANY) self.label_1 = wx.StaticText(self.notebook_1_pippane, wx.ID_ANY, _("Picture in Picture:")) @@ -146,6 +152,8 @@ def __init__(self, *args, **kwds): self.Bind(wx.EVT_BUTTON, self.webcamConfigurePressed, self.configurewebcambutton) self.Bind(wx.EVT_BUTTON, self.startCapturePressed, self.startbutton) self.Bind(wx.EVT_BUTTON, self.forceCapturePressed, self.forcecapturebutton) + self.Bind(wx.EVT_BUTTON, self.onRefreshWindowsPressed, self.multiwindow_refreshbutton) + self.Bind(wx.EVT_CHECKLISTBOX, self.onMultiWindowCheck, self.multiwindow_listbox) self.Bind(wx.EVT_BUTTON, self.pipMainImageBrowsePressed, self.pipmainimagefolderbrowse) self.Bind(wx.EVT_BUTTON, self.pipPipImageBrowsePressed, self.pippipimagefolderbrowse) self.Bind(wx.EVT_BUTTON, self.pipOutputBrowsePressed, self.pipoutputimagefolderbrowse) @@ -249,7 +257,7 @@ def __do_layout(self): grid_sizer_25 = wx.FlexGridSizer(1, 2, 0, 0) grid_sizer_22 = wx.FlexGridSizer(1, 2, 0, 0) grid_sizer_21 = wx.FlexGridSizer(1, 2, 0, 0) - grid_sizer_1 = wx.FlexGridSizer(4, 1, 0, 0) + grid_sizer_1 = wx.FlexGridSizer(5, 1, 0, 0) grid_sizer_26 = wx.FlexGridSizer(1, 2, 0, 0) grid_sizer_15 = wx.FlexGridSizer(4, 2, 0, 0) grid_sizer_20 = wx.FlexGridSizer(1, 2, 0, 0) @@ -279,6 +287,15 @@ def __do_layout(self): grid_sizer_26.AddGrowableCol(0) grid_sizer_26.AddGrowableCol(1) grid_sizer_1.Add(grid_sizer_26, 1, wx.EXPAND, 0) + + # Add Multi-Window Capture elements to the Capture Tab layout + multiwindow_sizer = wx.BoxSizer(wx.VERTICAL) + multiwindow_sizer.Add(self.multiwindow_label, 0, wx.BOTTOM, 5) + multiwindow_sizer.Add(self.multiwindow_listbox, 1, wx.EXPAND | wx.BOTTOM, 5) + multiwindow_sizer.Add(self.multiwindow_refreshbutton, 0, wx.ALIGN_RIGHT) + + grid_sizer_1.Add(multiwindow_sizer, 1, wx.ALL | wx.EXPAND, 10) + grid_sizer_1.Add(self.progresspanel, 1, wx.EXPAND, 0) self.notebook_1_capturepane.SetSizer(grid_sizer_1) grid_sizer_1.AddGrowableRow(2) @@ -462,6 +479,14 @@ def createAudioPressed(self, event): # wxGlade: chronoFrame. print("Event handler 'createAudioPressed' not implemented!") event.Skip() + def onRefreshWindowsPressed(self, event): + print("Event handler 'onRefreshWindowsPressed' not implemented!") + event.Skip() + + def onMultiWindowCheck(self, event): + print("Event handler 'onMultiWindowCheck' not implemented!") + event.Skip() + # end of class chronoFrame class screenshotConfigDialog(wx.Dialog): diff --git a/image.png b/image.png new file mode 100644 index 0000000..57be4d3 Binary files /dev/null and b/image.png differ diff --git a/test_enum.py b/test_enum.py new file mode 100644 index 0000000..ffd41ef --- /dev/null +++ b/test_enum.py @@ -0,0 +1,24 @@ +import sys +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()