Skip to content
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Chronolapse Multi-Window Capture Architecture

Copy link
Copy Markdown
Owner

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.


## 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.
198 changes: 192 additions & 6 deletions chronolapse.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
import threading

from PIL import Image, ImageDraw, ImageFont
import mss
import mss.tools

logging.basicConfig(level=logging.ERROR)

Expand All @@ -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):
Expand Down Expand Up @@ -191,6 +214,8 @@ def loadConfiguration(self):
'screenshot_subsection_width': '800',
'screenshot_subsection_height': '600',

'target_windows': 'Jules, GitHub Desktop, RPG Maker',

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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',
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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'):
Expand All @@ -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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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')
Expand Down Expand Up @@ -1748,6 +1895,42 @@ def subsectionchecked(self, event=None):
def exitMenuClicked(self, event): # wxGlade: chronoFrame.<event_handler>
self.Close()

def onRefreshWindowsPressed(self, event):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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):
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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
Expand Down
27 changes: 26 additions & 1 deletion chronolapsegui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -462,6 +479,14 @@ def createAudioPressed(self, event): # wxGlade: chronoFrame.<event_handler>
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):
Expand Down
Binary file added image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions test_enum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import sys

Copy link
Copy Markdown
Owner

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

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()