diff --git a/pyproject.toml b/pyproject.toml index bdfecd610..5f63d9f06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ scitex = "scitex.__main__:main" scitex-pkg = "scitex.cli.pkg:pkg" # MCP Server entry points (individual modules) # scitex-audio entry point now in standalone scitex-audio package -scitex-capture = "scitex.capture.mcp_server:main" +scitex-capture = "scitex_capture.mcp_server:main" scitex-diagram = "scitex.diagram.mcp_server:main" # scitex-plt entry point now in standalone scitex-plt package scitex-scholar = "scitex.scholar.mcp_server:main" @@ -206,12 +206,8 @@ browser = [ # Capture Module - Screenshot capture # Use: pip install scitex[capture] -capture = [ - "mss", - "Pillow", - "playwright>=1.40.0", - "mcp", -] +# Real implementation lives in the standalone scitex-capture package. +capture = ["scitex-capture[mcp,playwright]>=0.1.0"] # CLI Module - Command line interface # Use: pip install scitex[cli] diff --git a/src/scitex/capture/README.md b/src/scitex/capture/README.md deleted file mode 100644 index 94ce1b25b..000000000 --- a/src/scitex/capture/README.md +++ /dev/null @@ -1,284 +0,0 @@ -# scitex.capture - -Screen capture and monitoring utilities optimized for WSL and Windows. - -## Overview - -`scitex.capture` provides comprehensive screen capture capabilities with both: -1. **Direct API**: Python functions for direct screen capture -2. **MCP Server**: Model Context Protocol server for AI agent integration - -## Quick Start - -```python -from scitex import capture - -# Take a screenshot -capture.snap("debug message") - -# Multi-monitor -capture.snap(capture_all=True) - -# Continuous monitoring -capture.start() -# ... do work ... -capture.stop() - -# Create GIF from session -capture.gif() -``` - -## Core API - -### capture.snap() / capture.take() -Take a single screenshot. - -```python -from scitex import capture - -# Basic screenshot -capture.snap("checkpoint_1") - -# High quality -capture.snap("important_state", quality=95) - -# Specific monitor -capture.snap("monitor_2", monitor=1) - -# All monitors -capture.snap("all_screens", capture_all=True) -``` - -### capture.start() / capture.stop() -Continuous monitoring. - -```python -from scitex import capture - -# Start monitoring (every 1 second by default) -capture.start() - -# Do your work -process_data() - -# Stop monitoring -capture.stop() -``` - -### capture.gif() -Create animated GIF from latest monitoring session. - -```python -from scitex import capture - -# After monitoring session -capture.gif() # Creates GIF from latest session - -# Custom options -from scitex.capture import create_gif_from_latest_session -create_gif_from_latest_session(duration=0.5, optimize=True) -``` - -### capture.get_info() -Get comprehensive system display information. - -```python -info = capture.get_info() -print(info['Monitors']) # Monitor details -print(info['Windows']) # Window information -print(info['VirtualDesktops']) # Virtual desktop info -``` - -### capture.capture_window() -Capture specific window by handle. - -```python -# Get window information -info = capture.get_info() -windows = info['Windows']['Details'] - -# Capture first window -if windows: - handle = windows[0]['Handle'] - path = capture.capture_window(handle) -``` - -## MCP Server Integration - -The module includes an MCP server (`mcp_server.py`) that exposes scitex.capture functionality to AI agents via the Model Context Protocol. - -### MCP Functions Available: -- `mcp__cammy__capture_screenshot` - Single screenshots -- `mcp__cammy__start_monitoring` - Start continuous capture -- `mcp__cammy__stop_monitoring` - Stop continuous capture -- `mcp__cammy__get_monitoring_status` - Get status -- `mcp__cammy__create_gif` - Create GIF from sessions -- `mcp__cammy__list_sessions` - List available sessions -- `mcp__cammy__get_info` - Get system information -- `mcp__cammy__list_windows` - List all windows -- `mcp__cammy__capture_window` - Capture specific window -- And more... - -## Features - -- **WSL Support**: Seamlessly captures Windows host screens from WSL -- **Multi-Monitor**: Support for multiple monitors and all-monitor capture -- **JPEG Compression**: Configurable quality for smaller file sizes -- **Continuous Monitoring**: Background thread for automatic screenshots -- **GIF Creation**: Generate timeline GIFs from monitoring sessions -- **Window Capture**: Capture specific windows by handle -- **Thread-Safe**: Safe for concurrent operations -- **Human-Readable**: Timestamps and organized output - -## Advanced Usage - -### Custom Output Directory -```python -from scitex.capture import utils - -utils.capture( - message="custom_location", - output_dir="/my/custom/path", - quality=90 -) -``` - -### Monitoring with Callbacks -```python -from scitex.capture.capture import CaptureManager - -manager = CaptureManager() - -def on_capture(filepath): - print(f"Captured: {filepath}") - -def on_error(error): - print(f"Error: {error}") - -manager.start_capture( - interval=2.0, - quality=60, - on_capture=on_capture, - on_error=on_error -) -``` - -### GIF from Custom Files -```python -from scitex.capture.gif import create_gif_from_files - -gif_path = create_gif_from_files( - image_paths=["img1.png", "img2.png", "img3.png"], - output_path="/tmp/demo.gif", - duration=0.5 -) -``` - -### GIF from Pattern -```python -from scitex.capture.gif import create_gif_from_pattern - -gif_path = create_gif_from_pattern( - pattern="/tmp/screenshots/*.jpg", - duration=0.3 -) -``` - -## Configuration - -Default cache location: `~/.cache/scitex_capture` - -Quality settings: -- Monitoring: 60 (balance of quality and size) -- Single screenshots: 85 (higher quality) -- High-quality debug: 95 - -## CLI Usage - -The module can also be used from command line: - -```bash -# Take screenshot with message -python -m scitex.capture "my_message" - -# Take screenshot (no message) -python -m scitex.capture - -# Capture all monitors -python -m scitex.capture --all "all_screens" - -# Capture specific app -python -m scitex.capture --app chrome "browser_state" - -# Start monitoring -python -m scitex.capture --start - -# Stop monitoring -python -m scitex.capture --stop - -# Create GIF from latest session -python -m scitex.capture --gif - -# Start MCP server -python -m scitex.capture --mcp - -# List windows -python -m scitex.capture --list - -# Show display info -python -m scitex.capture --info -``` - -## API Reference - -### Main Functions - -- `capture.snap(message, **kwargs)` - Take screenshot (primary API) -- `capture.take(message, **kwargs)` - Take screenshot (alternative) -- `capture.start()` - Start monitoring -- `capture.stop()` - Stop monitoring -- `capture.gif()` - Create GIF from latest session -- `capture.get_info()` - Get display/window information -- `capture.list_windows()` - List all windows (alias for get_info) -- `capture.capture_window(handle)` - Capture specific window - -### GIF Functions - -- `create_gif_from_session(session_id, **kwargs)` - GIF from session ID -- `create_gif_from_latest_session(**kwargs)` - GIF from latest session -- `create_gif_from_files(image_paths, **kwargs)` - GIF from file list -- `create_gif_from_pattern(pattern, **kwargs)` - GIF from glob pattern - -### Utils - -- `utils.capture(...)` - Low-level capture function -- `utils.start_monitor(...)` - Low-level monitoring start -- `utils.stop_monitor()` - Low-level monitoring stop - -## Examples - -See `examples/capture_examples/` for comprehensive usage examples: -- `basic_usage.py` - Core functionality demos -- `debugging_workflow.py` - Practical debugging scenarios - -## Technical Details - -### PowerShell Scripts -Located in `powershell/` directory: -- `capture_single_monitor.ps1` - Single monitor capture with DPI awareness -- `capture_all_monitors.ps1` - All monitors combined -- `capture_window_by_handle.ps1` - Window-specific capture -- `detect_monitors_and_desktops.ps1` - System information enumeration - -### Fallback Mechanisms -1. PowerShell scripts (preferred for WSL) -2. Inline PowerShell commands -3. Native tools (mss, scrot) - -## See Also - -- Configuration: `config/capture.yaml` -- Examples: `examples/capture_examples/` -- MCP Server: `src/scitex/capture/mcp_server.py` - -EOF diff --git a/src/scitex/capture/TODO.md b/src/scitex/capture/TODO.md deleted file mode 100644 index 7e9a1b919..000000000 --- a/src/scitex/capture/TODO.md +++ /dev/null @@ -1,40 +0,0 @@ -# scitex.capture TODO - -## Completed -- [x] Integrate cammy into scitex-code as scitex.capture -- [x] MCP server integration (mcp_server.py) -- [x] Documentation updated -- [x] Module accessible via `from scitex import capture` -- [x] Python API: `python -m scitex.capture ` - -## In Progress -- [ ] Ensure `python -m scitex.capture --mcp` works for MCP server mode - -## Recent Changes (2025-10-19) -- [x] Updated cache directory from `~/.cache/cammy` to `~/.scitex/capture` -- [x] Added automatic migration from legacy location -- [x] Consistent with other scitex modules (scholar, writer, etc.) - -## Future Enhancements -- [ ] Add tests for MCP integration -- [ ] Add examples using MCP client -- [ ] Integrate with scitex.logging for consistent logging -- [ ] Add configuration file support integration with config/capture.yaml -- [ ] Add option to clean up legacy ~/.cache/cammy after successful migration - -## Notes -The module now supports both: -1. Direct Python API: `capture.snap()`, `capture.start()`, etc. -2. MCP Server: Exposes functionality to AI agents via Model Context Protocol - -Module structure: -- `__init__.py` - Public API exports -- `capture.py` - Core capture functionality -- `mcp_server.py` - MCP server implementation -- `utils.py` - Utility functions -- `gif.py` - GIF creation -- `cli.py` - CLI interface -- `__main__.py` - Module entry point -- `powershell/` - Windows PowerShell scripts - -EOF diff --git a/src/scitex/capture/__init__.py b/src/scitex/capture/__init__.py index d0df8eb1e..fee802766 100755 --- a/src/scitex/capture/__init__.py +++ b/src/scitex/capture/__init__.py @@ -1,108 +1,20 @@ -#!/usr/bin/env python3 -""" -scitex.capture - AI's Camera -A lightweight, intuitive screen capture library optimized for WSL and Windows. - -Features: -- Windows host screen capture from WSL -- Multi-monitor support -- JPEG compression for smaller file sizes -- Continuous monitoring with configurable intervals -- Human-readable timestamps -- Thread-safe operation - -Usage: - from scitex import capture +"""SciTeX capture — thin compatibility shim for scitex-capture. - # Single screenshot - capture.snap("debug message") +Aliases ``scitex.capture`` to the standalone ``scitex_capture`` package via +``sys.modules``. ``scitex.capture is scitex_capture``. - # Multi-monitor - capture.snap(capture_all=True) - - # Continuous monitoring - capture.start() - # ... do work ... - capture.stop() +Install: ``pip install scitex[capture]`` (or ``pip install scitex-capture``). +See: https://github.com/ywatanabe1989/scitex-capture """ -from .capture import CaptureManager as _CaptureManager # Internal class -from .gif import ( - create_gif_from_files, - create_gif_from_latest_session, - create_gif_from_pattern, - create_gif_from_session, -) -from .session import session -from .utils import capture, start_monitor, stop_monitor - -# Global manager for monitor enumeration -_manager = _CaptureManager() - - -def get_info(): - """Get comprehensive display info (monitors, windows, virtual desktops).""" - return _manager.get_info() - - -# Simpler, clearer aliases -get_info = get_info # Primary: simple and clear -list_windows = get_info # Alternative: focus on windows -get_display_info = get_info # Legacy - - -def capture_window(window_handle: int, output_path: str = None): - """ - Capture a specific window by its handle. - - Args: - window_handle: Window handle from get_info()['Windows']['Details'] - output_path: Optional path to save screenshot - - Returns: - Path to saved screenshot - - Examples: - >>> from scitex import capture - >>> info = capture.get_info() - >>> windows = info['Windows']['Details'] - >>> if windows: - >>> handle = windows[0]['Handle'] - >>> path = capture.capture_window(handle) - """ - return _manager.capture_window(window_handle, output_path) - - -# Convenience aliases - these are the main public API -snap = capture # Primary: natural camera action -take = capture # Alternative: "take a picture" -cpt = capture # Legacy: backwards compatibility -start = start_monitor -stop = stop_monitor - -# GIF creation aliases -gif = create_gif_from_latest_session # Primary: simple -make_gif = create_gif_from_latest_session # Alternative +import sys as _sys -__author__ = "Yusuke Watanabe" -__email__ = "Yusuke.Watanabe@scitex.ai" +try: + import scitex_capture as _real +except ImportError as _e: # pragma: no cover + raise ImportError( + "scitex.capture requires the 'scitex-capture' package. " + "Install with: pip install scitex[capture] (or: pip install scitex-capture)" + ) from _e -# Only expose the essential functions -__all__ = [ - "capture", - "snap", # Primary API - "take", # Alternative API - "cpt", # Legacy - "start", - "stop", - "session", - "get_info", # Primary: get all display info - "list_windows", # Alternative: focus on windows - "get_info", # Legacy - "get_display_info", # Legacy - "capture_window", - "create_gif_from_session", - "create_gif_from_files", - "create_gif_from_pattern", - "create_gif_from_latest_session", -] +_sys.modules[__name__] = _real diff --git a/src/scitex/capture/__main__.py b/src/scitex/capture/__main__.py deleted file mode 100755 index 91fd166da..000000000 --- a/src/scitex/capture/__main__.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Timestamp: "2025-10-18 09:55:55 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/__main__.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/__main__.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - -""" -Entry point for python -m scitex.capture -""" - -import sys - -from .cli import main - -if __name__ == "__main__": - sys.exit(main()) - -# EOF diff --git a/src/scitex/capture/_mcp/__init__.py b/src/scitex/capture/_mcp/__init__.py deleted file mode 100755 index 9dec79d3b..000000000 --- a/src/scitex/capture/_mcp/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: 2026-01-15 -# File: src/scitex/capture/_mcp/__init__.py -# ---------------------------------------- - -"""MCP integration for scitex-capture.""" - -from .handlers import * # noqa: F401, F403 -from .tool_schemas import get_tool_schemas # noqa: F401 - -# EOF diff --git a/src/scitex/capture/_mcp/handlers.py b/src/scitex/capture/_mcp/handlers.py deleted file mode 100755 index df8e4f801..000000000 --- a/src/scitex/capture/_mcp/handlers.py +++ /dev/null @@ -1,438 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: 2026-01-15 -# File: src/scitex/capture/_mcp/handlers.py -# ---------------------------------------- - -"""MCP tool handlers for scitex-capture.""" - -from __future__ import annotations - -import asyncio -import base64 -from datetime import datetime -from pathlib import Path - -__all__ = [ - "capture_screenshot_handler", - "start_monitoring_handler", - "stop_monitoring_handler", - "get_monitoring_status_handler", - "analyze_screenshot_handler", - "list_recent_screenshots_handler", - "clear_cache_handler", - "create_gif_handler", - "list_sessions_handler", - "get_info_handler", - "list_windows_handler", - "capture_window_handler", -] - - -def _get_capture_dir() -> Path: - """Get the capture output directory.""" - import os - - base_dir = Path(os.getenv("SCITEX_DIR", Path.home() / ".scitex")) - capture_dir = base_dir / "capture" - capture_dir.mkdir(parents=True, exist_ok=True) - return capture_dir - - -# Monitoring state (module-level singleton) -_monitoring_active = False -_monitoring_worker = None - - -async def capture_screenshot_handler( - message: str | None = None, - monitor_id: int = 0, - all: bool = False, - app: str | None = None, - url: str | None = None, - quality: int = 85, - return_base64: bool = False, -) -> dict: - """Capture screenshot with optional overlays.""" - try: - from scitex import capture - - loop = asyncio.get_event_loop() - - def do_capture(): - return capture.snap( - message=message, - quality=quality, - monitor_id=monitor_id, - all=all, - app=app, - url=url, - verbose=True, - ) - - path = await loop.run_in_executor(None, do_capture) - - if not path: - return {"success": False, "error": "Failed to capture screenshot"} - - category = "stderr" if "-stderr.jpg" in path else "stdout" - result = { - "success": True, - "path": path, - "category": category, - "message": f"Screenshot saved to {path}", - "timestamp": datetime.now().isoformat(), - } - - if return_base64 and path: - with open(path, "rb") as f: - result["base64"] = base64.b64encode(f.read()).decode() - - return result - except Exception as e: - return {"success": False, "error": str(e)} - - -async def start_monitoring_handler( - interval: float = 1.0, - monitor_id: int = 0, - capture_all: bool = False, - output_dir: str | None = None, - quality: int = 60, - verbose: bool = True, -) -> dict: - """Start continuous screenshot monitoring.""" - global _monitoring_active, _monitoring_worker - - if _monitoring_active: - return {"success": False, "message": "Monitoring already active"} - - try: - from scitex import capture - - loop = asyncio.get_event_loop() - - def start(): - return capture.start_monitor( - output_dir=output_dir or str(_get_capture_dir()), - interval=interval, - jpeg=True, - quality=quality, - verbose=verbose, - monitor_id=monitor_id, - capture_all=capture_all, - ) - - _monitoring_worker = await loop.run_in_executor(None, start) - _monitoring_active = True - - return { - "success": True, - "message": f"Started monitoring with {interval}s interval", - "interval": interval, - "monitor_id": monitor_id, - "capture_all": capture_all, - } - except Exception as e: - return {"success": False, "error": str(e)} - - -async def stop_monitoring_handler() -> dict: - """Stop continuous screenshot monitoring.""" - global _monitoring_active, _monitoring_worker - - if not _monitoring_active: - return {"success": False, "message": "Monitoring not active"} - - try: - from scitex import capture - - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, capture.stop) - - stats = {} - if _monitoring_worker: - stats = { - "screenshots_taken": getattr(_monitoring_worker, "screenshot_count", 0), - "session_id": getattr(_monitoring_worker, "session_id", None), - } - - _monitoring_active = False - _monitoring_worker = None - - return {"success": True, "message": "Monitoring stopped", **stats} - except Exception as e: - return {"success": False, "error": str(e)} - - -async def get_monitoring_status_handler() -> dict: - """Get current monitoring status and statistics.""" - global _monitoring_active, _monitoring_worker - - status = { - "success": True, - "active": _monitoring_active, - "cache_dir": str(_get_capture_dir()), - } - - if _monitoring_active and _monitoring_worker: - status.update( - { - "screenshots_taken": getattr(_monitoring_worker, "screenshot_count", 0), - "session_id": getattr(_monitoring_worker, "session_id", None), - } - ) - - cache_dir = _get_capture_dir() - if cache_dir.exists(): - jpg_files = list(cache_dir.glob("*.jpg")) - total_size = sum(f.stat().st_size for f in jpg_files) - status["cache_size_mb"] = round(total_size / (1024 * 1024), 2) - status["screenshot_count"] = len(jpg_files) - - return status - - -async def analyze_screenshot_handler(path: str) -> dict: - """Analyze screenshot for error indicators.""" - try: - from ..utils import _detect_category - - loop = asyncio.get_event_loop() - category = await loop.run_in_executor(None, _detect_category, path) - - path_obj = Path(path) - if not path_obj.exists(): - return {"success": False, "error": f"File not found: {path}"} - - return { - "success": True, - "path": path, - "category": category, - "is_error": category == "stderr", - "size_kb": round(path_obj.stat().st_size / 1024, 2), - } - except Exception as e: - return {"success": False, "error": str(e)} - - -async def list_recent_screenshots_handler( - limit: int = 10, - category: str = "all", -) -> dict: - """List recent screenshots from cache.""" - try: - cache_dir = _get_capture_dir() - if not cache_dir.exists(): - return {"success": True, "screenshots": [], "count": 0} - - screenshots = list(cache_dir.glob("*.jpg")) - - if category == "stdout": - screenshots = [s for s in screenshots if "-stdout.jpg" in s.name] - elif category == "stderr": - screenshots = [s for s in screenshots if "-stderr.jpg" in s.name] - - screenshots.sort(key=lambda p: p.stat().st_mtime, reverse=True) - screenshots = screenshots[:limit] - - result_list = [] - for s in screenshots: - cat = "stderr" if "-stderr.jpg" in s.name else "stdout" - result_list.append( - { - "filename": s.name, - "path": str(s), - "category": cat, - "size_kb": round(s.stat().st_size / 1024, 2), - } - ) - - return { - "success": True, - "screenshots": result_list, - "count": len(result_list), - } - except Exception as e: - return {"success": False, "error": str(e)} - - -async def clear_cache_handler( - max_size_gb: float = 1.0, - clear_all: bool = False, -) -> dict: - """Clear screenshot cache or manage cache size.""" - try: - cache_dir = _get_capture_dir() - if not cache_dir.exists(): - return {"success": True, "message": "Cache does not exist"} - - if clear_all: - removed = 0 - for s in cache_dir.glob("*.jpg"): - s.unlink() - removed += 1 - return {"success": True, "removed_count": removed} - else: - from ..utils import _manage_cache_size - - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, _manage_cache_size, cache_dir, max_size_gb) - return {"success": True, "max_size_gb": max_size_gb} - except Exception as e: - return {"success": False, "error": str(e)} - - -async def create_gif_handler( - session_id: str | None = None, - image_paths: list[str] | None = None, - pattern: str | None = None, - output_path: str | None = None, - duration: float = 0.5, - optimize: bool = True, - max_frames: int | None = None, -) -> dict: - """Create an animated GIF from screenshots.""" - try: - from ..gif import GifCreator - - creator = GifCreator() - loop = asyncio.get_event_loop() - capture_dir = str(_get_capture_dir()) - - if session_id: - if session_id == "latest": - result = await loop.run_in_executor( - None, - creator.create_gif_from_recent_session, - capture_dir, - duration, - optimize, - max_frames, - ) - else: - result = await loop.run_in_executor( - None, - creator.create_gif_from_session, - session_id, - output_path, - capture_dir, - duration, - optimize, - max_frames, - ) - elif image_paths: - if not output_path: - output_path = str( - _get_capture_dir() / f"gif_{datetime.now():%Y%m%d_%H%M%S}.gif" - ) - result = await loop.run_in_executor( - None, - creator.create_gif_from_files, - image_paths, - output_path, - duration, - optimize, - ) - elif pattern: - result = await loop.run_in_executor( - None, - creator.create_gif_from_pattern, - pattern, - output_path, - duration, - optimize, - max_frames, - ) - else: - return { - "success": False, - "error": "Specify session_id, image_paths, or pattern", - } - - if result: - return {"success": True, "path": result, "duration": duration} - return {"success": False, "error": "No images found to create GIF"} - except Exception as e: - return {"success": False, "error": str(e)} - - -async def list_sessions_handler(limit: int = 10) -> dict: - """List available monitoring sessions.""" - try: - from ..gif import GifCreator - - creator = GifCreator() - loop = asyncio.get_event_loop() - sessions = await loop.run_in_executor( - None, creator.get_recent_sessions, str(_get_capture_dir()) - ) - return { - "success": True, - "sessions": sessions[:limit], - "count": min(len(sessions), limit), - } - except Exception as e: - return {"success": False, "error": str(e)} - - -async def get_info_handler() -> dict: - """Get monitor and window information.""" - try: - from scitex import capture - - loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, capture.get_info) - return { - "success": True, - "monitors": info.get("Monitors", {}), - "windows": info.get("Windows", {}), - } - except Exception as e: - return {"success": False, "error": str(e)} - - -async def list_windows_handler() -> dict: - """List all visible windows.""" - try: - from scitex import capture - - loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, capture.get_info) - windows = info.get("Windows", {}).get("Details", []) - formatted = [ - { - "handle": w.get("Handle"), - "title": w.get("Title"), - "process": w.get("ProcessName"), - } - for w in windows - ] - return {"success": True, "windows": formatted, "count": len(formatted)} - except Exception as e: - return {"success": False, "error": str(e)} - - -async def capture_window_handler( - window_handle: int, - output_path: str | None = None, - quality: int = 85, -) -> dict: - """Capture a specific window by handle.""" - try: - from scitex import capture - - loop = asyncio.get_event_loop() - path = await loop.run_in_executor( - None, capture.capture_window, window_handle, output_path - ) - if path: - return {"success": True, "path": path, "window_handle": window_handle} - return { - "success": False, - "error": f"Failed to capture window {window_handle}", - } - except Exception as e: - return {"success": False, "error": str(e)} - - -# EOF diff --git a/src/scitex/capture/_mcp/tool_schemas.py b/src/scitex/capture/_mcp/tool_schemas.py deleted file mode 100755 index d15682d43..000000000 --- a/src/scitex/capture/_mcp/tool_schemas.py +++ /dev/null @@ -1,270 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: 2026-01-15 -# File: src/scitex/capture/_mcp/tool_schemas.py -# ---------------------------------------- - -"""Tool schemas for the scitex-capture MCP server.""" - -from __future__ import annotations - -import mcp.types as types - -__all__ = ["get_tool_schemas"] - - -def get_tool_schemas() -> list[types.Tool]: - """Return all tool schemas for the MCP server.""" - return [ - # Capture tools - types.Tool( - name="capture_screenshot", - description=( - "Capture screenshot - monitor, window, browser, or everything " - "including Windows screens from WSL" - ), - inputSchema={ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Optional message to include in filename", - }, - "monitor_id": { - "type": "integer", - "description": "Monitor number (0-based, default: 0 for primary monitor)", - "default": 0, - }, - "all": { - "type": "boolean", - "description": "Capture all monitors (shorthand)", - "default": False, - }, - "app": { - "type": "string", - "description": "App name to capture (e.g., 'chrome', 'code')", - }, - "url": { - "type": "string", - "description": "URL to capture (e.g., '127.0.0.1:8000' or 'http://localhost:3000')", - }, - "quality": { - "type": "integer", - "description": "JPEG quality (1-100, default: 85)", - "minimum": 1, - "maximum": 100, - "default": 85, - }, - "return_base64": { - "type": "boolean", - "description": "Return screenshot as base64 string", - "default": False, - }, - }, - }, - ), - types.Tool( - name="start_monitoring", - description="Start continuous screenshot monitoring at regular intervals", - inputSchema={ - "type": "object", - "properties": { - "interval": { - "type": "number", - "description": "Seconds between captures (default: 1.0)", - "minimum": 0.1, - "default": 1, - }, - "monitor_id": { - "type": "integer", - "description": "Monitor number (0-based, default: 0 for primary monitor)", - "default": 0, - }, - "capture_all": { - "type": "boolean", - "description": "Capture all monitors combined into single image (overrides monitor_id)", - "default": False, - }, - "output_dir": { - "type": "string", - "description": "Directory for screenshots (default: ~/.scitex/capture)", - }, - "quality": { - "type": "integer", - "description": "JPEG quality (1-100, default: 60)", - "minimum": 1, - "maximum": 100, - "default": 60, - }, - "verbose": { - "type": "boolean", - "description": "Show capture messages", - "default": True, - }, - }, - }, - ), - types.Tool( - name="stop_monitoring", - description="Stop continuous screenshot monitoring", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="get_monitoring_status", - description="Get current monitoring status and statistics", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="analyze_screenshot", - description="Analyze a screenshot for error indicators (stdout/stderr categorization)", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to screenshot to analyze", - }, - }, - "required": ["path"], - }, - ), - types.Tool( - name="list_recent_screenshots", - description="List recent screenshots from cache", - inputSchema={ - "type": "object", - "properties": { - "limit": { - "type": "integer", - "description": "Maximum number of screenshots to list", - "minimum": 1, - "maximum": 100, - "default": 10, - }, - "category": { - "type": "string", - "description": "Filter by category (stdout/stderr)", - "enum": ["stdout", "stderr", "all"], - "default": "all", - }, - }, - }, - ), - types.Tool( - name="clear_cache", - description="Clear screenshot cache or manage cache size", - inputSchema={ - "type": "object", - "properties": { - "max_size_gb": { - "type": "number", - "description": "Keep cache under this size in GB (removes oldest files)", - "minimum": 0.001, - "default": 1, - }, - "clear_all": { - "type": "boolean", - "description": "Remove all cached screenshots", - "default": False, - }, - }, - }, - ), - types.Tool( - name="create_gif", - description="Create an animated GIF from screenshots to summarize sessions or workflows", - inputSchema={ - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Session ID to create GIF from (e.g., '20250823_104523'). Use 'latest' for most recent session.", - }, - "image_paths": { - "type": "array", - "items": {"type": "string"}, - "description": "List of image file paths to create GIF from (alternative to session_id)", - }, - "pattern": { - "type": "string", - "description": "Glob pattern for images to include (alternative to session_id/image_paths)", - }, - "output_path": { - "type": "string", - "description": "Output GIF file path (auto-generated if not specified)", - }, - "duration": { - "type": "number", - "description": "Duration per frame in seconds (default: 0.5)", - "minimum": 0.1, - "maximum": 5, - "default": 0.5, - }, - "optimize": { - "type": "boolean", - "description": "Optimize GIF for smaller file size (default: true)", - "default": True, - }, - "max_frames": { - "type": "integer", - "description": "Maximum number of frames to include (default: no limit)", - "minimum": 1, - "maximum": 100, - }, - }, - }, - ), - types.Tool( - name="list_sessions", - description="List available monitoring sessions that can be converted to GIFs", - inputSchema={ - "type": "object", - "properties": { - "limit": { - "type": "integer", - "description": "Maximum number of sessions to list (default: 10)", - "minimum": 1, - "maximum": 50, - "default": 10, - }, - }, - }, - ), - types.Tool( - name="get_info", - description="Enumerate all monitors, virtual desktops, and visible windows", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="list_windows", - description="List all visible windows with their handles and process names", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="capture_window", - description="Capture a specific window by its handle", - inputSchema={ - "type": "object", - "properties": { - "window_handle": { - "type": "integer", - "description": "Window handle from list_windows", - }, - "output_path": { - "type": "string", - "description": "Optional output path for screenshot", - }, - "quality": { - "type": "integer", - "description": "JPEG quality (1-100, default: 85)", - "minimum": 1, - "maximum": 100, - "default": 85, - }, - }, - "required": ["window_handle"], - }, - ), - ] - - -# EOF diff --git a/src/scitex/capture/_skills/SKILL.md b/src/scitex/capture/_skills/SKILL.md deleted file mode 100644 index 52e0207de..000000000 --- a/src/scitex/capture/_skills/SKILL.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: stx.capture -description: AI-optimized screen capture for WSL/Windows — single shots, multi-monitor, URL, app window, continuous monitoring, GIF creation, and grid overlays. ---- - -# stx.capture - -Lightweight screen capture module optimized for WSL and Windows environments. -Captures the Windows host screen from inside WSL via PowerShell scripts. - -## Sub-skills - -- [snap.md](snap.md) — Single screenshot: `capture()` / `snap()`, auto-categorize, URL, app, all-monitors -- [monitor.md](monitor.md) — Continuous monitoring: `start_monitor()` / `stop_monitor()`, `Session` context manager -- [gif.md](gif.md) — GIF creation from session frames or arbitrary image lists -- [display-info.md](display-info.md) — `get_info()`, `capture_window()`, window enumeration -- [grid.md](grid.md) — Grid/cursor/monitor overlays for coordinate debugging -- [cli.md](cli.md) — `python -m scitex.capture` command-line interface -- [mcp.md](mcp.md) — MCP tools exposed via the unified scitex MCP server diff --git a/src/scitex/capture/_skills/cli.md b/src/scitex/capture/_skills/cli.md deleted file mode 100644 index 17369a2d4..000000000 --- a/src/scitex/capture/_skills/cli.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: Command-line interface for stx.capture — take screenshots, list windows, start/stop monitoring, create GIFs, and launch the MCP server. ---- - -# cli — Command-Line Interface - -Entry point: `python -m scitex.capture` (defined in `capture/__main__.py` and -`capture/cli.py`). - -## Usage - -``` -python -m scitex.capture [message] [options] -``` - -## Arguments and Flags - -| Flag | Type | Description | -|------|------|-------------| -| `message` | positional (optional) | Label embedded in filename | -| `--all` | flag | Capture all monitors | -| `--app APP` | str | Capture named app window (e.g. `chrome`) | -| `--url URL` | str | Capture URL via browser | -| `--monitor N` | int (default 0) | Monitor index (0-based) | -| `--quality N` | int (default 85) | JPEG quality 1-100 | -| `-o / --output PATH` | str | Explicit output path | -| `--list` | flag | List visible windows | -| `--info` | flag | Show monitor/virtual-desktop info | -| `--start` | flag | Start continuous monitoring | -| `--stop` | flag | Stop continuous monitoring | -| `--gif` | flag | Create GIF from latest session | -| `--mcp` | flag | Start MCP server | -| `--interval SEC` | float (default 1.0) | Monitoring interval | -| `-q / --quiet` | flag | Suppress output | - -## Examples - -```bash -# Single screenshot, primary monitor -python -m scitex.capture - -# With label -python -m scitex.capture "after_training" - -# All monitors -python -m scitex.capture --all - -# Specific monitor -python -m scitex.capture --monitor 1 - -# Capture Chrome -python -m scitex.capture --app chrome - -# Capture a local web server -python -m scitex.capture --url 127.0.0.1:8000 - -# Save to specific path -python -m scitex.capture -o /tmp/debug.jpg - -# List visible windows (handle + process name) -python -m scitex.capture --list - -# Show monitor info -python -m scitex.capture --info - -# Start monitoring at 2-second intervals -python -m scitex.capture --start --interval 2.0 - -# Stop monitoring (sends stop signal to running session) -python -m scitex.capture --stop - -# Create GIF from the latest monitoring session -python -m scitex.capture --gif - -# Start unified MCP server -python -m scitex.capture --mcp -``` - -## Exit Codes - -| Code | Meaning | -|------|---------| -| 0 | Success | -| 1 | Screenshot failed or other error | -| 130 | Interrupted by Ctrl+C | - -## --start behaviour - -`--start` runs an infinite loop (`time.sleep(1)`) until `KeyboardInterrupt`, -then calls `capture.stop()` automatically. The captures are saved to -`~/.scitex/capture/`. - -## --mcp behaviour - -Prints the JSON snippet for adding `scitex-capture` to Claude Code's MCP -server config, then launches the async MCP server via `asyncio.run(mcp_main())`. -Note: the standalone `mcp_server.py` is deprecated; the unified scitex MCP -server (`scitex serve`) is preferred. diff --git a/src/scitex/capture/_skills/display-info.md b/src/scitex/capture/_skills/display-info.md deleted file mode 100644 index ee2d678d2..000000000 --- a/src/scitex/capture/_skills/display-info.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -description: Enumerate monitors, virtual desktops, and visible windows; capture a specific window by handle. ---- - -# display-info — Monitor and Window Enumeration - -Defined in `capture/capture.py` as methods of `CaptureManager` and re-exported -via `capture/__init__.py`. - -## get_info - -```python -def get_info() -> dict -``` - -Runs `detect_monitors_and_desktops.ps1` via PowerShell and returns a JSON -structure. Returns `{"error": "..."}` on failure (no exceptions raised). - -**Public aliases**: `capture.get_info()`, `capture.list_windows()`, -`capture.get_display_info()` — all identical. - -### Return structure - -```python -{ - "Monitors": { - "Count": int, - "PrimaryMonitor": str, # device name - "Details": [ - { - "DeviceName": str, - "Bounds": {"X": int, "Y": int, "Width": int, "Height": int}, - "IsPrimary": bool, - }, - ... - ], - }, - "Windows": { - "VisibleCount": int, - "Details": [ - { - "Handle": int, # use with capture_window() - "Title": str, - "ProcessName": str, - "ProcessId": int, - }, - ... - ], - }, - "VirtualDesktops": { - "Supported": bool, - "Note": str, - }, - "Timestamp": str, # ISO-8601 -} -``` - -## capture_window - -```python -def capture_window( - window_handle: int, - output_path: str = None, -) -> str | None -``` - -Captures a specific window using `capture_window_by_handle.ps1`. The handle -must be obtained from `get_info()["Windows"]["Details"][n]["Handle"]`. - -Returns path to saved screenshot (JPEG by default) or `None` on failure. - -Auto-generates path as `/tmp/window_{handle}_{timestamp}.jpg` when -`output_path` is `None`. - -## Examples - -```python -from scitex import capture - -# List all monitors -info = capture.get_info() -for i, mon in enumerate(info["Monitors"]["Details"]): - b = mon["Bounds"] - print(f"Monitor {i}: {b['Width']}x{b['Height']} @ ({b['X']},{b['Y']})") - -# List visible windows -windows = info["Windows"]["Details"] -for w in windows: - print(f"[{w['ProcessName']}] {w['Title']} handle={w['Handle']}") - -# Capture the first visible window -if windows: - handle = windows[0]["Handle"] - path = capture.capture_window(handle) - print(f"Saved: {path}") - -# Find and capture a specific app -for w in windows: - if "code" in w["ProcessName"].lower(): - path = capture.capture_window(w["Handle"], output_path="/tmp/vscode.jpg") - break -``` - -## Dependency - -Requires `powershell.exe` accessible from WSL. The PowerShell scripts live in -`capture/powershell/`: - -| Script | Used by | -|--------|---------| -| `detect_monitors_and_desktops.ps1` | `get_info()` | -| `capture_window_by_handle.ps1` | `capture_window()` | -| `capture_single_monitor.ps1` | `snap()` (single monitor) | -| `capture_all_monitors.ps1` | `snap(all=True)` | -| `capture_url.ps1` | `snap(url=...)` WSL fallback | -| `capture_all_desktops.ps1` | Available but not used in public API | -| `enumerate_virtual_desktops.ps1` | Available but not used in public API | diff --git a/src/scitex/capture/_skills/gif.md b/src/scitex/capture/_skills/gif.md deleted file mode 100644 index e3ff13da7..000000000 --- a/src/scitex/capture/_skills/gif.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -description: Create animated GIFs from monitoring session frames, explicit file lists, or glob patterns. ---- - -# gif — Animated GIF Creation - -Defined in `capture/gif.py` as the `GifCreator` class with four convenience -wrapper functions exported at the module level. - -## Public Functions - -### create_gif_from_latest_session - -```python -def create_gif_from_latest_session( - screenshot_dir: str = "~/.scitex/capture", - duration: float = 0.5, - optimize: bool = True, - max_frames: int = None, -) -> str | None -``` - -Finds the most recent session ID in `screenshot_dir` and calls -`create_gif_from_session` on it. Returns GIF path or `None`. - -**Public alias**: `capture.gif(...)` and `capture.make_gif(...)`. - -### create_gif_from_session - -```python -def create_gif_from_session( - session_id: str, - output_path: str = None, # auto: {session_id}_summary.gif - screenshot_dir: str = "~/.scitex/capture", - duration: float = 0.5, - optimize: bool = True, - max_frames: int = None, -) -> str | None -``` - -Globs `{session_id}_*.jpg` (then `*.png`) in `screenshot_dir`, sorts -alphabetically, optionally thins to `max_frames` via even stride, and -creates the GIF. - -### create_gif_from_files - -```python -def create_gif_from_files( - image_paths: list[str], - output_path: str, - duration: float = 0.5, - optimize: bool = True, - loop: int = 0, # 0 = infinite loop -) -> str | None -``` - -Lowest-level function. Accepts arbitrary image list. Resizes all frames to -match the first frame's dimensions (`Image.Resampling.LANCZOS`). Requires -`Pillow`. - -### create_gif_from_pattern - -```python -def create_gif_from_pattern( - pattern: str, # glob pattern, e.g. "/tmp/frames/*.png" - output_path: str = None, - duration: float = 0.5, - optimize: bool = True, - max_frames: int = None, -) -> str | None -``` - -Expands the glob with `glob.glob`, sorts alphabetically, and calls -`create_gif_from_files`. Auto-generates path as -`{pattern_dir}/gif_summary_{timestamp}.gif` if `output_path` is `None`. - -## Session ID Discovery - -`GifCreator.get_recent_sessions(screenshot_dir)` returns a list of session -IDs (format `YYYYMMDD_HHMMSS`) found in the directory, sorted newest-first. -A session is detected when a file name matches: - -``` -^(\d{8}_\d{6})_\d{4}_.*\.(jpg|png)$ -``` - -## Frame Thinning - -When `max_frames` is set and the file count exceeds it, frames are selected -by even stride: `step = total // max_frames`, then `files[::step][:max_frames]`. - -## Dependencies - -Requires `Pillow`. Install with: - -``` -pip install Pillow -``` - -## Examples - -```python -from scitex import capture - -# GIF from the most recent monitoring session -path = capture.gif() -# same as: -path = capture.create_gif_from_latest_session() - -# Slower playback, cap at 30 frames -path = capture.create_gif_from_latest_session(duration=1.0, max_frames=30) - -# GIF from a specific session -path = capture.create_gif_from_session( - "20250823_104523", - output_path="/tmp/session_review.gif", -) - -# GIF from explicit file list -path = capture.create_gif_from_files( - image_paths=["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"], - output_path="/tmp/out.gif", - duration=0.3, -) - -# GIF from glob -path = capture.create_gif_from_pattern( - "/tmp/experiment/*.jpg", - output_path="/tmp/experiment.gif", - max_frames=50, -) -``` diff --git a/src/scitex/capture/_skills/grid.md b/src/scitex/capture/_skills/grid.md deleted file mode 100644 index f2fdc0eb3..000000000 --- a/src/scitex/capture/_skills/grid.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -description: Draw coordinate grids, monitor boundaries, and cursor-position markers on screenshots to help with UI automation and coordinate debugging. ---- - -# grid — Visual Overlays for Coordinate Debugging - -Defined in `capture/grid.py`. NOT re-exported by `capture/__init__.py`; import -directly from the submodule. - -## draw_grid_overlay - -```python -from scitex.capture.grid import draw_grid_overlay - -def draw_grid_overlay( - filepath: str, - grid_spacing: int = 100, - output_path: str = None, # default: {stem}_grid{ext} - grid_color: tuple = (255, 0, 0), # red lines - text_color: tuple = (255, 255, 0), # yellow labels - line_width: int = 1, - show_coordinates: bool = True, -) -> str -``` - -Draws a pixel-coordinate grid directly on the image. X labels appear at the -top of each vertical line; Y labels appear at the left of each horizontal line. -Modifies a copy — the original file is overwritten only if `output_path` is the -same as `filepath`. - -Requires `Pillow`. Raises `ImportError` if not installed. - -## add_monitor_info_overlay - -```python -from scitex.capture.grid import add_monitor_info_overlay - -def add_monitor_info_overlay( - filepath: str, - monitor_info: dict, # from capture.get_info() - output_path: str = None, # default: {stem}_monitors{ext} -) -> str -``` - -Draws colored rectangles around each monitor's region in a multi-monitor -combined screenshot. Labels include `Monitor N: WxH @ (X,Y) [PRIMARY]`. - -Colors cycle through red, green, blue, yellow, magenta, cyan. - -Pass the raw dict returned by `capture.get_info()` as `monitor_info`. - -## draw_cursor_overlay - -```python -from scitex.capture.grid import draw_cursor_overlay - -def draw_cursor_overlay( - filepath: str, - cursor_pos: tuple = None, # (x, y) system coords; auto-detected if None - output_path: str = None, # default: {stem}_cursor{ext} - marker_color: tuple = (0, 255, 0), # green crosshair - marker_size: int = 20, - show_coords: bool = True, - capture_mode: str = "all", # "all" or "0", "1", ... -) -> str -``` - -Draws a crosshair + center dot at the cursor's position translated into image -coordinates. Handles multi-monitor coordinate offsets automatically: - -- `capture_mode="all"` — offsets by `(min_x, min_y)` across all monitors. -- `capture_mode="N"` — offsets by the Nth monitor's `(X, Y)` position. - -Also shows: `Mon: Sys:(sx,sy) Img:(ix,iy)` text label next to the marker. -If the cursor is outside the image bounds, a red note is drawn at the bottom. - -Cursor position is fetched via PowerShell (`GetCursorPos` from `user32.dll`). - -## get_display_info (grid module) - -```python -from scitex.capture.grid import get_display_info - -def get_display_info() -> dict -``` - -A lightweight alternative to `capture.get_info()`. Uses -`System.Windows.Forms.Screen.AllScreens` and returns: - -```python -{ - "monitors": [ - {"Name": str, "Primary": bool, "X": int, "Y": int, "Width": int, "Height": int}, - ... - ], - "dpi_scale": float, # e.g. 1.25 for 125 % - "dpi_percent": int, # e.g. 125 -} -``` - -## Examples - -```python -from scitex import capture -from scitex.capture.grid import ( - draw_grid_overlay, - add_monitor_info_overlay, - draw_cursor_overlay, -) - -# 1. Take a screenshot and overlay a 200-px grid -path = capture.snap() -grid_path = draw_grid_overlay(path, grid_spacing=200) -print(f"Grid overlay: {grid_path}") - -# 2. Annotate monitor boundaries on an all-monitor capture -all_path = capture.snap(all=True) -info = capture.get_info() -annotated = add_monitor_info_overlay(all_path, info) - -# 3. Mark where the cursor is right now -cursor_path = draw_cursor_overlay(path, capture_mode="0") - -# 4. Supply cursor coords explicitly (useful for testing) -cursor_path = draw_cursor_overlay(path, cursor_pos=(960, 540)) -``` - -## Dependencies - -All functions require `Pillow`. Font detection tries common system paths -(DejaVu, Liberation, FreeMono on Linux; Consolas/Courier on Windows; Monaco/Menlo -on macOS) then falls back to `ImageFont.load_default()`. diff --git a/src/scitex/capture/_skills/mcp.md b/src/scitex/capture/_skills/mcp.md deleted file mode 100644 index f613544d6..000000000 --- a/src/scitex/capture/_skills/mcp.md +++ /dev/null @@ -1,207 +0,0 @@ ---- -description: MCP tools for screen capture — take screenshots, monitor, create GIFs, inspect windows. Exposed via the unified scitex MCP server. ---- - -# mcp — MCP Tools - -Handlers live in `capture/_mcp/handlers.py`. Registered with the unified scitex -MCP server (`scitex serve`). - -The deprecated standalone server in `capture/mcp_server.py` emits a -`DeprecationWarning` on import and should not be used directly. - -## Tools - -### capture_screenshot - -``` -capture_screenshot( - message: str = None, - monitor_id: int = 0, - all: bool = False, - app: str = None, - url: str = None, - quality: int = 85, - return_base64: bool = False, -) -> dict -``` - -Delegates to `capture.snap(...)`. Returns: - -```json -{ - "success": true, - "path": "/home/user/.scitex/capture/20250823_...-stdout.jpg", - "category": "stdout", - "message": "Screenshot saved to ...", - "timestamp": "2025-08-23T10:45:23.456789", - "base64": "" // only if return_base64=true -} -``` - -### start_monitoring - -``` -start_monitoring( - interval: float = 1.0, - monitor_id: int = 0, - capture_all: bool = False, - output_dir: str = None, - quality: int = 60, - verbose: bool = True, -) -> dict -``` - -Starts `ScreenshotWorker` in background. Returns `{"success": true, ...}`. -Only one monitoring session can run at a time per server instance. - -### stop_monitoring - -``` -stop_monitoring() -> dict -``` - -Returns `{"success": true, "screenshots_taken": N, "session_id": "..."}`. - -### get_monitoring_status - -``` -get_monitoring_status() -> dict -``` - -Returns: - -```json -{ - "success": true, - "active": false, - "cache_dir": "/home/user/.scitex/capture", - "cache_size_mb": 14.3, - "screenshot_count": 87 -} -``` - -When monitoring is active also includes `screenshots_taken`, `session_id`. - -### analyze_screenshot - -``` -analyze_screenshot(path: str) -> dict -``` - -Runs `_detect_category(path)` — pixel-based color heuristic. Returns: - -```json -{ - "success": true, - "path": "...", - "category": "stdout", - "is_error": false, - "size_kb": 45.2 -} -``` - -### list_recent_screenshots - -``` -list_recent_screenshots( - limit: int = 10, - category: str = "all", // "all" | "stdout" | "stderr" -) -> dict -``` - -Lists `*.jpg` files in `$SCITEX_DIR/capture`, sorted newest-first. - -### clear_cache - -``` -clear_cache( - max_size_gb: float = 1.0, - clear_all: bool = False, -) -> dict -``` - -`clear_all=True` removes every `*.jpg` in the cache directory. -Otherwise trims oldest files until under `max_size_gb`. - -### create_gif - -``` -create_gif( - session_id: str = None, // "latest" or specific ID like "20250823_104523" - image_paths: list = None, // explicit file list - pattern: str = None, // glob pattern - output_path: str = None, - duration: float = 0.5, - optimize: bool = True, - max_frames: int = None, -) -> dict -``` - -One of `session_id`, `image_paths`, or `pattern` must be provided. -Returns `{"success": true, "path": "...", "duration": 0.5}`. - -### list_sessions - -``` -list_sessions(limit: int = 10) -> dict -``` - -Lists monitoring session IDs (YYYYMMDD_HHMMSS format) found in the capture -directory, newest-first. Returns `{"success": true, "sessions": [...], "count": N}`. - -### get_info - -``` -get_info() -> dict -``` - -Delegates to `capture.get_info()`. Returns monitor and window data. - -### list_windows - -``` -list_windows() -> dict -``` - -Returns simplified window list: - -```json -{ - "success": true, - "windows": [ - {"handle": 12345, "title": "Visual Studio Code", "process": "Code.exe"}, - ... - ], - "count": 12 -} -``` - -### capture_window - -``` -capture_window( - window_handle: int, - output_path: str = None, - quality: int = 85, -) -> dict -``` - -Captures specific window. `window_handle` from `list_windows`. - -## MCP Resources - -The deprecated standalone `mcp_server.py` exposes screenshots as resources: - -``` -screenshot:// -``` - -Returns `image/jpeg` content as base64. The 20 most-recent screenshots are -listed by `list_resources()`. - -## Configuration - -Cache directory resolves as: `$SCITEX_DIR/capture` if `SCITEX_DIR` is set, -otherwise `~/.scitex/capture`. Migrates automatically from legacy location -`~/.cache/cammy` on first access. diff --git a/src/scitex/capture/_skills/monitor.md b/src/scitex/capture/_skills/monitor.md deleted file mode 100644 index e2c155bc0..000000000 --- a/src/scitex/capture/_skills/monitor.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -description: Continuous screenshot monitoring at configurable intervals, with event callbacks and a context-manager Session wrapper. ---- - -# monitor — Continuous Monitoring - -Two-layer API for ongoing capture: the low-level `ScreenshotWorker` thread class -(`capture/capture.py`) and the high-level convenience functions / `Session` context -manager (`capture/utils.py`, `capture/session.py`). - -## start_monitor - -```python -def start_monitor( - output_dir: str = "~/.scitex/capture/", - interval: float = 1.0, - jpeg: bool = True, - quality: int = 60, - on_capture=None, # callable(filepath: str) - on_error=None, # callable(exception) - verbose: bool = True, - monitor_id: int = 0, - capture_all: bool = False, -) -> ScreenshotWorker -``` - -Returns the running `ScreenshotWorker` instance. The worker runs in a daemon -thread so it is automatically killed when the main process exits. - -**Public aliases**: `capture.start(...)` delegates to `start_monitor(...)`. - -## stop_monitor - -```python -def stop_monitor() -> None -``` - -Stops the global `CaptureManager` worker. Waits up to 2 seconds for the thread to -join before returning. **Public alias**: `capture.stop()`. - -## ScreenshotWorker — internal class - -Defined in `capture/capture.py`. Used directly when you need fine-grained control. - -```python -class ScreenshotWorker: - def __init__( - self, - output_dir: str = "/tmp/scitex_capture_screenshots", - interval_sec: float = 1.0, - verbose: bool = False, - use_jpeg: bool = True, - jpeg_quality: int = 60, - on_capture=None, # called after each successful capture - on_error=None, # called on each capture exception - ) - - def start(self, session_id: str = None) -> None - def stop(self) -> None - def get_status(self) -> dict -``` - -`get_status()` returns: - -```python -{ - "running": bool, - "session_id": str, # YYYYMMDD_HHMMSS if auto-generated - "screenshot_count": int, - "output_dir": str, - "interval_sec": float, - "use_jpeg": bool, - "jpeg_quality": int, -} -``` - -Output filenames follow: `{session_id}_{count:04d}_{timestamp}.{ext}` - -## Session context manager - -```python -class Session: - def __init__( - self, - output_dir: str = "~/.scitex/capture/", - interval: float = 1.0, - jpeg: bool = True, - quality: int = 60, - on_capture=None, - on_error=None, - verbose: bool = True, - monitor_id: int = 0, - capture_all: bool = False, - ) -``` - -`__enter__` calls `start_monitor(...)` and returns `self`. -`__exit__` calls `stop_monitor()`. Does **not** suppress exceptions. - -**Factory function**: `capture.session(**kwargs)` returns a `Session` object. - -## Examples - -```python -from scitex import capture - -# Simple start / stop -capture.start() -# ... your code ... -capture.stop() - -# With configurable interval and callbacks -def on_each(path): - print(f"New frame: {path}") - -worker = capture.start( - interval=2.0, - quality=50, - on_capture=on_each, -) -# worker.get_status() -> {"running": True, "screenshot_count": ..., ...} -capture.stop() - -# Context manager (auto start/stop) -with capture.session(interval=0.5, output_dir="/tmp/my_session/") as sess: - # monitoring runs here - do_long_running_work() -# monitoring automatically stopped on exit - -# All monitors, continuous -with capture.session(capture_all=True, interval=3.0): - run_experiment() -``` - -## File Layout - -Screenshots saved under `output_dir`: - -``` -~/.scitex/capture/ - 20250823_104523_0000_20250823_104523_123.jpg - 20250823_104523_0001_20250823_104524_456.jpg - ... -``` - -The session ID embedded in each filename is used by the GIF creator to -group frames (see `gif.md`). - -## Cache Management - -`start_monitor` does **not** enforce cache size during monitoring. -Cache trimming runs only inside `capture()` (single-shot) via -`_manage_cache_size(cache_dir, max_cache_gb)`. - -To manage cache manually: - -```python -from scitex.capture.utils import _manage_cache_size -from pathlib import Path - -_manage_cache_size(Path("~/.scitex/capture").expanduser(), max_size_gb=0.5) -``` - -Files are removed oldest-first until total size is under the limit. diff --git a/src/scitex/capture/_skills/snap.md b/src/scitex/capture/_skills/snap.md deleted file mode 100644 index 4c3b0452e..000000000 --- a/src/scitex/capture/_skills/snap.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -description: Take a single screenshot — monitor, app window, URL, or all monitors. Auto-categorizes as stdout/stderr based on content. ---- - -# snap — Single Screenshot - -Primary function for one-shot captures. Defined in `capture/utils.py`. - -## Signature - -```python -def capture( - message: str = None, - path: str = None, - quality: int = 85, - auto_categorize: bool = True, - verbose: bool = True, - monitor_id: int = 0, - capture_all: bool = False, - all: bool = False, # shorthand for capture_all - app: str = None, - url: str = None, - url_wait: int = 3, - url_width: int = 1920, - url_height: int = 1080, - max_cache_gb: float = 1.0, -) -> str -``` - -**Public aliases** (all call `capture()` identically): - -| Alias | Style | -|-------|-------| -| `capture.snap(...)` | Primary — natural camera action | -| `capture.take(...)` | Alternative phrasing | -| `capture.cpt(...)` | Legacy backwards-compat | - -## Parameters - -| Parameter | Default | Description | -|-----------|---------|-------------| -| `message` | `None` | Label embedded in output filename | -| `path` | `None` | Explicit save path; auto-generated under `~/.scitex/capture/` if omitted | -| `quality` | `85` | JPEG quality 1-100 | -| `auto_categorize` | `True` | Detect stdout/stderr from content and embed in filename | -| `monitor_id` | `0` | 0-based monitor index (primary = 0) | -| `capture_all` | `False` | Capture all monitors stitched into one image | -| `all` | `False` | Shorthand for `capture_all=True` | -| `app` | `None` | App name to find and capture (e.g. `"chrome"`, `"code"`) | -| `url` | `None` | URL to capture via Playwright headless browser | -| `url_wait` | `3` | Seconds to wait after page load before screenshot | -| `url_width` | `1920` | Viewport width for URL capture | -| `url_height` | `1080` | Viewport height for URL capture | -| `max_cache_gb` | `1.0` | Auto-evict oldest files when cache exceeds this size | - -## Returns - -`str` — absolute path to the saved screenshot, or `None` on failure. - -## Output File Naming - -When `path` is not given, the filename is assembled from placeholders: - -``` -~/.scitex/capture/.jpg -``` - -- `` — `YYYYMMDD_HHMMSS_mmm` -- `` — empty for primary monitor; `-all-monitors` or `-monitor1` otherwise -- `` — sanitised, first 50 chars of `message` param -- `` — `-stdout` or `-stderr` - -## Capture Priority Order - -1. **URL capture** (`url=` set) — uses Playwright (`chromium`, headless). Falls back to Windows-side `capture_url.ps1` in WSL. -2. **App capture** (`app=` set) — calls `get_info()` to find matching window by process name or title, then `capture_window(handle)`. -3. **Monitor capture** (default) — PowerShell scripts for WSL; `mss` or `scrot` fallback for native Linux. - -## Auto-categorization - -When `auto_categorize=True` the function: - -1. Checks `sys.exc_info()` — if inside an exception handler the category is immediately `stderr` and the traceback is appended to `message`. -2. Otherwise reads pixel colors of the captured image (via PIL): >5 % red pixels → `"error"`, >5 % yellow pixels → `"warning"`, else `"stdout"`. - -The category is embedded as `[STDOUT]` / `[STDERR]` in the EXIF `UserComment` tag (falls back to a `.txt` sidecar file if PIL is unavailable). - -## Examples - -```python -from scitex import capture - -# Minimal — primary monitor, auto path -capture.snap() - -# With label -capture.snap("after_training_epoch_5") - -# Specific output path -capture.snap(path="/tmp/debug.jpg") - -# All monitors -capture.snap(all=True) - -# Secondary monitor -capture.snap(monitor_id=1) - -# Capture Chrome window -capture.snap(app="chrome") - -# Capture a local web app (Playwright required) -capture.snap(url="http://127.0.0.1:8000", url_wait=5) - -# Capture URL without http:// prefix (auto-expanded) -capture.snap(url="localhost:3000") - -# Lower quality for smaller files -capture.snap(quality=50) -``` - -## Installation Notes - -- **WSL screen capture**: requires `powershell.exe` accessible from WSL (`/mnt/c/Windows/System32/...` or in `$PATH`). -- **URL capture**: `pip install 'scitex[capture-browser]'` or `pip install playwright && playwright install chromium`. -- **JPEG save / PIL**: `pip install Pillow`. -- **Native Linux fallback**: `pip install mss` or `apt install scrot`. diff --git a/src/scitex/capture/capture.py b/src/scitex/capture/capture.py deleted file mode 100755 index fa4128539..000000000 --- a/src/scitex/capture/capture.py +++ /dev/null @@ -1,821 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Timestamp: "2025-10-18 09:55:59 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/capture.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/capture.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - -""" -Core screenshot capture functionality. -Optimized for WSL to Windows host screen capture. -""" - -import subprocess -import sys -import threading -import time -from datetime import datetime -from pathlib import Path -from typing import Optional - - -class ScreenshotWorker: - """ - Independent worker thread for continuous screenshot capture. - Takes screenshots at configurable intervals with compression options. - """ - - def __init__( - self, - output_dir: str = "/tmp/scitex_capture_screenshots", - interval_sec: float = 1.0, - verbose: bool = False, - use_jpeg: bool = True, - jpeg_quality: int = 60, - on_capture=None, - on_error=None, - ): - """ - Initialize screenshot worker. - - Parameters - ---------- - output_dir : str - Directory for saving screenshots - interval_sec : float - Seconds between screenshots (default: 1.0) - verbose : bool - Print screenshot paths in runtime log - use_jpeg : bool - Use JPEG compression for smaller files (default: True) - jpeg_quality : int - JPEG quality 1-100, lower = smaller files (default: 60) - on_capture : callable, optional - Callback function called with filepath after each capture - on_error : callable, optional - Callback function called with exception on errors - """ - self.output_dir = Path(output_dir) - self.interval_sec = interval_sec - self.verbose = verbose - self.use_jpeg = use_jpeg - self.jpeg_quality = jpeg_quality - self.on_capture = on_capture - self.on_error = on_error - - # Worker state - self.running = False - self.worker_thread = None - self.screenshot_count = 0 - self.session_id = None - - # Monitor capture settings - self.monitor = 0 # Default to primary monitor (0-based indexing) - self.capture_all = False # Default to single monitor - - # Create output directory - self.output_dir.mkdir(parents=True, exist_ok=True) - - def start(self, session_id: str = None): - """Start the screenshot worker thread.""" - if self.running: - if self.verbose: - print("⚠️ Worker already running") - return - - self.running = True - self.screenshot_count = 0 - self.session_id = session_id or datetime.now().strftime("%Y%m%d_%H%M%S") - - # Start worker thread - self.worker_thread = threading.Thread( - target=self._worker_loop, daemon=True, name="ScreenshotWorker" - ) - self.worker_thread.start() - - if self.verbose: - ext = "jpg" if self.use_jpeg else "png" - print( - f"📸 Started: {self.output_dir}/{self.session_id}_NNNN_*.{ext} (interval: {self.interval_sec}s)" - ) - - def stop(self): - """Stop the screenshot worker thread.""" - if not self.running: - return - - self.running = False - - if self.worker_thread and self.worker_thread.is_alive(): - self.worker_thread.join(timeout=2) - - if self.verbose: - print( - f"📸 Stopped: {self.screenshot_count} screenshots in {self.output_dir}" - ) - - def _worker_loop(self): - """Main worker loop that takes screenshots.""" - - next_capture_time = time.time() - - while self.running: - current_time = time.time() - - # Check if it's time for next capture - if current_time >= next_capture_time: - try: - screenshot_path = self._take_screenshot() - - if screenshot_path: - if self.verbose: - # Simple one-line output - print(f"📸 {screenshot_path}") - - # Call on_capture callback if provided - if self.on_capture: - try: - self.on_capture(screenshot_path) - except Exception as cb_error: - if self.verbose: - print(f"⚠️ Callback error: {cb_error}") - - except Exception as e: - if self.verbose: - print(f"❌ Error: {e}") - - # Call on_error callback if provided - if self.on_error: - try: - self.on_error(e) - except Exception as cb_error: - if self.verbose: - print(f"⚠️ Error callback failed: {cb_error}") - - # Schedule next capture - next_capture_time = current_time + self.interval_sec - - # Short sleep to avoid busy waiting, but allow responsive stopping - time.sleep(0.01) - - def _take_screenshot(self) -> Optional[str]: - """Take a single screenshot.""" - try: - # Generate filename with timestamp - now = datetime.now() - timestamp = now.strftime("%Y%m%d_%H%M%S_%f")[:-3] - ext = "jpg" if self.use_jpeg else "png" - filename = ( - f"{self.session_id}_{self.screenshot_count:04d}_{timestamp}.{ext}" - ) - filepath = self.output_dir / filename - - # Try Windows PowerShell method for WSL - if self._is_wsl(): - if self._capture_windows_screen( - filepath, - monitor=self.monitor, - capture_all=self.capture_all, - ): - self.screenshot_count += 1 - return str(filepath) - - # Fallback to native screenshot tools - if self._capture_native_screen(filepath): - self.screenshot_count += 1 - return str(filepath) - - return None - - except Exception as e: - if self.verbose: - print(f"❌ Screenshot failed: {e}") - return None - - def _is_wsl(self) -> bool: - """Check if running in WSL.""" - return sys.platform == "linux" and "microsoft" in os.uname().release.lower() - - def _capture_windows_screen( - self, filepath: Path, monitor: int = 1, capture_all: bool = False - ) -> bool: - """Capture Windows host screen from WSL with DPI awareness using external PowerShell scripts. - - Args: - filepath: Path to save the screenshot - monitor: Monitor number to capture (1-based index) - capture_all: If True, capture all monitors combined - """ - try: - # Try using external PowerShell script first - script_dir = Path(__file__).parent / "powershell" - if capture_all: - script_path = script_dir / "capture_all_monitors.ps1" - else: - script_path = script_dir / "capture_single_monitor.ps1" - - # Check if script exists - if script_path.exists(): - # Find PowerShell executable - ps_paths = [ - "powershell.exe", - "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - "/mnt/c/Windows/SysWOW64/WindowsPowerShell/v1.0/powershell.exe", - ] - - ps_exe = None - for path in ps_paths: - try: - test_result = subprocess.run( - [path, "-Command", "echo test"], - capture_output=True, - timeout=1, - ) - if test_result.returncode == 0: - ps_exe = path - break - except: - continue - - if ps_exe: - # Build PowerShell command - if capture_all: - cmd = [ - ps_exe, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script_path), - "-OutputFormat", - "base64", - ] - else: - # Pass 0-based monitor index directly to PowerShell - cmd = [ - ps_exe, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script_path), - "-MonitorNumber", - str(monitor), - "-OutputFormat", - "base64", - ] - - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=5 - ) - - if result.returncode == 0 and result.stdout.strip(): - # Decode base64 PNG data - import base64 - - png_data = base64.b64decode(result.stdout.strip()) - - # Save directly as JPEG if requested, otherwise as PNG - if self.use_jpeg: - try: - import io - - from PIL import Image - - img = Image.open(io.BytesIO(png_data)) - # Convert RGBA to RGB for JPEG - if img.mode == "RGBA": - rgb_img = Image.new( - "RGB", img.size, (255, 255, 255) - ) - rgb_img.paste( - img, mask=img.split()[3] - ) # Use alpha channel as mask - img = rgb_img - img.save( - str(filepath), - "JPEG", - quality=self.jpeg_quality, - optimize=True, - ) - except ImportError: - # PIL not available, save as PNG - with open(str(filepath), "wb") as f: - f.write(png_data) - else: - with open(str(filepath), "wb") as f: - f.write(png_data) - - return filepath.exists() - - # Fallback to inline script - return self._capture_windows_screen_inline(filepath) - - except Exception as e: - if self.verbose: - print(f"❌ Windows screen capture error: {e}") - import traceback - - traceback.print_exc() - return False - - def _capture_windows_screen_inline(self, filepath: Path) -> bool: - """Fallback inline PowerShell capture (when .ps1 files not available).""" - try: - if self.verbose: - print("🔍 Attempting inline PowerShell capture...") - # Use base64 encoding to avoid path issues (most reliable for WSL) - # Now with DPI awareness for proper high-resolution capture - ps_script = """ - Add-Type -AssemblyName System.Windows.Forms - Add-Type -AssemblyName System.Drawing - - # Enable DPI awareness for proper high-resolution capture - Add-Type @' - using System; - using System.Runtime.InteropServices; - public class User32 { - [DllImport("user32.dll")] - public static extern bool SetProcessDPIAware(); - } -'@ - $null = [User32]::SetProcessDPIAware() - - $screen = [System.Windows.Forms.Screen]::PrimaryScreen - $bitmap = New-Object System.Drawing.Bitmap $screen.Bounds.Width, $screen.Bounds.Height - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) - - # Set high quality rendering - $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality - $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic - $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality - $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality - - $graphics.CopyFromScreen($screen.Bounds.X, $screen.Bounds.Y, 0, 0, $bitmap.Size) - - $stream = New-Object System.IO.MemoryStream - $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) - $bytes = $stream.ToArray() - [Convert]::ToBase64String($bytes) - - $graphics.Dispose() - $bitmap.Dispose() - $stream.Dispose() - """ - - # Find PowerShell executable - ps_paths = [ - # Check PATH first (might be in .win-bin or similar) - "powershell.exe", - # Standard WSL path - "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - # Alternative locations - "/mnt/c/Windows/SysWOW64/WindowsPowerShell/v1.0/powershell.exe", - ] - - ps_exe = None - for path in ps_paths: - try: - # Just check if the file exists and is executable - test_path = ( - Path(path) if not path.startswith("/mnt/") else Path(path) - ) - if path == "powershell.exe": - # In PATH - use it directly - ps_exe = path - if self.verbose: - print(f"✓ Found PowerShell in PATH") - break - elif test_path.exists() or Path(path).exists(): - ps_exe = path - if self.verbose: - print(f"✓ Found PowerShell at {path}") - break - except: - continue - - if not ps_exe: - if self.verbose: - print("❌ PowerShell executable not found") - return False - - if self.verbose: - print(f"✓ Using PowerShell: {ps_exe}") - - # Execute PowerShell - cmd = [ps_exe, "-NoProfile", "-Command", ps_script] - - if self.verbose: - print("🔄 Executing PowerShell script...") - - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) - - if self.verbose: - print(f"✓ PowerShell return code: {result.returncode}") - if result.stderr: - print(f"PowerShell stderr: {result.stderr[:500]}") - if result.stdout: - print(f"✓ PowerShell stdout length: {len(result.stdout)} chars") - except subprocess.TimeoutExpired as e: - if self.verbose: - print(f"❌ PowerShell timeout after 10s") - return False - - if result.returncode == 0 and result.stdout.strip(): - # Decode base64 PNG data - import base64 - - png_data = base64.b64decode(result.stdout.strip()) - - # Save directly as JPEG if requested, otherwise as PNG - if self.use_jpeg: - try: - import io - - from PIL import Image - - # Load PNG from memory - img = Image.open(io.BytesIO(png_data)) - # Convert RGBA to RGB for JPEG - if img.mode == "RGBA": - rgb_img = Image.new("RGB", img.size, (255, 255, 255)) - rgb_img.paste(img, mask=img.split()[3]) - img = rgb_img - # Save as JPEG with quality - img.save( - str(filepath), - "JPEG", - quality=self.jpeg_quality, - optimize=True, - ) - except ImportError: - # PIL not available, save as PNG - with open(str(filepath), "wb") as f: - f.write(png_data) - else: - # Save as PNG - with open(str(filepath), "wb") as f: - f.write(png_data) - - return filepath.exists() - except Exception: - pass - return False - - def _capture_native_screen(self, filepath: Path) -> bool: - """Capture screen using native tools.""" - try: - # Try mss first - try: - import mss - - with mss.mss() as sct: - # Capture primary monitor - monitor = ( - sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0] - ) - screenshot = sct.grab(monitor) - - if self.use_jpeg: - # Convert to PIL for JPEG saving - from PIL import Image - - img = Image.frombytes( - "RGB", - screenshot.size, - screenshot.bgra, - "raw", - "BGRX", - ) - img.save(str(filepath), "JPEG", quality=self.jpeg_quality) - else: - mss.tools.to_png( - screenshot.rgb, - screenshot.size, - output=str(filepath), - ) - - return filepath.exists() - except ImportError: - pass - - # Try scrot - if self.use_jpeg: - cmd = [ - "scrot", - "-z", - "-q", - str(self.jpeg_quality), - str(filepath), - ] - else: - cmd = ["scrot", "-z", str(filepath)] - - result = subprocess.run(cmd, capture_output=True, timeout=2) - return result.returncode == 0 and filepath.exists() - - except Exception as e: - if self.verbose: - print(f"❌ Native screen capture failed: {e}") - return False - - def get_status(self) -> dict: - """Get current worker status.""" - return { - "running": self.running, - "session_id": self.session_id, - "screenshot_count": self.screenshot_count, - "output_dir": str(self.output_dir), - "interval_sec": self.interval_sec, - "use_jpeg": self.use_jpeg, - "jpeg_quality": self.jpeg_quality, - } - - -class CaptureManager: - """High-level interface for managing screen capture.""" - - def __init__(self): - self.worker = None - - def start_capture( - self, - output_dir: str = "/tmp/scitex_capture_screenshots", - interval: float = 1.0, - jpeg: bool = True, - quality: int = 60, - on_capture=None, - on_error=None, - verbose: bool = False, - monitor_id: int = 0, - capture_all: bool = False, - ) -> ScreenshotWorker: - """Start continuous screen capture.""" - if self.worker and self.worker.running: - print("Capture already running") - return self.worker - - self.worker = ScreenshotWorker( - output_dir=output_dir, - interval_sec=interval, - use_jpeg=jpeg, - jpeg_quality=quality, - on_capture=on_capture, - on_error=on_error, - verbose=verbose, - ) - # Set monitor parameters - self.worker.monitor = monitor_id - self.worker.capture_all = capture_all - self.worker.start() - return self.worker - - def stop_capture(self): - """Stop screen capture.""" - if self.worker: - self.worker.stop() - self.worker = None - - def take_single_screenshot( - self, - output_path: str = None, - jpeg: bool = True, - quality: int = 85, - monitor_id: int = 0, - capture_all_monitors: bool = False, - ) -> Optional[str]: - """ - Take a single screenshot. - - Args: - output_path: Path to save screenshot - jpeg: Use JPEG compression - quality: JPEG quality (1-100) - monitor_id: Monitor index to capture (0-based) - capture_all_monitors: Capture all monitors combined - - Returns: - Path to saved screenshot - """ - if output_path is None: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - ext = "jpg" if jpeg else "png" - output_path = f"/tmp/screenshot_{timestamp}.{ext}" - - worker = ScreenshotWorker( - output_dir=str(Path(output_path).parent), - use_jpeg=jpeg, - jpeg_quality=quality, - verbose=False, - ) - - # Set monitor parameters - worker.monitor = monitor_id - worker.capture_all = capture_all_monitors - - # Take single screenshot - worker.session_id = "single" - worker.screenshot_count = 0 - screenshot_path = worker._take_screenshot() - - if screenshot_path: - # Rename to desired path - Path(screenshot_path).rename(output_path) - return output_path - - return None - - def get_info(self) -> dict: - """ - Enumerate all available monitors and virtual desktops. - - Returns: - Dictionary with monitor information - """ - try: - script_dir = Path(__file__).parent / "powershell" - script_path = script_dir / "detect_monitors_and_desktops.ps1" - - if not script_path.exists(): - return {"error": "Detection script not found"} - - # Find PowerShell - ps_paths = [ - "powershell.exe", - "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - ] - - ps_exe = None - for path in ps_paths: - try: - result = subprocess.run( - [path, "-Command", "echo test"], - capture_output=True, - timeout=1, - ) - if result.returncode == 0: - ps_exe = path - break - except: - continue - - if not ps_exe: - return {"error": "PowerShell not found"} - - # Execute detection script - cmd = [ - ps_exe, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script_path), - ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) - - if result.returncode == 0 and result.stdout.strip(): - # Parse JSON from output (skip non-JSON lines) - import json - - lines = result.stdout.strip().split("\n") - for line in lines: - line = line.strip() - if line.startswith("{"): - return json.loads(line) - - return {"error": "No JSON in output"} - else: - return { - "error": (result.stderr if result.stderr else "Detection failed") - } - - except Exception as e: - return {"error": str(e)} - - def capture_window( - self, - window_handle: int, - output_path: str = None, - jpeg: bool = True, - quality: int = 85, - ) -> Optional[str]: - """ - Capture a specific window by its handle. - - Args: - window_handle: Window handle (from get_info) - output_path: Path to save screenshot - jpeg: Use JPEG compression - quality: JPEG quality (1-100) - - Returns: - Path to saved screenshot or None - """ - try: - script_dir = Path(__file__).parent / "powershell" - script_path = script_dir / "capture_window_by_handle.ps1" - - if not script_path.exists(): - return None - - # Find PowerShell - ps_paths = [ - "powershell.exe", - "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - ] - - ps_exe = None - for path in ps_paths: - try: - result = subprocess.run( - [path, "-Command", "echo test"], - capture_output=True, - timeout=1, - ) - if result.returncode == 0: - ps_exe = path - break - except: - continue - - if not ps_exe: - return None - - # Execute window capture script - cmd = [ - ps_exe, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script_path), - "-WindowHandle", - str(window_handle), - ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) - - if result.returncode == 0 and result.stdout.strip(): - # Parse JSON from output - import base64 - import json - - lines = result.stdout.strip().split("\n") - for line in lines: - line = line.strip() - if line.startswith("{"): - data = json.loads(line) - break - else: - return None - - if not data.get("Success"): - return None - - # Generate output path if not provided - if output_path is None: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - ext = "jpg" if jpeg else "png" - output_path = f"/tmp/window_{window_handle}_{timestamp}.{ext}" - - # Decode base64 image - img_data = base64.b64decode(data.get("Base64Data", "")) - - # Save as JPEG or PNG - if jpeg: - try: - import io - - from PIL import Image - - img = Image.open(io.BytesIO(img_data)) - if img.mode == "RGBA": - rgb_img = Image.new("RGB", img.size, (255, 255, 255)) - rgb_img.paste(img, mask=img.split()[3]) - img = rgb_img - img.save(output_path, "JPEG", quality=quality, optimize=True) - except ImportError: - output_path = output_path.replace(".jpg", ".png").replace( - ".jpeg", ".png" - ) - with open(output_path, "wb") as f: - f.write(img_data) - else: - with open(output_path, "wb") as f: - f.write(img_data) - - return output_path if Path(output_path).exists() else None - - except Exception as e: - return None - - -# EOF diff --git a/src/scitex/capture/cli.py b/src/scitex/capture/cli.py deleted file mode 100755 index eb028f8a2..000000000 --- a/src/scitex/capture/cli.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Timestamp: "2025-10-18 09:55:58 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/cli.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/cli.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - -""" -CLI for scitex.capture - AI's Camera -""" - -import argparse -import sys - - -def main(): - """Main CLI entry point.""" - parser = argparse.ArgumentParser( - description="scitex.capture - AI's Camera: Capture screenshots from anywhere", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python -m scitex.capture # Capture current screen - python -m scitex.capture --all # Capture all monitors - python -m scitex.capture --app chrome # Capture Chrome window - python -m scitex.capture --url 127.0.0.1:8000 # Capture URL - python -m scitex.capture --monitor 1 # Capture monitor 1 - python -m scitex.capture --list # List available windows - - python -m scitex.capture --start # Start monitoring - python -m scitex.capture --stop # Stop monitoring - python -m scitex.capture --gif # Create GIF from session - python -m scitex.capture --mcp # Start MCP server - """, - ) - - # Capture options - parser.add_argument("message", nargs="?", help="Optional message for filename") - parser.add_argument("--all", action="store_true", help="Capture all monitors") - parser.add_argument("--app", type=str, help="App name to capture (e.g., chrome)") - parser.add_argument("--url", type=str, help="URL to capture (e.g., 127.0.0.1:8000)") - parser.add_argument("--monitor", type=int, default=0, help="Monitor ID (0-based)") - parser.add_argument("--quality", type=int, default=85, help="JPEG quality (1-100)") - parser.add_argument("-o", "--output", type=str, help="Output path") - - # Actions - parser.add_argument("--list", action="store_true", help="List available windows") - parser.add_argument("--info", action="store_true", help="Show display info") - parser.add_argument("--start", action="store_true", help="Start monitoring") - parser.add_argument("--stop", action="store_true", help="Stop monitoring") - parser.add_argument( - "--gif", action="store_true", help="Create GIF from latest session" - ) - parser.add_argument("--mcp", action="store_true", help="Start MCP server") - - # Options - parser.add_argument( - "--interval", - type=float, - default=1.0, - help="Monitoring interval in seconds", - ) - parser.add_argument("-q", "--quiet", action="store_true", help="Quiet mode") - - args = parser.parse_args() - - # Import scitex.capture after parsing to avoid import overhead for --help - from scitex import capture - - verbose = not args.quiet - - try: - # Handle actions - if args.list: - info = capture.get_info() - windows = info.get("Windows", {}).get("Details", []) - print(f"\n📱 Visible Windows ({len(windows)}):") - print("=" * 60) - for i, win in enumerate(windows, 1): - print(f"{i}. [{win['ProcessName']}] {win['Title']}") - print(f" Handle: {win['Handle']} | PID: {win['ProcessId']}") - return 0 - - elif args.info: - info = capture.get_info() - monitors = info.get("Monitors", {}) - windows = info.get("Windows", {}) - vd = info.get("VirtualDesktops", {}) - - print("\n🖥️ Display Information") - print("=" * 60) - print(f"\n📺 Monitors: {monitors.get('Count')}") - print(f" Primary: {monitors.get('PrimaryMonitor')}") - - for i, mon in enumerate(monitors.get("Details", [])): - bounds = mon.get("Bounds", {}) - print(f"\n Monitor {i}:") - print(f" Device: {mon.get('DeviceName')}") - print(f" Resolution: {bounds.get('Width')}x{bounds.get('Height')}") - print(f" Primary: {mon.get('IsPrimary')}") - - print(f"\n🪟 Windows: {windows.get('VisibleCount')}") - print(f" On current virtual desktop: {len(windows.get('Details', []))}") - - print(f"\n🖥️ Virtual Desktops:") - print(f" Supported: {vd.get('Supported')}") - print(f" Note: {vd.get('Note')}") - - return 0 - - elif args.start: - print(f"📸 Starting monitoring (interval: {args.interval}s)...") - capture.start( - interval=args.interval, - verbose=verbose, - monitor_id=args.monitor, - all=args.all, - ) - print( - "✅ Monitoring started. Press Ctrl+C to stop, or run: python -m scitex.capture --stop" - ) - print(f"📁 Saving to: ~/.scitex/capture/") - - # Keep running - try: - import time - - while True: - time.sleep(1) - except KeyboardInterrupt: - capture.stop() - print("\n✅ Monitoring stopped") - - return 0 - - elif args.stop: - capture.stop() - print("✅ Monitoring stopped") - return 0 - - elif args.gif: - print("📹 Creating GIF from latest session...") - path = capture.gif() - if path: - print(f"✅ GIF created: {path}") - return 0 - else: - print("❌ No session found") - return 1 - - elif args.mcp: - print("🤖 Starting scitex.capture MCP server...") - print("Add to Claude Code settings:") - print("{") - print(' "mcpServers": {') - print(' "scitex-capture": {') - print(' "command": "python",') - print(' "args": ["-m", "scitex.capture", "--mcp"]') - print(" }") - print(" }") - print("}") - print() - - # Start MCP server - import asyncio - - from .mcp_server import main as mcp_main - - asyncio.run(mcp_main()) - return 0 - - # Default: capture screenshot - else: - path = capture.snap( - message=args.message, - path=args.output, - quality=args.quality, - monitor_id=args.monitor, - all=args.all, - app=args.app, - url=args.url, - verbose=verbose, - ) - - if path: - if not args.quiet: - print(f"✅ {path}") - return 0 - else: - print("❌ Screenshot failed") - return 1 - - except KeyboardInterrupt: - print("\n⚠️ Interrupted") - return 130 - except Exception as e: - print(f"❌ Error: {e}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) - -# EOF diff --git a/src/scitex/capture/gif.py b/src/scitex/capture/gif.py deleted file mode 100755 index ed0e001fd..000000000 --- a/src/scitex/capture/gif.py +++ /dev/null @@ -1,341 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Timestamp: "2025-10-18 09:55:56 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/gif.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/gif.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - -""" -GIF creation functionality for CAM. -Create animated GIFs from screenshot sequences for visual summaries. -""" - -import glob -import re -from datetime import datetime -from pathlib import Path -from typing import List, Optional - - -class GifCreator: - """ - Creates animated GIFs from screenshot sequences. - Useful for creating visual summaries of monitoring sessions or workflows. - """ - - def __init__(self): - """Initialize GIF creator.""" - pass - - def create_gif_from_session( - self, - session_id: str, - output_path: Optional[str] = None, - screenshot_dir: str = "~/.scitex/capture", - duration: float = 0.5, - optimize: bool = True, - max_frames: Optional[int] = None, - ) -> Optional[str]: - """ - Create a GIF from a monitoring session's screenshots. - - Args: - session_id: Session ID from monitoring (e.g., "20250823_104523") - output_path: Output GIF path (auto-generated if None) - screenshot_dir: Directory containing screenshots - duration: Duration per frame in seconds (default: 0.5) - optimize: Optimize GIF for smaller file size (default: True) - max_frames: Maximum number of frames to include (None = all) - - Returns: - Path to created GIF file, or None if failed - """ - try: - screenshot_dir = Path(screenshot_dir).expanduser() - - # Find all screenshots for this session - pattern = f"{session_id}_*.jpg" - jpg_files = list(screenshot_dir.glob(pattern)) - - # Also try PNG if no JPG files found - if not jpg_files: - pattern = f"{session_id}_*.png" - jpg_files = list(screenshot_dir.glob(pattern)) - - if not jpg_files: - print(f"No screenshots found for session {session_id}") - return None - - # Sort by filename (which includes timestamp) - jpg_files.sort() - - # Limit frames if specified - if max_frames and len(jpg_files) > max_frames: - # Take evenly spaced frames - step = len(jpg_files) // max_frames - jpg_files = jpg_files[::step][:max_frames] - - if output_path is None: - output_path = screenshot_dir / f"{session_id}_summary.gif" - else: - output_path = Path(output_path) - - return self.create_gif_from_files( - image_paths=[str(f) for f in jpg_files], - output_path=str(output_path), - duration=duration, - optimize=optimize, - ) - - except Exception as e: - print(f"Error creating GIF from session: {e}") - return None - - def create_gif_from_files( - self, - image_paths: List[str], - output_path: str, - duration: float = 0.5, - optimize: bool = True, - loop: int = 0, - ) -> Optional[str]: - """ - Create a GIF from a list of image files. - - Args: - image_paths: List of image file paths - output_path: Output GIF path - duration: Duration per frame in seconds (default: 0.5) - optimize: Optimize GIF for smaller file size (default: True) - loop: Number of loops (0 = infinite, default: 0) - - Returns: - Path to created GIF file, or None if failed - """ - try: - from PIL import Image - - if not image_paths: - print("No image paths provided") - return None - - # Load all images - images = [] - for path in image_paths: - if not os.path.exists(path): - print(f"Image not found: {path}") - continue - - try: - img = Image.open(path) - # Convert to RGB if necessary (for consistency) - if img.mode != "RGB": - img = img.convert("RGB") - images.append(img) - except Exception as e: - print(f"Error loading image {path}: {e}") - continue - - if not images: - print("No valid images found") - return None - - # Ensure all images have the same size (resize to first image size) - target_size = images[0].size - for i in range(1, len(images)): - if images[i].size != target_size: - images[i] = images[i].resize(target_size, Image.Resampling.LANCZOS) - - # Create output directory if it doesn't exist - output_path = Path(output_path) - output_path.parent.mkdir(parents=True, exist_ok=True) - - # Save as GIF - duration_ms = int(duration * 1000) # Convert to milliseconds - - images[0].save( - str(output_path), - format="GIF", - save_all=True, - append_images=images[1:], - duration=duration_ms, - loop=loop, - optimize=optimize, - ) - - if output_path.exists(): - file_size = output_path.stat().st_size / 1024 # KB - print( - f"📹 GIF created: {output_path} ({len(images)} frames, {file_size:.1f}KB)" - ) - return str(output_path) - else: - return None - - except ImportError: - print( - "PIL (Pillow) is required for GIF creation. Install with: pip install Pillow" - ) - return None - except Exception as e: - print(f"Error creating GIF: {e}") - return None - - def create_gif_from_pattern( - self, - pattern: str, - output_path: Optional[str] = None, - duration: float = 0.5, - optimize: bool = True, - max_frames: Optional[int] = None, - ) -> Optional[str]: - """ - Create a GIF from files matching a glob pattern. - - Args: - pattern: Glob pattern for image files (e.g., "/path/screenshots/*.jpg") - output_path: Output GIF path (auto-generated if None) - duration: Duration per frame in seconds (default: 0.5) - optimize: Optimize GIF for smaller file size (default: True) - max_frames: Maximum number of frames to include (None = all) - - Returns: - Path to created GIF file, or None if failed - """ - try: - # Find matching files - files = glob.glob(pattern) - files.sort() # Sort alphabetically - - if not files: - print(f"No files found matching pattern: {pattern}") - return None - - # Limit frames if specified - if max_frames and len(files) > max_frames: - step = len(files) // max_frames - files = files[::step][:max_frames] - - if output_path is None: - # Generate output path based on pattern - pattern_dir = Path(pattern).parent - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_path = pattern_dir / f"gif_summary_{timestamp}.gif" - - return self.create_gif_from_files( - image_paths=files, - output_path=str(output_path), - duration=duration, - optimize=optimize, - ) - - except Exception as e: - print(f"Error creating GIF from pattern: {e}") - return None - - def get_recent_sessions( - self, screenshot_dir: str = "~/.scitex/capture" - ) -> List[str]: - """ - Get list of recent monitoring session IDs. - - Args: - screenshot_dir: Directory containing screenshots - - Returns: - List of session IDs sorted by recency (newest first) - """ - try: - screenshot_dir = Path(screenshot_dir).expanduser() - - if not screenshot_dir.exists(): - return [] - - # Find all monitoring session files (format: SESSIONID_NNNN_timestamp.ext) - session_pattern = re.compile(r"^(\d{8}_\d{6})_\d{4}_.*\.(jpg|png)$") - - sessions = set() - for file in screenshot_dir.iterdir(): - if file.is_file(): - match = session_pattern.match(file.name) - if match: - sessions.add(match.group(1)) - - # Sort by session ID (which includes timestamp) - return sorted(sessions, reverse=True) - - except Exception as e: - print(f"Error getting recent sessions: {e}") - return [] - - def create_gif_from_recent_session( - self, - screenshot_dir: str = "~/.scitex/capture", - duration: float = 0.5, - optimize: bool = True, - max_frames: Optional[int] = None, - ) -> Optional[str]: - """ - Create a GIF from the most recent monitoring session. - - Args: - screenshot_dir: Directory containing screenshots - duration: Duration per frame in seconds (default: 0.5) - optimize: Optimize GIF for smaller file size (default: True) - max_frames: Maximum number of frames to include (None = all) - - Returns: - Path to created GIF file, or None if failed - """ - sessions = self.get_recent_sessions(screenshot_dir) - - if not sessions: - print("No monitoring sessions found") - return None - - latest_session = sessions[0] - print(f"Creating GIF from latest session: {latest_session}") - - return self.create_gif_from_session( - session_id=latest_session, - screenshot_dir=screenshot_dir, - duration=duration, - optimize=optimize, - max_frames=max_frames, - ) - - -# Convenience functions for easy usage -def create_gif_from_session(session_id: str, **kwargs) -> Optional[str]: - """Create GIF from monitoring session screenshots.""" - creator = GifCreator() - return creator.create_gif_from_session(session_id, **kwargs) - - -def create_gif_from_files( - image_paths: List[str], output_path: str, **kwargs -) -> Optional[str]: - """Create GIF from list of image files.""" - creator = GifCreator() - return creator.create_gif_from_files(image_paths, output_path, **kwargs) - - -def create_gif_from_pattern(pattern: str, **kwargs) -> Optional[str]: - """Create GIF from files matching glob pattern.""" - creator = GifCreator() - return creator.create_gif_from_pattern(pattern, **kwargs) - - -def create_gif_from_latest_session(**kwargs) -> Optional[str]: - """Create GIF from the most recent monitoring session.""" - creator = GifCreator() - return creator.create_gif_from_recent_session(**kwargs) - - -# EOF diff --git a/src/scitex/capture/grid.py b/src/scitex/capture/grid.py deleted file mode 100755 index 40042d0c0..000000000 --- a/src/scitex/capture/grid.py +++ /dev/null @@ -1,487 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: "2026-01-01 19:50:00 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/grid.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/grid.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - -""" -Grid overlay functionality for screenshot coordinate mapping. - -Provides utilities to draw coordinate grids on screenshots, -helping AI agents understand screen positions for precise targeting. -""" - -from pathlib import Path -from typing import Tuple - - -def draw_grid_overlay( - filepath: str, - grid_spacing: int = 100, - output_path: str = None, - grid_color: Tuple[int, int, int] = (255, 0, 0), - text_color: Tuple[int, int, int] = (255, 255, 0), - line_width: int = 1, - show_coordinates: bool = True, -) -> str: - """ - Draw a coordinate grid overlay on a screenshot image. - - This helps AI agents and users understand screen coordinates for - precise click targeting and UI element identification. - - Parameters - ---------- - filepath : str - Path to the input image - grid_spacing : int - Pixels between grid lines (default: 100) - output_path : str, optional - Output path (default: adds '_grid' suffix to input) - grid_color : tuple - RGB color for grid lines (default: red) - text_color : tuple - RGB color for coordinate labels (default: yellow) - line_width : int - Width of grid lines in pixels (default: 1) - show_coordinates : bool - Whether to show coordinate labels (default: True) - - Returns - ------- - str - Path to the output image with grid overlay - - Examples - -------- - >>> from scitex.capture.grid import draw_grid_overlay - >>> draw_grid_overlay("/path/to/screenshot.jpg") - '/path/to/screenshot_grid.jpg' - """ - try: - from PIL import Image, ImageDraw, ImageFont - except ImportError: - raise ImportError( - "PIL (Pillow) is required for grid overlay. " - "Install with: pip install Pillow" - ) - - # Load image - img = Image.open(filepath) - draw = ImageDraw.Draw(img) - width, height = img.size - - # Try to load a font for coordinate labels - font = None - if show_coordinates: - font = _get_font(size=12) - - # Draw vertical lines - for x in range(0, width, grid_spacing): - draw.line([(x, 0), (x, height)], fill=grid_color, width=line_width) - if show_coordinates and font: - draw.text((x + 2, 10), str(x), fill=text_color, font=font) - - # Draw horizontal lines - for y in range(0, height, grid_spacing): - draw.line([(0, y), (width, y)], fill=grid_color, width=line_width) - if show_coordinates and font: - draw.text((10, y + 2), str(y), fill=text_color, font=font) - - # Generate output path - if output_path is None: - path_obj = Path(filepath) - output_path = str(path_obj.parent / f"{path_obj.stem}_grid{path_obj.suffix}") - - # Save - if filepath.lower().endswith((".jpg", ".jpeg")): - img.save(output_path, "JPEG", quality=85) - else: - img.save(output_path) - - return output_path - - -def _get_font(size: int = 12): - """Get a monospace font for coordinate labels.""" - try: - from PIL import ImageFont - except ImportError: - return None - - # Try common system fonts - font_paths = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", - "/usr/share/fonts/truetype/freefont/FreeMono.ttf", - "C:/Windows/Fonts/consola.ttf", - "C:/Windows/Fonts/cour.ttf", - "/System/Library/Fonts/Monaco.ttf", - "/System/Library/Fonts/Menlo.ttc", - ] - - for font_path in font_paths: - if Path(font_path).exists(): - try: - return ImageFont.truetype(font_path, size) - except: - continue - - # Fallback to default - try: - return ImageFont.load_default() - except: - return None - - -def add_monitor_info_overlay( - filepath: str, - monitor_info: dict, - output_path: str = None, -) -> str: - """ - Add monitor boundary and info overlay to a multi-monitor screenshot. - - Parameters - ---------- - filepath : str - Path to the input image - monitor_info : dict - Dictionary with monitor information from get_info() - output_path : str, optional - Output path (default: adds '_monitors' suffix) - - Returns - ------- - str - Path to the output image with monitor overlay - """ - try: - from PIL import Image, ImageDraw - except ImportError: - raise ImportError("PIL (Pillow) is required") - - img = Image.open(filepath) - draw = ImageDraw.Draw(img) - font = _get_font(size=14) - - # Draw monitor boundaries and labels - monitors = monitor_info.get("Monitors", {}).get("Details", []) - colors = [ - (255, 0, 0), # Red - (0, 255, 0), # Green - (0, 0, 255), # Blue - (255, 255, 0), # Yellow - (255, 0, 255), # Magenta - (0, 255, 255), # Cyan - ] - - for i, mon in enumerate(monitors): - color = colors[i % len(colors)] - x = mon.get("X", 0) - y = mon.get("Y", 0) - w = mon.get("Width", 0) - h = mon.get("Height", 0) - - # Offset for combined image (Y might be negative) - # Find minimum Y to offset - min_y = min(m.get("Y", 0) for m in monitors) if monitors else 0 - offset_y = -min_y if min_y < 0 else 0 - - # Draw rectangle border - rect_y = y + offset_y - draw.rectangle( - [(x, rect_y), (x + w - 1, rect_y + h - 1)], - outline=color, - width=3, - ) - - # Draw label - label = f"Monitor {i}: {w}x{h} @ ({x},{y})" - if mon.get("Primary"): - label += " [PRIMARY]" - - if font: - draw.text((x + 10, rect_y + 10), label, fill=color, font=font) - - # Generate output path - if output_path is None: - path_obj = Path(filepath) - output_path = str( - path_obj.parent / f"{path_obj.stem}_monitors{path_obj.suffix}" - ) - - # Save - if filepath.lower().endswith((".jpg", ".jpeg")): - img.save(output_path, "JPEG", quality=85) - else: - img.save(output_path) - - return output_path - - -def draw_cursor_overlay( - filepath: str, - cursor_pos: Tuple[int, int] = None, - output_path: str = None, - marker_color: Tuple[int, int, int] = (0, 255, 0), - marker_size: int = 20, - show_coords: bool = True, - capture_mode: str = "all", # "all" for all monitors, or monitor index "0", "1", etc. -) -> str: - """ - Draw cursor position marker on a screenshot. - - Helps verify cursor coordinates for UI automation debugging. - - Parameters - ---------- - filepath : str - Path to the input image - cursor_pos : tuple, optional - System coordinates (x, y). If None, gets current cursor position. - output_path : str, optional - Output path (default: adds '_cursor' suffix) - marker_color : tuple - RGB color for cursor marker (default: green) - marker_size : int - Size of the crosshair marker (default: 20) - show_coords : bool - Whether to show coordinate text (default: True) - capture_mode : str - "all" for all-monitor capture, or "0", "1" etc. for specific monitor - - Returns - ------- - str - Path to the output image with cursor overlay - """ - try: - from PIL import Image, ImageDraw - except ImportError: - raise ImportError("PIL (Pillow) is required") - - # Get cursor position if not provided - if cursor_pos is None: - cursor_pos = _get_cursor_position() - if cursor_pos is None: - raise RuntimeError("Could not get cursor position") - - sys_x, sys_y = cursor_pos - - # Get display info to calculate proper offsets - display_info = get_display_info() - monitors = display_info.get("monitors", []) - - # Calculate image coordinate offset based on capture mode - if capture_mode == "all" and monitors: - # For all-monitor capture: offset by the minimum X and Y - min_x = min(m.get("X", 0) for m in monitors) - min_y = min(m.get("Y", 0) for m in monitors) - img_x = sys_x - min_x - img_y = sys_y - min_y - elif monitors and capture_mode.isdigit(): - # For single monitor capture: offset by that monitor's position - mon_idx = int(capture_mode) - if mon_idx < len(monitors): - mon = monitors[mon_idx] - img_x = sys_x - mon.get("X", 0) - img_y = sys_y - mon.get("Y", 0) - else: - img_x, img_y = sys_x, sys_y - else: - # Fallback: no offset - img_x, img_y = sys_x, sys_y - - img = Image.open(filepath) - draw = ImageDraw.Draw(img) - width, height = img.size - - # Find which monitor the cursor is on - cursor_monitor = "?" - for i, mon in enumerate(monitors): - mx, my = mon.get("X", 0), mon.get("Y", 0) - mw, mh = mon.get("Width", 0), mon.get("Height", 0) - if mx <= sys_x < mx + mw and my <= sys_y < my + mh: - cursor_monitor = str(i) - break - - # Check if cursor is within image bounds - if 0 <= img_x < width and 0 <= img_y < height: - # Draw crosshair - half = marker_size // 2 - # Horizontal line - draw.line( - [(img_x - half, img_y), (img_x + half, img_y)], - fill=marker_color, - width=2, - ) - # Vertical line - draw.line( - [(img_x, img_y - half), (img_x, img_y + half)], - fill=marker_color, - width=2, - ) - # Center dot - draw.ellipse( - [(img_x - 3, img_y - 3), (img_x + 3, img_y + 3)], - fill=marker_color, - ) - - # Draw coordinate text with monitor info - if show_coords: - font = _get_font(size=12) - text = f"Mon:{cursor_monitor} Sys:({sys_x},{sys_y}) Img:({img_x},{img_y})" - if font: - draw.text((img_x + 10, img_y + 10), text, fill=marker_color, font=font) - else: - # Cursor outside image - show info at corner - font = _get_font(size=12) - text = f"Outside image: Mon:{cursor_monitor} Sys:({sys_x},{sys_y}) Img:({img_x},{img_y})" - if font: - draw.text((10, height - 30), text, fill=(255, 0, 0), font=font) - - # Generate output path - if output_path is None: - path_obj = Path(filepath) - output_path = str(path_obj.parent / f"{path_obj.stem}_cursor{path_obj.suffix}") - - # Save - if filepath.lower().endswith((".jpg", ".jpeg")): - img.save(output_path, "JPEG", quality=85) - else: - img.save(output_path) - - return output_path - - -def _get_cursor_position() -> Tuple[int, int]: - """Get current cursor position from Windows via PowerShell.""" - import subprocess - - ps_script = """ -Add-Type @" -using System; -using System.Runtime.InteropServices; -public class CursorPos { - [DllImport("user32.dll")] - public static extern bool GetCursorPos(out POINT lpPoint); - [StructLayout(LayoutKind.Sequential)] - public struct POINT { public int X; public int Y; } -} -"@ -$pos = New-Object CursorPos+POINT -[CursorPos]::GetCursorPos([ref]$pos) | Out-Null -Write-Output "$($pos.X),$($pos.Y)" -""" - try: - result = subprocess.run( - ["powershell.exe", "-Command", ps_script], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - parts = result.stdout.strip().split(",") - return (int(parts[0]), int(parts[1])) - except Exception: - pass - return None - - -def _get_dpi_scale() -> float: - """Get Windows DPI scaling factor via PowerShell.""" - import subprocess - - ps_script = """ -Add-Type @" -using System; -using System.Runtime.InteropServices; -public class DpiInfo { - [DllImport("user32.dll")] - public static extern IntPtr GetDC(IntPtr hwnd); - [DllImport("gdi32.dll")] - public static extern int GetDeviceCaps(IntPtr hdc, int nIndex); - [DllImport("user32.dll")] - public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc); - public const int LOGPIXELSX = 88; -} -"@ -$hdc = [DpiInfo]::GetDC([IntPtr]::Zero) -$dpi = [DpiInfo]::GetDeviceCaps($hdc, 88) -[DpiInfo]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null -$scale = $dpi / 96.0 -Write-Output $scale -""" - try: - result = subprocess.run( - ["powershell.exe", "-Command", ps_script], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - return float(result.stdout.strip()) - except Exception: - pass - return 1.0 - - -def get_display_info() -> dict: - """Get comprehensive display info including DPI, resolution, monitors.""" - import subprocess - - ps_script = """ -Add-Type -AssemblyName System.Windows.Forms -$screens = [System.Windows.Forms.Screen]::AllScreens -$info = @() -foreach ($s in $screens) { - $info += @{ - Name = $s.DeviceName - Primary = $s.Primary - X = $s.Bounds.X - Y = $s.Bounds.Y - Width = $s.Bounds.Width - Height = $s.Bounds.Height - } -} -$info | ConvertTo-Json -Compress -""" - try: - result = subprocess.run( - ["powershell.exe", "-Command", ps_script], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - import json - - monitors = json.loads(result.stdout.strip()) - if not isinstance(monitors, list): - monitors = [monitors] - dpi_scale = _get_dpi_scale() - return { - "monitors": monitors, - "dpi_scale": dpi_scale, - "dpi_percent": int(dpi_scale * 100), - } - except Exception: - pass - return {"monitors": [], "dpi_scale": 1.0, "dpi_percent": 100} - - -__all__ = [ - "draw_grid_overlay", - "add_monitor_info_overlay", - "draw_cursor_overlay", - "get_display_info", -] - -# EOF diff --git a/src/scitex/capture/mcp_server.py b/src/scitex/capture/mcp_server.py deleted file mode 100755 index e66468749..000000000 --- a/src/scitex/capture/mcp_server.py +++ /dev/null @@ -1,991 +0,0 @@ -#!/usr/bin/env python3 -# Timestamp: "2025-10-17 03:24:58 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/mcp_server.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/mcp_server.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - -"""MCP Server for SciTeX Capture - Screen Capture for Python. - -.. deprecated:: - This standalone server is deprecated. Use the unified scitex MCP server: - CLI: scitex serve - Python: from scitex.mcp_server import run_server - -Provides screenshot capture capabilities via Model Context Protocol. -""" - -import warnings - -warnings.warn( - "scitex.capture.mcp_server is deprecated. Use 'scitex serve' or " - "'from scitex.mcp_server import run_server' for the unified MCP server.", - DeprecationWarning, - stacklevel=2, -) - -import asyncio -import base64 -from datetime import datetime -from pathlib import Path - -# Graceful MCP dependency handling -try: - import mcp.types as types - from mcp.server import NotificationOptions, Server - from mcp.server.models import InitializationOptions - from mcp.server.stdio import stdio_server - - MCP_AVAILABLE = True -except ImportError: - MCP_AVAILABLE = False - types = None # type: ignore - Server = None # type: ignore - NotificationOptions = None # type: ignore - InitializationOptions = None # type: ignore - stdio_server = None # type: ignore - -# Directory configuration -import os -import shutil - -from scitex import capture - -# Use SCITEX_DIR environment variable if set, otherwise default to ~/.scitex -SCITEX_BASE_DIR = Path(os.getenv("SCITEX_DIR", Path.home() / ".scitex")) -SCITEX_CAPTURE_DIR = SCITEX_BASE_DIR / "capture" -LEGACY_CAPTURE_DIR = Path.home() / ".cache" / "cammy" - - -def get_capture_dir() -> Path: - """ - Get the screenshot capture directory. - Uses $SCITEX_DIR/capture if SCITEX_DIR is set, otherwise ~/.scitex/capture. - Migrates from legacy location (~/.cache/cammy) if needed. - - Returns: - Path to $SCITEX_DIR/capture or ~/.scitex/capture (migrating from ~/.cache/cammy if needed) - """ - new_dir = SCITEX_CAPTURE_DIR - old_dir = LEGACY_CAPTURE_DIR - - # Create new directory if it doesn't exist - new_dir.mkdir(parents=True, exist_ok=True) - - # Migrate from old location if exists and new is empty - if old_dir.exists(): - new_screenshots = list(new_dir.glob("*.jpg")) - if not new_screenshots or len(new_screenshots) == 0: - # Move files from old to new location - try: - for img in old_dir.glob("*.jpg"): - shutil.move(str(img), str(new_dir / img.name)) - print(f"Migrated screenshots from {old_dir} to {new_dir}") - except Exception as e: - print(f"Warning: Could not migrate some files: {e}") - - return new_dir - - -class CaptureServer: - def __init__(self): - self.server = Server("scitex-capture-server") - self.monitoring_active = False - self.monitoring_worker = None - self.setup_handlers() - - def setup_handlers(self): - @self.server.list_tools() - async def handle_list_tools(): - return [ - types.Tool( - name="capture_screenshot", - description="Capture screenshot - monitor, window, browser, or everything including Windows screens from WSL", - inputSchema={ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Optional message to include in filename", - }, - "monitor_id": { - "type": "integer", - "description": "Monitor number (0-based, default: 0 for primary monitor)", - "default": 0, - }, - "all": { - "type": "boolean", - "description": "Capture all monitors (shorthand)", - "default": False, - }, - "app": { - "type": "string", - "description": "App name to capture (e.g., 'chrome', 'code')", - }, - "url": { - "type": "string", - "description": "URL to capture (e.g., '127.0.0.1:8000' or 'http://localhost:3000')", - }, - "quality": { - "type": "integer", - "description": "JPEG quality (1-100, default: 85)", - "minimum": 1, - "maximum": 100, - "default": 85, - }, - "return_base64": { - "type": "boolean", - "description": "Return screenshot as base64 string", - "default": False, - }, - }, - }, - ), - types.Tool( - name="start_monitoring", - description="Start continuous screenshot monitoring at regular intervals", - inputSchema={ - "type": "object", - "properties": { - "interval": { - "type": "number", - "description": "Seconds between captures (default: 1.0)", - "minimum": 0.1, - "default": 1.0, - }, - "monitor_id": { - "type": "integer", - "description": "Monitor number (0-based, default: 0 for primary monitor)", - "default": 0, - }, - "capture_all": { - "type": "boolean", - "description": "Capture all monitors combined into single image (overrides monitor_id)", - "default": False, - }, - "output_dir": { - "type": "string", - "description": "Directory for screenshots (default: ~/.scitex/capture)", - }, - "quality": { - "type": "integer", - "description": "JPEG quality (1-100, default: 60)", - "minimum": 1, - "maximum": 100, - "default": 60, - }, - "verbose": { - "type": "boolean", - "description": "Show capture messages", - "default": True, - }, - }, - }, - ), - types.Tool( - name="stop_monitoring", - description="Stop continuous screenshot monitoring", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="get_monitoring_status", - description="Get current monitoring status and statistics", - inputSchema={"type": "object", "properties": {}}, - ), - types.Tool( - name="analyze_screenshot", - description="Analyze a screenshot for error indicators (stdout/stderr categorization)", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to screenshot to analyze", - } - }, - "required": ["path"], - }, - ), - types.Tool( - name="list_recent_screenshots", - description="List recent screenshots from cache", - inputSchema={ - "type": "object", - "properties": { - "limit": { - "type": "integer", - "description": "Maximum number of screenshots to list", - "default": 10, - "minimum": 1, - "maximum": 100, - }, - "category": { - "type": "string", - "description": "Filter by category (stdout/stderr)", - "enum": ["stdout", "stderr", "all"], - "default": "all", - }, - }, - }, - ), - types.Tool( - name="clear_cache", - description="Clear screenshot cache or manage cache size", - inputSchema={ - "type": "object", - "properties": { - "max_size_gb": { - "type": "number", - "description": "Keep cache under this size in GB (removes oldest files)", - "minimum": 0.001, - "default": 1.0, - }, - "clear_all": { - "type": "boolean", - "description": "Remove all cached screenshots", - "default": False, - }, - }, - }, - ), - types.Tool( - name="create_gif", - description="Create an animated GIF from screenshots to summarize sessions or workflows", - inputSchema={ - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Session ID to create GIF from (e.g., '20250823_104523'). Use 'latest' for most recent session.", - }, - "image_paths": { - "type": "array", - "items": {"type": "string"}, - "description": "List of image file paths to create GIF from (alternative to session_id)", - }, - "pattern": { - "type": "string", - "description": "Glob pattern for images to include (alternative to session_id/image_paths)", - }, - "output_path": { - "type": "string", - "description": "Output GIF file path (auto-generated if not specified)", - }, - "duration": { - "type": "number", - "description": "Duration per frame in seconds (default: 0.5)", - "minimum": 0.1, - "maximum": 5.0, - "default": 0.5, - }, - "optimize": { - "type": "boolean", - "description": "Optimize GIF for smaller file size (default: true)", - "default": True, - }, - "max_frames": { - "type": "integer", - "description": "Maximum number of frames to include (default: no limit)", - "minimum": 1, - "maximum": 100, - }, - }, - }, - ), - types.Tool( - name="list_sessions", - description="List available monitoring sessions that can be converted to GIFs", - inputSchema={ - "type": "object", - "properties": { - "limit": { - "type": "integer", - "description": "Maximum number of sessions to list (default: 10)", - "minimum": 1, - "maximum": 50, - "default": 10, - } - }, - }, - ), - types.Tool( - name="get_info", - description="Enumerate all monitors, virtual desktops, and visible windows", - inputSchema={ - "type": "object", - "properties": {}, - }, - ), - types.Tool( - name="list_windows", - description="List all visible windows with their handles and process names", - inputSchema={ - "type": "object", - "properties": {}, - }, - ), - types.Tool( - name="capture_window", - description="Capture a specific window by its handle", - inputSchema={ - "type": "object", - "properties": { - "window_handle": { - "type": "integer", - "description": "Window handle from list_windows", - }, - "output_path": { - "type": "string", - "description": "Optional output path for screenshot", - }, - "quality": { - "type": "integer", - "description": "JPEG quality (1-100, default: 85)", - "minimum": 1, - "maximum": 100, - "default": 85, - }, - }, - "required": ["window_handle"], - }, - ), - ] - - @self.server.call_tool() - async def handle_call_tool(name: str, arguments: dict): - if name == "capture_screenshot": - return await self.capture_screenshot(**arguments) - elif name == "start_monitoring": - return await self.start_monitoring(**arguments) - elif name == "stop_monitoring": - return await self.stop_monitoring() - elif name == "get_monitoring_status": - return await self.get_monitoring_status() - elif name == "analyze_screenshot": - return await self.analyze_screenshot(**arguments) - elif name == "list_recent_screenshots": - return await self.list_recent_screenshots(**arguments) - elif name == "clear_cache": - return await self.clear_cache(**arguments) - elif name == "create_gif": - return await self.create_gif(**arguments) - elif name == "list_sessions": - return await self.list_sessions(**arguments) - elif name == "get_info": - return await self.get_info_tool() - elif name == "list_windows": - return await self.list_windows_tool() - elif name == "capture_window": - return await self.capture_window_tool(**arguments) - else: - raise ValueError(f"Unknown tool: {name}") - - # Provide screenshots as resources - @self.server.list_resources() - async def handle_list_resources(): - cache_dir = get_capture_dir() - if not cache_dir.exists(): - return [] - - resources = [] - # Get last 20 screenshots, sorted by modification time - screenshots = sorted( - cache_dir.glob("*.jpg"), - key=lambda p: p.stat().st_mtime, - reverse=True, - )[:20] - - for img_file in screenshots: - # Parse category from filename - category = ( - "stdout" - if "-stdout.jpg" in img_file.name - else ("stderr" if "-stderr.jpg" in img_file.name else "unknown") - ) - - mtime = datetime.fromtimestamp(img_file.stat().st_mtime) - resources.append( - types.Resource( - uri=f"screenshot://{img_file.name}", - name=img_file.name, - description=f"{category} screenshot from {mtime.strftime('%Y-%m-%d %H:%M:%S')}", - mimeType="image/jpeg", - ) - ) - return resources - - @self.server.read_resource() - async def handle_read_resource(uri: str): - if uri.startswith("screenshot://"): - filename = uri.replace("screenshot://", "") - filepath = get_capture_dir() / filename - - if filepath.exists(): - with open(filepath, "rb") as f: - content = base64.b64encode(f.read()).decode() - - return types.ResourceContent( - uri=uri, mimeType="image/jpeg", content=content - ) - else: - raise ValueError(f"Screenshot not found: {filename}") - - async def capture_screenshot( - self, - message=None, - monitor_id=0, - all=False, - app=None, - url=None, - quality=85, - return_base64=False, - ): - """Capture a screenshot - monitor, window, browser, or everything.""" - try: - # Run in thread pool since capture is sync - loop = asyncio.get_event_loop() - - # Use capture.snap which now handles all, app, url parameters - def do_capture(): - return capture.snap( - message=message, - quality=quality, - monitor_id=monitor_id, - all=all, - app=app, - url=url, - verbose=True, - ) - - path = await loop.run_in_executor(None, do_capture) - - if not path: - return { - "success": False, - "error": "Failed to capture screenshot", - } - - # Determine category from filename - category = "stderr" if "-stderr.jpg" in path else "stdout" - - result = { - "success": True, - "path": path, - "category": category, - "message": f"Screenshot saved to {path}", - "timestamp": datetime.now().isoformat(), - } - - if return_base64 and path: - with open(path, "rb") as f: - result["base64"] = base64.b64encode(f.read()).decode() - - return result - - except Exception as e: - return {"success": False, "error": str(e)} - - async def start_monitoring( - self, - interval=1.0, - monitor_id=0, - capture_all=False, - output_dir=None, - quality=60, - verbose=True, - ): - """Start continuous monitoring.""" - if self.monitoring_active: - return {"success": False, "message": "Monitoring already active"} - - try: - loop = asyncio.get_event_loop() - - # Use a lambda to pass the monitor parameters correctly - def start_with_monitor(): - return capture.start_monitor( - output_dir=output_dir or "~/.scitex/capture/", - interval=interval, - jpeg=True, - quality=quality, - on_capture=None, - on_error=None, - verbose=verbose, - monitor_id=monitor_id, - capture_all=capture_all, - ) - - self.monitoring_worker = await loop.run_in_executor( - None, start_with_monitor - ) - - self.monitoring_active = True - - return { - "success": True, - "message": f"Started monitoring with {interval}s interval on monitor {monitor_id}", - "output_dir": output_dir or "~/.scitex/capture/", - "interval": interval, - "monitor_id": monitor_id, - "capture_all": capture_all, - "quality": quality, - } - except Exception as e: - return {"success": False, "error": str(e)} - - async def stop_monitoring(self): - """Stop continuous monitoring.""" - if not self.monitoring_active: - return {"success": False, "message": "Monitoring not active"} - - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, capture.stop) - - # Get stats from worker - stats = {} - if self.monitoring_worker: - stats = { - "screenshots_taken": self.monitoring_worker.screenshot_count, - "session_id": self.monitoring_worker.session_id, - "output_dir": str(self.monitoring_worker.output_dir), - } - - self.monitoring_active = False - self.monitoring_worker = None - - return {"success": True, "message": "Monitoring stopped", **stats} - except Exception as e: - return {"success": False, "error": str(e)} - - async def get_monitoring_status(self): - """Get monitoring status.""" - status = { - "active": self.monitoring_active, - "cache_dir": str(get_capture_dir()), - } - - if self.monitoring_active and self.monitoring_worker: - status.update( - { - "screenshots_taken": self.monitoring_worker.screenshot_count, - "session_id": self.monitoring_worker.session_id, - "interval": self.monitoring_worker.interval_sec, - "output_dir": str(self.monitoring_worker.output_dir), - "running": self.monitoring_worker.running, - } - ) - - # Get cache size - cache_dir = get_capture_dir() - if cache_dir.exists(): - total_size = sum(f.stat().st_size for f in cache_dir.glob("*.jpg")) - status["cache_size_mb"] = round(total_size / (1024 * 1024), 2) - status["screenshot_count"] = len(list(cache_dir.glob("*.jpg"))) - - return status - - async def analyze_screenshot(self, path: str): - """Analyze screenshot for errors/warnings.""" - try: - # Use scitex.capture's internal detection - from .utils import _detect_category - - loop = asyncio.get_event_loop() - category = await loop.run_in_executor(None, _detect_category, path) - - # Get file info - path_obj = Path(path) - if not path_obj.exists(): - return {"success": False, "error": f"File not found: {path}"} - - return { - "success": True, - "path": path, - "category": category, - "is_error": category == "stderr", - "size_kb": round(path_obj.stat().st_size / 1024, 2), - "modified": datetime.fromtimestamp( - path_obj.stat().st_mtime - ).isoformat(), - } - except Exception as e: - return {"success": False, "error": str(e)} - - async def list_recent_screenshots(self, limit=10, category="all"): - """List recent screenshots from cache.""" - try: - cache_dir = get_capture_dir() - if not cache_dir.exists(): - return { - "success": True, - "screenshots": [], - "message": "Cache directory does not exist", - } - - # Get all screenshots - screenshots = list(cache_dir.glob("*.jpg")) - - # Filter by category if specified - if category == "stdout": - screenshots = [s for s in screenshots if "-stdout.jpg" in s.name] - elif category == "stderr": - screenshots = [s for s in screenshots if "-stderr.jpg" in s.name] - - # Sort by modification time (newest first) - screenshots.sort(key=lambda p: p.stat().st_mtime, reverse=True) - - # Limit results - screenshots = screenshots[:limit] - - # Build result - result_list = [] - for screenshot in screenshots: - cat = "stderr" if "-stderr.jpg" in screenshot.name else "stdout" - result_list.append( - { - "filename": screenshot.name, - "path": str(screenshot), - "category": cat, - "size_kb": round(screenshot.stat().st_size / 1024, 2), - "modified": datetime.fromtimestamp( - screenshot.stat().st_mtime - ).isoformat(), - } - ) - - return { - "success": True, - "screenshots": result_list, - "count": len(result_list), - "total_in_cache": len(list(cache_dir.glob("*.jpg"))), - } - except Exception as e: - return {"success": False, "error": str(e)} - - async def clear_cache(self, max_size_gb=1.0, clear_all=False): - """Clear or manage cache size.""" - try: - cache_dir = get_capture_dir() - if not cache_dir.exists(): - return { - "success": True, - "message": "Cache directory does not exist", - } - - if clear_all: - # Remove all screenshots - removed = 0 - for screenshot in cache_dir.glob("*.jpg"): - try: - screenshot.unlink() - removed += 1 - except: - pass - - return { - "success": True, - "message": f"Removed {removed} screenshots", - "removed_count": removed, - } - else: - # Use scitex.capture's cache management - from .utils import _manage_cache_size - - loop = asyncio.get_event_loop() - await loop.run_in_executor( - None, _manage_cache_size, cache_dir, max_size_gb - ) - - # Get new cache size - total_size = sum(f.stat().st_size for f in cache_dir.glob("*.jpg")) - - return { - "success": True, - "message": f"Cache managed to stay under {max_size_gb}GB", - "cache_size_mb": round(total_size / (1024 * 1024), 2), - "max_size_gb": max_size_gb, - } - except Exception as e: - return {"success": False, "error": str(e)} - - async def create_gif( - self, - session_id=None, - image_paths=None, - pattern=None, - output_path=None, - duration=0.5, - optimize=True, - max_frames=None, - ): - """Create GIF from screenshots.""" - try: - from .gif import GifCreator - - creator = GifCreator() - loop = asyncio.get_event_loop() - - # Determine which creation method to use - if session_id: - if session_id == "latest": - # Use most recent session - result_path = await loop.run_in_executor( - None, - creator.create_gif_from_recent_session, - "~/.scitex/capture", - duration, - optimize, - max_frames, - ) - else: - # Use specific session - result_path = await loop.run_in_executor( - None, - creator.create_gif_from_session, - session_id, - output_path, - "~/.scitex/capture", - duration, - optimize, - max_frames, - ) - elif image_paths: - # Use specific image paths - if not output_path: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - output_path = f"~/.scitex/capture/custom_gif_{timestamp}.gif" - - result_path = await loop.run_in_executor( - None, - creator.create_gif_from_files, - image_paths, - output_path, - duration, - optimize, - ) - elif pattern: - # Use glob pattern - result_path = await loop.run_in_executor( - None, - creator.create_gif_from_pattern, - pattern, - output_path, - duration, - optimize, - max_frames, - ) - else: - return { - "success": False, - "error": "Must specify either session_id, image_paths, or pattern", - } - - if result_path: - # Get file info - path_obj = Path(result_path) - file_size = path_obj.stat().st_size / 1024 # KB - - return { - "success": True, - "path": result_path, - "size_kb": round(file_size, 2), - "message": f"GIF created successfully: {result_path}", - "duration_per_frame": duration, - "optimized": optimize, - } - else: - return { - "success": False, - "error": "Failed to create GIF - no suitable images found", - } - - except ImportError: - return { - "success": False, - "error": "PIL (Pillow) is required for GIF creation. Install with: pip install Pillow", - } - except Exception as e: - return {"success": False, "error": str(e)} - - async def list_sessions(self, limit=10): - """List available monitoring sessions.""" - try: - from .gif import GifCreator - - creator = GifCreator() - loop = asyncio.get_event_loop() - - sessions = await loop.run_in_executor( - None, creator.get_recent_sessions, "~/.scitex/capture" - ) - - # Limit results - sessions = sessions[:limit] - - # Get details for each session - session_details = [] - cache_dir = get_capture_dir() - - for session_id in sessions: - # Count screenshots in session - jpg_files = list(cache_dir.glob(f"{session_id}_*.jpg")) - png_files = list(cache_dir.glob(f"{session_id}_*.png")) - - if not jpg_files and not png_files: - continue - - files = jpg_files + png_files - files.sort() - - if files: - # Get session info - first_file = files[0] - last_file = files[-1] - total_size = sum(f.stat().st_size for f in files) - - session_details.append( - { - "session_id": session_id, - "screenshot_count": len(files), - "first_screenshot": first_file.name, - "last_screenshot": last_file.name, - "total_size_kb": round(total_size / 1024, 2), - "start_time": datetime.fromtimestamp( - first_file.stat().st_mtime - ).isoformat(), - "end_time": datetime.fromtimestamp( - last_file.stat().st_mtime - ).isoformat(), - } - ) - - return { - "success": True, - "sessions": session_details, - "count": len(session_details), - "message": f"Found {len(session_details)} monitoring sessions", - } - - except Exception as e: - return {"success": False, "error": str(e)} - - async def get_info_tool(self): - """Enumerate all monitors and virtual desktops.""" - try: - loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, capture.get_info) - - return { - "success": True, - "monitors": info.get("Monitors", {}), - "virtual_desktops": info.get("VirtualDesktops", {}), - "windows": info.get("Windows", {}), - "timestamp": info.get("Timestamp", ""), - } - except Exception as e: - return {"success": False, "error": str(e)} - - async def list_windows_tool(self): - """List all visible windows.""" - try: - loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, capture.get_info) - - windows = info.get("Windows", {}) - window_list = windows.get("Details", []) - - # Format for easy use - formatted_windows = [] - for win in window_list: - formatted_windows.append( - { - "handle": win.get("Handle"), - "title": win.get("Title"), - "process_name": win.get("ProcessName"), - "process_id": win.get("ProcessId"), - } - ) - - return { - "success": True, - "windows": formatted_windows, - "count": len(formatted_windows), - "visible_count": windows.get("VisibleCount", 0), - "message": f"Found {len(formatted_windows)} windows on current virtual desktop", - } - except Exception as e: - return {"success": False, "error": str(e)} - - async def capture_window_tool( - self, window_handle: int, output_path: str = None, quality: int = 85 - ): - """Capture a specific window by handle.""" - try: - loop = asyncio.get_event_loop() - path = await loop.run_in_executor( - None, capture.capture_window, window_handle, output_path - ) - - if path: - return { - "success": True, - "path": path, - "window_handle": window_handle, - "message": f"Window captured to {path}", - } - else: - return { - "success": False, - "error": f"Failed to capture window {window_handle}", - } - except Exception as e: - return {"success": False, "error": str(e)} - - -async def _run_server(): - """Run the MCP server (internal).""" - server = CaptureServer() - async with stdio_server() as (read_stream, write_stream): - await server.server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="scitex-capture", - server_version="0.2.1", - capabilities=server.server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -def main(): - """Main entry point for the MCP server.""" - if not MCP_AVAILABLE: - import sys - - print("=" * 60) - print("MCP Server 'scitex-capture' requires the 'mcp' package.") - print() - print("Install with:") - print(" pip install mcp") - print() - print("Or install scitex with MCP support:") - print(" pip install scitex[mcp]") - print("=" * 60) - sys.exit(1) - - asyncio.run(_run_server()) - - -if __name__ == "__main__": - main() - -# EOF diff --git a/src/scitex/capture/powershell/capture_all_desktops.ps1 b/src/scitex/capture/powershell/capture_all_desktops.ps1 deleted file mode 100644 index 28beb07de..000000000 --- a/src/scitex/capture/powershell/capture_all_desktops.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -# Capture screenshots from all virtual desktops in Windows 10/11 -# This uses Windows.Forms to capture each monitor (virtual desktops typically span monitors) - -param( - [Parameter(Mandatory=$false)] - [string]$OutputFormat = "base64" # "base64" or "file" -) - -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -# Enable DPI awareness for high-resolution capture -Add-Type @' -using System; -using System.Runtime.InteropServices; -public class User32 { - [DllImport("user32.dll")] - public static extern bool SetProcessDPIAware(); -} -'@ -$null = [User32]::SetProcessDPIAware() - -# Get all screens (monitors) -$screens = [System.Windows.Forms.Screen]::AllScreens - -$results = @() - -foreach ($screen in $screens) { - try { - # Create bitmap for this screen - $bounds = $screen.Bounds - $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) - - # Set high quality rendering - $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality - $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic - $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality - $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality - - # Capture from screen - $graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bitmap.Size) - - # Convert to base64 - $stream = New-Object System.IO.MemoryStream - $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) - $bytes = $stream.ToArray() - $base64 = [Convert]::ToBase64String($bytes) - - $results += @{ - DeviceName = $screen.DeviceName - IsPrimary = $screen.Primary - Bounds = @{ - X = $bounds.X - Y = $bounds.Y - Width = $bounds.Width - Height = $bounds.Height - } - Base64Data = $base64 - } - - # Cleanup - $graphics.Dispose() - $bitmap.Dispose() - $stream.Dispose() - } - catch { - Write-Error "Failed to capture screen $($screen.DeviceName): $_" - } -} - -# Output as JSON -$output = @{ - TotalScreens = $results.Count - Screens = $results - Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") -} - -$output | ConvertTo-Json -Depth 4 diff --git a/src/scitex/capture/powershell/capture_all_monitors.ps1 b/src/scitex/capture/powershell/capture_all_monitors.ps1 deleted file mode 100644 index 2d7e0ca27..000000000 --- a/src/scitex/capture/powershell/capture_all_monitors.ps1 +++ /dev/null @@ -1,63 +0,0 @@ -param( - [Parameter(Mandatory=$false)] - [string]$OutputFormat = "base64" # "base64" or "file" -) - -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -# Enable DPI awareness for proper high-resolution capture -Add-Type @' -using System; -using System.Runtime.InteropServices; -public class User32 { - [DllImport("user32.dll")] - public static extern bool SetProcessDPIAware(); -} -'@ - -# Set process DPI aware -$null = [User32]::SetProcessDPIAware() - -# Get virtual screen (all monitors combined) -$virtualScreen = [System.Windows.Forms.SystemInformation]::VirtualScreen - -# Create bitmap for entire virtual screen -$bitmap = New-Object System.Drawing.Bitmap $virtualScreen.Width, $virtualScreen.Height -$graphics = [System.Drawing.Graphics]::FromImage($bitmap) - -# Set high quality rendering -$graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality -$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic -$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality -$graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality - -# Copy from entire virtual screen -$graphics.CopyFromScreen($virtualScreen.X, $virtualScreen.Y, 0, 0, $virtualScreen.Size) - -# Output based on format -if ($OutputFormat -eq "base64") { - # Convert to base64 for easy transfer to WSL - $stream = New-Object System.IO.MemoryStream - $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) - $bytes = $stream.ToArray() - [Convert]::ToBase64String($bytes) - $stream.Dispose() -} else { - # Save to file (for testing/debugging) - $file = "$env:TEMP\screenshot_all_$(Get-Date -Format 'yyyyMMdd_HHmmss').png" - $bitmap.Save($file, [System.Drawing.Imaging.ImageFormat]::Png) - - # Get monitor info for display - $screens = [System.Windows.Forms.Screen]::AllScreens - Write-Host "Captured $($screens.Count) monitor(s):" - foreach ($screen in $screens) { - $bounds = $screen.Bounds - Write-Host " - $($screen.DeviceName): $($bounds.Width)x$($bounds.Height) at ($($bounds.X), $($bounds.Y))" - } - Write-Output $file -} - -# Cleanup -$graphics.Dispose() -$bitmap.Dispose() diff --git a/src/scitex/capture/powershell/capture_single_monitor.ps1 b/src/scitex/capture/powershell/capture_single_monitor.ps1 deleted file mode 100644 index e7cff5682..000000000 --- a/src/scitex/capture/powershell/capture_single_monitor.ps1 +++ /dev/null @@ -1,77 +0,0 @@ -param( - [Parameter(Mandatory=$false)] - [int]$MonitorNumber = 0, # 0-based index from Python - - [Parameter(Mandatory=$false)] - [string]$OutputFormat = "base64" # "base64" or "file" -) - -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -# Enable DPI awareness for proper high-resolution capture -Add-Type @' -using System; -using System.Runtime.InteropServices; -public class User32 { - [DllImport("user32.dll")] - public static extern bool SetProcessDPIAware(); - - [DllImport("shcore.dll")] - public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); - - [DllImport("user32.dll")] - public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags); -} -'@ - -# Set process DPI aware -$null = [User32]::SetProcessDPIAware() - -# Get all screens -$screens = [System.Windows.Forms.Screen]::AllScreens - -# Monitor number (0-based index from Python) -$monitorIndex = $MonitorNumber - -# Check if monitor exists -if ($monitorIndex -ge $screens.Count -or $monitorIndex -lt 0) { - Write-Error "Monitor $MonitorNumber not found. Valid range: 0-$(($screens.Count - 1)). $($screens.Count) monitor(s) available." - exit 1 -} - -# Get the specified monitor -$targetScreen = $screens[$monitorIndex] -$bounds = $targetScreen.Bounds - -# Create bitmap with screen dimensions -$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height -$graphics = [System.Drawing.Graphics]::FromImage($bitmap) - -# Set high quality rendering -$graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality -$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic -$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality -$graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality - -# Copy from screen -$graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size) - -# Output based on format -if ($OutputFormat -eq "base64") { - # Convert to base64 for easy transfer to WSL - $stream = New-Object System.IO.MemoryStream - $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) - $bytes = $stream.ToArray() - [Convert]::ToBase64String($bytes) - $stream.Dispose() -} else { - # Save to file (for testing/debugging) - $file = "$env:TEMP\screenshot_$(Get-Date -Format 'yyyyMMdd_HHmmss').png" - $bitmap.Save($file, [System.Drawing.Imaging.ImageFormat]::Png) - Write-Output $file -} - -# Cleanup -$graphics.Dispose() -$bitmap.Dispose() diff --git a/src/scitex/capture/powershell/capture_url.ps1 b/src/scitex/capture/powershell/capture_url.ps1 deleted file mode 100644 index 85c41e581..000000000 --- a/src/scitex/capture/powershell/capture_url.ps1 +++ /dev/null @@ -1,169 +0,0 @@ -# Capture URL screenshot by opening browser, capturing window, then closing -# Works with Windows host URLs from WSL with proper rendering - -param( - [Parameter(Mandatory=$true)] - [string]$Url, - - [Parameter(Mandatory=$false)] - [int]$WaitSeconds = 3, - - [Parameter(Mandatory=$false)] - [string]$OutputFormat = "base64", - - [Parameter(Mandatory=$false)] - [int]$WindowWidth = 1920, - - [Parameter(Mandatory=$false)] - [int]$WindowHeight = 1080 -) - -Add-Type -AssemblyName System.Drawing -Add-Type -AssemblyName System.Windows.Forms - -Add-Type @" -using System; -using System.Runtime.InteropServices; -using System.Text; - -public class WindowHelper { - [DllImport("user32.dll")] - public static extern bool SetProcessDPIAware(); - - [DllImport("user32.dll")] - public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); - - [DllImport("user32.dll")] - public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - - [DllImport("user32.dll")] - public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags); - - [DllImport("user32.dll")] - public static extern bool SetForegroundWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); - - [DllImport("user32.dll")] - public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); - - [DllImport("user32.dll")] - public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); - - [DllImport("user32.dll")] - public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); - - [StructLayout(LayoutKind.Sequential)] - public struct RECT { - public int Left; - public int Top; - public int Right; - public int Bottom; - } -} -"@ - -$null = [WindowHelper]::SetProcessDPIAware() - -try { - # Open URL in default browser - $process = Start-Process $Url -PassThru - - # Wait for page to load and render - Start-Sleep -Seconds $WaitSeconds - - # Find the browser window - $browserHandle = $null - $browserProcesses = @('chrome', 'msedge', 'firefox', 'brave') - - foreach ($browserName in $browserProcesses) { - $processes = Get-Process -Name $browserName -ErrorAction SilentlyContinue - if ($processes) { - # Get the most recently active window - foreach ($proc in $processes) { - if ($proc.MainWindowHandle -ne [IntPtr]::Zero) { - $browserHandle = $proc.MainWindowHandle - break - } - } - if ($browserHandle) { break } - } - } - - if (-not $browserHandle) { - throw "Browser window not found" - } - - # Resize window to specified dimensions for consistent layout - # SW_RESTORE = 9 (restore if minimized/maximized) - [WindowHelper]::ShowWindow($browserHandle, 9) | Out-Null - Start-Sleep -Milliseconds 200 - - # Move and resize window (X=0, Y=0, Width, Height, Repaint=true) - [WindowHelper]::MoveWindow($browserHandle, 0, 0, $WindowWidth, $WindowHeight, $true) | Out-Null - Start-Sleep -Milliseconds 300 - - # Bring browser to foreground for better capture - [WindowHelper]::SetForegroundWindow($browserHandle) | Out-Null - Start-Sleep -Milliseconds 500 - - # Get window rectangle - $rect = New-Object WindowHelper+RECT - [WindowHelper]::GetWindowRect($browserHandle, [ref]$rect) | Out-Null - - $width = $rect.Right - $rect.Left - $height = $rect.Bottom - $rect.Top - - if ($width -le 0 -or $height -le 0) { - throw "Invalid window size: ${width}x${height}" - } - - # Capture window with proper rendering - $bitmap = New-Object System.Drawing.Bitmap($width, $height) - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) - - # Set high quality - $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality - $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic - - $hdc = $graphics.GetHdc() - - # PW_RENDERFULLCONTENT = 2 - [WindowHelper]::PrintWindow($browserHandle, $hdc, 2) | Out-Null - - $graphics.ReleaseHdc($hdc) - - # Convert to base64 - $stream = New-Object System.IO.MemoryStream - $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) - $bytes = $stream.ToArray() - $base64 = [Convert]::ToBase64String($bytes) - - $result = @{ - Success = $true - Url = $Url - Width = $bitmap.Width - Height = $bitmap.Height - Base64Data = $base64 - Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - } - - $result | ConvertTo-Json -Depth 2 -Compress - - # Cleanup - $webBrowser.Dispose() - $bitmap.Dispose() - $stream.Dispose() - -} catch { - $errorResult = @{ - Success = $false - Url = $Url - Error = $_.Exception.Message - Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - } - - $errorResult | ConvertTo-Json -Depth 2 -Compress - exit 1 -} diff --git a/src/scitex/capture/powershell/capture_window_by_handle.ps1 b/src/scitex/capture/powershell/capture_window_by_handle.ps1 deleted file mode 100644 index 2614463b6..000000000 --- a/src/scitex/capture/powershell/capture_window_by_handle.ps1 +++ /dev/null @@ -1,129 +0,0 @@ -# Capture a specific window by its handle -# Allows selective capture of individual application windows - -param( - [Parameter(Mandatory=$true)] - [long]$WindowHandle, - - [Parameter(Mandatory=$false)] - [string]$OutputFormat = "base64" # "base64" or "file" -) - -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -# Enable DPI awareness -Add-Type @' -using System; -using System.Runtime.InteropServices; - -public class WindowCapture { - [DllImport("user32.dll")] - public static extern bool SetProcessDPIAware(); - - [DllImport("user32.dll")] - public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - - [DllImport("user32.dll")] - public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags); - - [DllImport("user32.dll")] - public static extern bool IsWindowVisible(IntPtr hWnd); - - [DllImport("user32.dll")] - public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount); - - [StructLayout(LayoutKind.Sequential)] - public struct RECT { - public int Left; - public int Top; - public int Right; - public int Bottom; - } -} -'@ - -$null = [WindowCapture]::SetProcessDPIAware() - -try { - $hWnd = [IntPtr]$WindowHandle - - # Check if window is visible - if (-not [WindowCapture]::IsWindowVisible($hWnd)) { - Write-Error "Window is not visible or does not exist" - exit 1 - } - - # Get window rectangle - $rect = New-Object WindowCapture+RECT - if (-not [WindowCapture]::GetWindowRect($hWnd, [ref]$rect)) { - Write-Error "Failed to get window rectangle" - exit 1 - } - - # Calculate dimensions - $width = $rect.Right - $rect.Left - $height = $rect.Bottom - $rect.Top - - if ($width -le 0 -or $height -le 0) { - Write-Error "Invalid window dimensions: ${width}x${height}" - exit 1 - } - - # Get window title - $sb = New-Object System.Text.StringBuilder(256) - [WindowCapture]::GetWindowText($hWnd, $sb, $sb.Capacity) | Out-Null - $windowTitle = $sb.ToString() - - # Create bitmap - $bitmap = New-Object System.Drawing.Bitmap($width, $height) - $graphics = [System.Drawing.Graphics]::FromImage($bitmap) - $hdc = $graphics.GetHdc() - - # Capture window - $success = [WindowCapture]::PrintWindow($hWnd, $hdc, 2) # 2 = PW_RENDERFULLCONTENT - - $graphics.ReleaseHdc($hdc) - - if (-not $success) { - Write-Error "PrintWindow failed" - $graphics.Dispose() - $bitmap.Dispose() - exit 1 - } - - # Convert to base64 - $stream = New-Object System.IO.MemoryStream - $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) - $bytes = $stream.ToArray() - $base64 = [Convert]::ToBase64String($bytes) - - # Output result - $result = @{ - WindowHandle = $WindowHandle - WindowTitle = $windowTitle - Width = $width - Height = $height - Success = $true - Base64Data = $base64 - Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - } - - $result | ConvertTo-Json -Depth 3 -Compress - - # Cleanup - $graphics.Dispose() - $bitmap.Dispose() - $stream.Dispose() - -} catch { - $errorResult = @{ - WindowHandle = $WindowHandle - Success = $false - Error = $_.Exception.Message - Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") - } - - $errorResult | ConvertTo-Json -Depth 2 -Compress - exit 1 -} diff --git a/src/scitex/capture/powershell/detect_monitors_and_desktops.ps1 b/src/scitex/capture/powershell/detect_monitors_and_desktops.ps1 deleted file mode 100644 index 2fa0b7c46..000000000 --- a/src/scitex/capture/powershell/detect_monitors_and_desktops.ps1 +++ /dev/null @@ -1,143 +0,0 @@ -# Detect Monitors and Virtual Desktops in Windows -# Returns comprehensive information about physical monitors and virtual desktop state - -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.Drawing - -# Get all physical monitors -$screens = [System.Windows.Forms.Screen]::AllScreens -$monitorInfo = @() - -foreach ($screen in $screens) { - $bounds = $screen.Bounds - $monitorInfo += @{ - DeviceName = $screen.DeviceName - IsPrimary = $screen.Primary - Bounds = @{ - X = $bounds.X - Y = $bounds.Y - Width = $bounds.Width - Height = $bounds.Height - } - WorkingArea = @{ - X = $screen.WorkingArea.X - Y = $screen.WorkingArea.Y - Width = $screen.WorkingArea.Width - Height = $screen.WorkingArea.Height - } - BitsPerPixel = $screen.BitsPerPixel - } -} - -# Virtual Desktop detection (Windows 10/11) -# Note: Virtual Desktop API is not officially exposed via PowerShell -# This uses a workaround to detect virtual desktops through Task View -Add-Type @" -using System; -using System.Runtime.InteropServices; -using System.Text; - -public class VirtualDesktopDetector { - [DllImport("user32.dll")] - public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); - - [DllImport("user32.dll")] - public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam); - - [DllImport("user32.dll")] - public static extern bool IsWindowVisible(IntPtr hWnd); - - [DllImport("user32.dll")] - public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); - - [DllImport("user32.dll")] - public static extern int GetWindowTextLength(IntPtr hWnd); - - [DllImport("user32.dll", SetLastError = true)] - public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); - - public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); -} -"@ - -# Count visible windows (proxy for virtual desktop activity) -$visibleWindows = 0 -$windowList = @() - -$callback = { - param($hWnd, $lParam) - - if ([VirtualDesktopDetector]::IsWindowVisible($hWnd)) { - $length = [VirtualDesktopDetector]::GetWindowTextLength($hWnd) - if ($length -gt 0) { - $sb = New-Object System.Text.StringBuilder($length + 1) - [VirtualDesktopDetector]::GetWindowText($hWnd, $sb, $sb.Capacity) | Out-Null - $title = $sb.ToString() - - if ($title -and $title -ne "") { - $processId = 0 - [VirtualDesktopDetector]::GetWindowThreadProcessId($hWnd, [ref]$processId) | Out-Null - - $processName = "Unknown" - try { - $process = Get-Process -Id $processId -ErrorAction SilentlyContinue - if ($process) { - $processName = $process.ProcessName - } - } catch { - # Process may have exited, use Unknown - } - - # Always add window, even if process name unknown - $script:windowList += @{ - Handle = $hWnd.ToInt64() - Title = $title - ProcessId = $processId - ProcessName = $processName - } - $script:visibleWindows++ - } - } - } - return $true -} - -[VirtualDesktopDetector]::EnumWindows($callback, [IntPtr]::Zero) - -# Check if Task View / Virtual Desktop feature is enabled -$taskViewEnabled = $false -$virtualDesktopCount = 1 # Default to 1 (always have at least one desktop) - -try { - # Try to detect virtual desktop support through registry - $regPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced" - $taskViewKey = Get-ItemProperty -Path $regPath -Name "TaskView" -ErrorAction SilentlyContinue - if ($taskViewKey) { - $taskViewEnabled = $true - } -} catch { - # Virtual desktop support detection failed -} - -# Build output -$result = @{ - Monitors = @{ - Count = $monitorInfo.Count - Details = $monitorInfo - PrimaryMonitor = ($monitorInfo | Where-Object { $_.IsPrimary -eq $true }).DeviceName - } - VirtualDesktops = @{ - Supported = $taskViewEnabled - EstimatedCount = $virtualDesktopCount - Note = "Windows does not officially expose Virtual Desktop count via PowerShell. Count represents minimum (current desktop)." - } - Windows = @{ - VisibleCount = $visibleWindows - TotalEnumerated = $windowList.Count - Details = $windowList - } - Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") -} - -# Output as JSON -$result | ConvertTo-Json -Depth 5 -Compress diff --git a/src/scitex/capture/powershell/enumerate_virtual_desktops.ps1 b/src/scitex/capture/powershell/enumerate_virtual_desktops.ps1 deleted file mode 100644 index 9c785c4f6..000000000 --- a/src/scitex/capture/powershell/enumerate_virtual_desktops.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -# Enumerate Windows Virtual Desktops -# This script detects and lists all virtual desktops in Windows 10/11 - -Add-Type @" -using System; -using System.Runtime.InteropServices; - -public class VirtualDesktop { - [DllImport("user32.dll")] - public static extern IntPtr GetDesktopWindow(); - - [DllImport("user32.dll")] - public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam); - - [DllImport("user32.dll")] - public static extern bool IsWindowVisible(IntPtr hWnd); - - [DllImport("user32.dll")] - public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount); - - [DllImport("user32.dll")] - public static extern int GetWindowTextLength(IntPtr hWnd); - - public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); -} -"@ - -# Get all visible windows -$windows = @() -$callback = { - param($hWnd, $lParam) - - if ([VirtualDesktop]::IsWindowVisible($hWnd)) { - $length = [VirtualDesktop]::GetWindowTextLength($hWnd) - if ($length -gt 0) { - $sb = New-Object System.Text.StringBuilder($length + 1) - [VirtualDesktop]::GetWindowText($hWnd, $sb, $sb.Capacity) | Out-Null - $title = $sb.ToString() - - if ($title) { - $windows += @{ - Handle = $hWnd.ToInt64() - Title = $title - } - } - } - } - return $true -} - -[VirtualDesktop]::EnumWindows($callback, [IntPtr]::Zero) - -# Output as JSON for easy parsing -$result = @{ - TotalWindows = $windows.Count - Windows = $windows - Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") -} - -$result | ConvertTo-Json -Depth 3 diff --git a/src/scitex/capture/session.py b/src/scitex/capture/session.py deleted file mode 100755 index e39bb811a..000000000 --- a/src/scitex/capture/session.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Timestamp: "2025-10-18 09:55:53 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/session.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/session.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - - -class Session: - """Context manager for CAM session with automatic start/stop.""" - - def __init__( - self, - output_dir: str = "~/.scitex/capture/", - interval: float = 1.0, - jpeg: bool = True, - quality: int = 60, - on_capture=None, - on_error=None, - verbose: bool = True, - monitor_id: int = 0, - capture_all: bool = False, - ): - """Initialize session parameters.""" - self.output_dir = output_dir - self.interval = interval - self.jpeg = jpeg - self.quality = quality - self.on_capture = on_capture - self.on_error = on_error - self.verbose = verbose - self.monitor_id = monitor_id - self.capture_all = capture_all - self.worker = None - - def __enter__(self): - """Start monitoring when entering context.""" - from .utils import start_monitor - - self.worker = start_monitor( - output_dir=self.output_dir, - interval=self.interval, - jpeg=self.jpeg, - quality=self.quality, - on_capture=self.on_capture, - on_error=self.on_error, - verbose=self.verbose, - monitor_id=self.monitor_id, - capture_all=self.capture_all, - ) - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Stop monitoring when exiting context.""" - from .utils import stop_monitor - - stop_monitor() - return False - - -def session(**kwargs): - """Create a new session context manager.""" - return Session(**kwargs) - - -# EOF diff --git a/src/scitex/capture/utils.py b/src/scitex/capture/utils.py deleted file mode 100755 index bdcb51516..000000000 --- a/src/scitex/capture/utils.py +++ /dev/null @@ -1,671 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# Timestamp: "2025-10-18 09:55:52 (ywatanabe)" -# File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/utils.py -# ---------------------------------------- -from __future__ import annotations - -import os - -__FILE__ = "./src/scitex/capture/utils.py" -__DIR__ = os.path.dirname(__FILE__) -# ---------------------------------------- - -import sys - -""" -Utility functions for easy screen capture. -""" - -from datetime import datetime -from pathlib import Path -from typing import Optional - -from .capture import CaptureManager, ScreenshotWorker - -# Global manager instance -_manager = CaptureManager() - - -def _manage_cache_size(cache_dir: Path, max_size_gb: float = 1.0): - """ - Manage cache directory size by removing old files if size exceeds limit. - - Parameters - ---------- - cache_dir : Path - Directory to manage - max_size_gb : float - Maximum size in GB (default: 1.0) - """ - if not cache_dir.exists(): - return - - max_size_bytes = max_size_gb * 1024 * 1024 * 1024 # Convert GB to bytes - - # Get all files with their sizes and modification times - files = [] - total_size = 0 - - for file_path in cache_dir.glob("*.jpg"): - if file_path.is_file(): - size = file_path.stat().st_size - mtime = file_path.stat().st_mtime - files.append((file_path, size, mtime)) - total_size += size - - # Also check PNG files - for file_path in cache_dir.glob("*.png"): - if file_path.is_file(): - size = file_path.stat().st_size - mtime = file_path.stat().st_mtime - files.append((file_path, size, mtime)) - total_size += size - - # If under limit, nothing to do - if total_size <= max_size_bytes: - return - - # Sort by modification time (oldest first) - files.sort(key=lambda x: x[2]) - - # Remove oldest files until under limit - for file_path, size, _ in files: - if total_size <= max_size_bytes: - break - try: - file_path.unlink() - total_size -= size - except: - pass # File might be in use - - -def capture( - message: str = None, - path: str = None, - quality: int = 85, - auto_categorize: bool = True, - verbose: bool = True, - monitor_id: int = 0, - capture_all: bool = False, - all: bool = False, - app: str = None, - url: str = None, - url_wait: int = 3, - url_width: int = 1920, - url_height: int = 1080, - max_cache_gb: float = 1.0, -) -> str: - """ - Take a screenshot - monitor, window, browser, or everything. - - Parameters - ---------- - message : str, optional - Message to include in filename - path : str, optional - Output path (default: ~/.scitex/capture/) - quality : int - JPEG quality (1-100) - all : bool - Capture all monitors - app : str, optional - App name to capture (e.g., "chrome", "code") - url : str, optional - URL to capture via browser (e.g., "http://127.0.0.1:8000/") - url_wait : int - Seconds to wait for page load (default: 3) - url_width : int - Browser window width for URL capture (default: 1920) - url_height : int - Browser window height for URL capture (default: 1080) - monitor_id : int - Monitor to capture (0-based, default: 0) - - Returns - ------- - str - Path to saved screenshot - - Examples - -------- - >>> from scitex import capture - >>> - >>> capture.snap() # Current monitor - >>> capture.snap(all=True) # All monitors - >>> capture.snap(app="chrome") # Chrome window - >>> capture.snap(url="http://localhost:8000") # Browser page - """ - # Handle URL capture - if url: - # Auto-add http:// if no protocol specified - if not url.startswith(("http://", "https://", "file://")): - url = f"http://{url}" - - # Try Playwright first (headless, non-interfering) - try: - from playwright.sync_api import sync_playwright - - if path is None: - timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") - url_slug = ( - url.replace("://", "_").replace("/", "_").replace(":", "_")[:30] - ) - path = f"~/.scitex/capture/{timestamp_str}-url-{url_slug}.jpg" - - path = os.path.expanduser(path) - - if verbose: - print(f"📸 Capturing URL: {url}") - - # Check if DISPLAY is set (WSL with X11 forward causes visible browser) - import os as _os - - original_display = _os.environ.get("DISPLAY") - - # Force headless by unsetting DISPLAY temporarily - if original_display: - _os.environ.pop("DISPLAY", None) - - try: - with sync_playwright() as p: - # Use stealth args from scitex.browser - stealth_args = [ - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-blink-features=AutomationControlled", - "--window-size=1920,1080", - ] - browser = p.chromium.launch(headless=True, args=stealth_args) - context = browser.new_context( - viewport={"width": url_width, "height": url_height} - ) - page = context.new_page() - # Use domcontentloaded for faster capture, with longer timeout - page.goto(url, wait_until="domcontentloaded", timeout=30000) - # Wait additional time for rendering - page.wait_for_timeout(url_wait * 1000) - page.screenshot( - path=path, - type="jpeg", - quality=quality, - full_page=False, - ) - browser.close() - finally: - # Restore DISPLAY - if original_display: - _os.environ["DISPLAY"] = original_display - - if Path(path).exists(): - if verbose: - print(f"📸 URL: {path}") - return path - - except ImportError: - if verbose: - print( - "⚠️ Playwright not installed: pip install 'scitex[capture-browser]'" - ) - pass # Try PowerShell fallback - except Exception as e: - if verbose: - print(f"⚠️ Playwright failed: {e}") - pass # Try PowerShell fallback - - # For WSL: Fallback to Windows-side browser - if sys.platform == "linux" and "microsoft" in os.uname().release.lower(): - try: - import base64 - import json - import subprocess - - # Generate output path - if path is None: - timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") - url_slug = ( - url.replace("://", "_").replace("/", "_").replace(":", "_")[:30] - ) - path = f"~/.scitex/capture/{timestamp_str}-url-{url_slug}.jpg" - - path = os.path.expanduser(path) - - if verbose: - print(f"📸 Capturing URL on Windows: {url}") - - # Use PowerShell script on Windows host - script_dir = Path(__file__).parent / "powershell" - script_path = script_dir / "capture_url.ps1" - - if script_path.exists(): - # Find PowerShell - ps_paths = [ - "powershell.exe", - "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", - ] - ps_exe = None - for p in ps_paths: - try: - result = subprocess.run( - [p, "-Command", "echo test"], - capture_output=True, - timeout=1, - ) - if result.returncode == 0: - ps_exe = p - break - except: - continue - - if ps_exe: - cmd = [ - ps_exe, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script_path), - "-Url", - url, - "-WaitSeconds", - str(url_wait), - "-WindowWidth", - str(url_width), - "-WindowHeight", - str(url_height), - ] - - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=30 - ) - - if result.returncode == 0 and result.stdout.strip(): - # Parse JSON - lines = result.stdout.strip().split("\n") - for line in lines: - if line.strip().startswith("{"): - data = json.loads(line) - if data.get("Success"): - img_data = base64.b64decode( - data.get("Base64Data", "") - ) - - # Save as JPEG - try: - import io - - from PIL import Image - - img = Image.open(io.BytesIO(img_data)) - if img.mode == "RGBA": - rgb_img = Image.new( - "RGB", - img.size, - (255, 255, 255), - ) - rgb_img.paste(img, mask=img.split()[3]) - img = rgb_img - img.save( - path, - "JPEG", - quality=quality, - optimize=True, - ) - - if verbose: - print(f"📸 URL: {path}") - return path - except ImportError: - # Save as PNG fallback - with open( - path.replace(".jpg", ".png"), - "wb", - ) as f: - f.write(img_data) - return path.replace(".jpg", ".png") - break - - except Exception as e: - if verbose: - print(f"⚠️ PowerShell URL capture failed: {e}") - - # If all methods failed - if verbose: - print( - "❌ URL capture failed - Playwright not available and PowerShell failed" - ) - return None - - # Handle app-specific capture - if app: - info = _manager.get_info() - windows = info.get("Windows", {}).get("Details", []) - - # Search for matching window - app_lower = app.lower() - matching_window = None - - for win in windows: - process_name = win.get("ProcessName", "").lower() - title = win.get("Title", "").lower() - - if app_lower in process_name or app_lower in title: - matching_window = win - break - - if matching_window: - handle = matching_window.get("Handle") - result_path = _manager.capture_window(handle, path) - - if result_path and verbose: - print(f"📸 {matching_window.get('ProcessName')}: {result_path}") - - return result_path - else: - if verbose: - print(f"❌ App '{app}' not found in visible windows") - return None - - # Handle 'all' shorthand - if all: - capture_all = True - - # Take screenshot first to analyze it - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] - temp_dir = "/tmp/scitex_capture_temp" - Path(temp_dir).mkdir(exist_ok=True) - - # Take screenshot to temp location - use_jpeg = ( - True if path is None or path.lower().endswith((".jpg", ".jpeg")) else False - ) - worker = ScreenshotWorker( - output_dir=temp_dir, - use_jpeg=use_jpeg, - jpeg_quality=quality, - verbose=verbose, # Use the verbose parameter passed by user - ) - - worker.session_id = "capture" - worker.screenshot_count = 0 - worker.monitor = monitor_id - worker.capture_all = capture_all - temp_path = worker._take_screenshot() - - if not temp_path: - return None - - # Detect category if auto_categorize enabled - category = "stdout" - if auto_categorize: - # First check if we're in an exception context - if _is_in_exception_context(): - category = "stderr" - # Add exception info to message - import traceback - - exc_info = traceback.format_exc(limit=3) - if message: - message = f"{message}\n{exc_info}" - else: - message = exc_info - else: - # Try visual detection - category = _detect_category(temp_path) - - # Build monitor/scope tag for filename - scope_tag = "" - if capture_all: - scope_tag = "-all-monitors" - elif monitor_id > 0: - scope_tag = f"-monitor{monitor_id}" - # monitor_id=0 (primary) gets no tag for cleaner default names - - # Normalize message for filename - normalized_msg = "" - if message: - # Remove special chars, keep only alphanumeric and spaces - import re - - normalized = re.sub(r"[^\w\s-]", "", message.split("\n")[0]) # First line only - normalized = re.sub(r"[-\s]+", "-", normalized).strip("-") - normalized_msg = f"-{normalized[:50]}" if normalized else "" # Limit length - - # Add category suffix - category_suffix = f"-{category}" - - # Handle path with category and message - if path is None: - # Include monitor/scope info in filename - path = f"~/.scitex/capture/.jpg" - - # Expand user home - path = os.path.expanduser(path) - - # Replace placeholders - if "" in path: - path = path.replace("", timestamp) - if "" in path: - path = path.replace("", scope_tag) - if "" in path: - path = path.replace("", normalized_msg) - if "" in path: - path = path.replace("", category_suffix) - - # Ensure directory exists - output_dir = Path(path).parent - output_dir.mkdir(parents=True, exist_ok=True) - - # Move to final location - final_path = Path(path) - Path(temp_path).rename(final_path) - - # Add message with category as metadata - if message or category != "stdout": - metadata = ( - f"[{category.upper()}] {message}" if message else f"[{category.upper()}]" - ) - _add_message_metadata(str(final_path), metadata) - - # Manage cache size (remove old files if needed) - cache_dir = Path(os.path.expanduser("~/.scitex/capture")) - if cache_dir.exists(): - _manage_cache_size(cache_dir, max_cache_gb) - - # Print path for user feedback (useful in interactive sessions) - final_path_str = str(final_path) - if verbose: - try: - if category == "stderr": - print(f"📸 stderr: {final_path_str}") - else: - print(f"📸 stdout: {final_path_str}") - except: - # In case print fails in some environments - pass - - return final_path_str - - -def take_screenshot( - output_path: str = None, jpeg: bool = True, quality: int = 85 -) -> Optional[str]: - """ - Take a single screenshot (simple interface). - - Parameters - ---------- - output_path : str, optional - Where to save the screenshot - jpeg : bool - Use JPEG format (True) or PNG (False) - quality : int - JPEG quality (1-100) - - Returns - ------- - str or None - Path to saved screenshot - """ - return _manager.take_single_screenshot(output_path, jpeg, quality) - - -def start_monitor( - output_dir: str = "~/.scitex/capture/", - interval: float = 1.0, - jpeg: bool = True, - quality: int = 60, - on_capture=None, - on_error=None, - verbose: bool = True, - monitor_id: int = 0, - capture_all: bool = False, -) -> ScreenshotWorker: - """ - Start continuous screenshot monitoring. - - Parameters - ---------- - output_dir : str - Directory for screenshots (default: ~/.scitex/capture/) - interval : float - Seconds between captures - jpeg : bool - Use JPEG compression - quality : int - JPEG quality (1-100) - on_capture : callable, optional - Function called with filepath after each capture - on_error : callable, optional - Function called with exception on errors - verbose : bool - Print status messages - monitor_id : int - Monitor number to capture (0-based index, default: 0 for primary monitor) - capture_all : bool - If True, capture all monitors combined (default: False) - - Returns - ------- - ScreenshotWorker - The worker instance - - Examples - -------- - >>> # Simple monitoring - >>> capture.start() - - >>> # With event hooks - >>> capture.start( - ... on_capture=lambda path: print(f"Saved: {path}"), - ... on_error=lambda e: logging.error(e) - ... ) - - >>> # Detect specific screen content - >>> def check_error_dialog(path): - ... if "error" in analyze_image(path): - ... send_alert(f"Error detected: {path}") - >>> capture.start(on_capture=check_error_dialog) - """ - # Expand user home directory - output_dir = os.path.expanduser(output_dir) - - return _manager.start_capture( - output_dir=output_dir, - interval=interval, - jpeg=jpeg, - quality=quality, - on_capture=on_capture, - on_error=on_error, - verbose=verbose, - monitor_id=monitor_id, - capture_all=capture_all, - ) - - -def stop_monitor(): - """Stop continuous screenshot monitoring.""" - _manager.stop_capture() - - -def _is_in_exception_context() -> bool: - """ - Check if we're currently in an exception handler. - """ - import sys - - # Check if there's an active exception - exc_info = sys.exc_info() - return exc_info[0] is not None - - -def _detect_category(filepath: str) -> str: - """ - Detect screenshot category based on content. - Simple heuristic based on common error indicators. - """ - try: - # Try OCR-based detection if available - from PIL import Image - - img = Image.open(filepath) - - # Simple color-based heuristics - # Red dominant = likely error - # Yellow/orange dominant = likely warning - pixels = img.convert("RGB").getdata() - red_count = sum(1 for r, g, b in pixels if r > 200 and g < 100 and b < 100) - yellow_count = sum(1 for r, g, b in pixels if r > 200 and g > 150 and b < 100) - - total_pixels = len(pixels) - red_ratio = red_count / total_pixels if total_pixels > 0 else 0 - yellow_ratio = yellow_count / total_pixels if total_pixels > 0 else 0 - - # Thresholds for detection - if red_ratio > 0.05: # More than 5% red pixels - return "error" - elif yellow_ratio > 0.05: # More than 5% yellow pixels - return "warning" - - except: - pass - - # Check filename for common error keywords - filename_lower = str(filepath).lower() - if any(word in filename_lower for word in ["error", "fail", "exception", "crash"]): - return "stderr" - elif any(word in filename_lower for word in ["warn", "alert", "caution"]): - return "stderr" # Warnings also go to stderr - - return "stdout" - - -def _add_message_metadata(filepath: str, message: str): - """Add message as metadata to image file.""" - try: - # Try to add EXIF comment using PIL - from PIL import Image - - img = Image.open(filepath) - - # Add comment to image metadata - exif = img.getexif() - exif[0x9286] = message # UserComment EXIF tag - - # Save with metadata - img.save(filepath, exif=exif) - except: - # If PIL not available, create companion text file - text_path = Path(filepath).with_suffix(".txt") - text_path.write_text(f"{datetime.now().isoformat()}: {message}\n") - - -# Convenience exports -__all__ = [ - "capture", - "take_screenshot", - "start_monitor", - "stop_monitor", -] - -# EOF diff --git a/tests/scitex/capture/_mcp/test_capture_handlers.py b/tests/scitex/capture/_mcp/test_capture_handlers.py deleted file mode 100755 index efc8364dd..000000000 --- a/tests/scitex/capture/_mcp/test_capture_handlers.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for capture MCP handlers.""" - -import pytest - - -class TestCaptureScreenshotHandler: - """Tests for capture_screenshot_handler.""" - - @pytest.mark.asyncio - async def test_capture_screenshot_returns_dict(self): - """Test that handler returns dict with success key.""" - from scitex.capture._mcp.handlers import capture_screenshot_handler - - result = await capture_screenshot_handler(return_base64=False) - assert isinstance(result, dict) - assert "success" in result - - @pytest.mark.asyncio - async def test_capture_screenshot_with_base64(self): - """Test base64 return option.""" - from scitex.capture._mcp.handlers import capture_screenshot_handler - - result = await capture_screenshot_handler(return_base64=True) - assert isinstance(result, dict) - assert "success" in result - - -class TestMonitoringHandlers: - """Tests for monitoring-related handlers.""" - - @pytest.mark.asyncio - async def test_get_monitoring_status(self): - """Test monitoring status handler.""" - from scitex.capture._mcp.handlers import get_monitoring_status_handler - - result = await get_monitoring_status_handler() - assert isinstance(result, dict) - assert "success" in result - - @pytest.mark.asyncio - async def test_stop_monitoring_when_not_running(self): - """Test stop monitoring when not running.""" - from scitex.capture._mcp.handlers import stop_monitoring_handler - - result = await stop_monitoring_handler() - assert isinstance(result, dict) - - -class TestListRecentScreenshotsHandler: - """Tests for list_recent_screenshots_handler.""" - - @pytest.mark.asyncio - async def test_list_recent_screenshots_default(self): - """Test listing screenshots with defaults.""" - from scitex.capture._mcp.handlers import list_recent_screenshots_handler - - result = await list_recent_screenshots_handler() - assert isinstance(result, dict) - assert "success" in result - - @pytest.mark.asyncio - async def test_list_recent_screenshots_with_limit(self): - """Test listing screenshots with custom limit.""" - from scitex.capture._mcp.handlers import list_recent_screenshots_handler - - result = await list_recent_screenshots_handler(limit=5) - assert isinstance(result, dict) - assert "success" in result - - -class TestListSessionsHandler: - """Tests for list_sessions_handler.""" - - @pytest.mark.asyncio - async def test_list_sessions(self): - """Test listing monitoring sessions.""" - from scitex.capture._mcp.handlers import list_sessions_handler - - result = await list_sessions_handler(limit=10) - assert isinstance(result, dict) - assert "success" in result - - -class TestGetInfoHandler: - """Tests for get_info_handler.""" - - @pytest.mark.asyncio - async def test_get_info(self): - """Test getting system info.""" - from scitex.capture._mcp.handlers import get_info_handler - - result = await get_info_handler() - assert isinstance(result, dict) - assert "success" in result - - -if __name__ == "__main__": - import os - - pytest.main([os.path.abspath(__file__), "-v"]) diff --git a/tests/scitex/capture/test___main__.py b/tests/scitex/capture/test___main__.py deleted file mode 100644 index 61880300d..000000000 --- a/tests/scitex/capture/test___main__.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.capture.__main__ module. - -Tests the entry point for python -m scitex.capture: -- Module imports -- Main function accessibility -- Module execution -""" - -import os -import subprocess -import sys -from unittest.mock import MagicMock, patch - -import pytest - - -class TestModuleImports: - """Test module import functionality.""" - - def test_module_importable(self): - """Test __main__ module can be imported.""" - from scitex.capture import __main__ - - assert __main__ is not None - - def test_main_accessible(self): - """Test main function is accessible from module.""" - from scitex.capture.__main__ import main - - assert callable(main) - - def test_main_is_from_cli(self): - """Test main function is imported from cli module.""" - from scitex.capture.__main__ import main - from scitex.capture.cli import main as cli_main - - assert main is cli_main - - -class TestModuleExecution: - """Test module execution as script.""" - - def test_module_runnable_with_help(self): - """Test module can be run with python -m and --help.""" - result = subprocess.run( - [sys.executable, "-m", "scitex.capture", "--help"], - capture_output=True, - text=True, - timeout=10, - ) - - assert result.returncode == 0 - assert "usage" in result.stdout.lower() or "help" in result.stdout.lower() - - def test_module_execution_calls_main(self): - """Test module execution calls main function.""" - from scitex.capture import __main__ - - with patch.object(__main__, "main", return_value=0) as mock_main: - # Simulate running as __main__ - with patch.object(__main__, "__name__", "__main__"): - # The actual execution happens at import time, - # so we test the structure - assert hasattr(__main__, "main") - assert callable(__main__.main) - - def test_module_returns_main_exit_code(self): - """Test module passes main's return value to sys.exit.""" - # Test by running actual subprocess with mocked capture - result = subprocess.run( - [sys.executable, "-m", "scitex.capture", "--stop"], - capture_output=True, - text=True, - timeout=10, - ) - - # --stop should return 0 (success) - assert result.returncode == 0 - - -class TestModuleIntegration: - """Test module integration with CLI.""" - - def test_help_output_contains_capture_commands(self): - """Test help output contains expected capture commands.""" - result = subprocess.run( - [sys.executable, "-m", "scitex.capture", "--help"], - capture_output=True, - text=True, - timeout=10, - ) - - # Check for common capture-related options - help_text = result.stdout.lower() - assert "--list" in help_text or "list" in help_text - assert "--info" in help_text or "info" in help_text - - def test_list_action_via_module(self): - """Test --list action works via module.""" - with patch("scitex.capture.get_info") as mock_info: - mock_info.return_value = {"Windows": {"Details": []}} - - result = subprocess.run( - [sys.executable, "-m", "scitex.capture", "--list"], - capture_output=True, - text=True, - timeout=10, - ) - - # May succeed or fail depending on WSL state, but should not crash - assert result.returncode in [0, 1] - - def test_info_action_via_module(self): - """Test --info action works via module.""" - result = subprocess.run( - [sys.executable, "-m", "scitex.capture", "--info"], - capture_output=True, - text=True, - timeout=10, - ) - - # May succeed or fail depending on WSL state, but should not crash - assert result.returncode in [0, 1] - - -class TestModuleAttributes: - """Test module-level attributes.""" - - def test_module_has_file_attribute(self): - """Test module has __FILE__ attribute.""" - from scitex.capture import __main__ - - assert hasattr(__main__, "__FILE__") - - def test_module_has_dir_attribute(self): - """Test module has __DIR__ attribute.""" - from scitex.capture import __main__ - - assert hasattr(__main__, "__DIR__") - - def test_module_docstring_exists(self): - """Test module has a docstring.""" - from scitex.capture import __main__ - - # The docstring is in the source but may not be __doc__ - # Check the source structure is correct - assert __main__ is not None - - -if __name__ == "__main__": - import os - - import pytest - - pytest.main([os.path.abspath(__file__)]) - -# -------------------------------------------------------------------------------- -# Start of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/__main__.py -# -------------------------------------------------------------------------------- -# #!/usr/bin/env python3 -# # -*- coding: utf-8 -*- -# # Timestamp: "2025-10-18 09:55:55 (ywatanabe)" -# # File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/__main__.py -# # ---------------------------------------- -# from __future__ import annotations -# import os -# -# __FILE__ = "./src/scitex/capture/__main__.py" -# __DIR__ = os.path.dirname(__FILE__) -# # ---------------------------------------- -# -# """ -# Entry point for python -m scitex.capture -# """ -# -# import sys -# -# from .cli import main -# -# if __name__ == "__main__": -# sys.exit(main()) -# -# # EOF - -# -------------------------------------------------------------------------------- -# End of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/__main__.py -# -------------------------------------------------------------------------------- diff --git a/tests/scitex/capture/test_capture.py b/tests/scitex/capture/test_capture.py deleted file mode 100644 index 28b3c2f3b..000000000 --- a/tests/scitex/capture/test_capture.py +++ /dev/null @@ -1,1302 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.capture.capture module. - -Tests core screenshot capture functionality including: -- ScreenshotWorker initialization and configuration -- Worker lifecycle (start/stop) -- Status reporting -- CaptureManager high-level interface -""" - -import os -import sys -import tempfile -import threading -import time -from pathlib import Path -from unittest.mock import MagicMock, PropertyMock, patch - -import pytest - - -class TestScreenshotWorkerInit: - """Test ScreenshotWorker initialization.""" - - def test_default_initialization(self): - """Test worker initializes with default parameters.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - - assert worker.output_dir == Path(tmpdir) - assert worker.interval_sec == 1.0 - assert worker.verbose is False - assert worker.use_jpeg is True - assert worker.jpeg_quality == 60 - assert worker.running is False - assert worker.worker_thread is None - assert worker.screenshot_count == 0 - assert worker.session_id is None - assert worker.monitor == 0 - assert worker.capture_all is False - - def test_custom_initialization(self): - """Test worker initializes with custom parameters.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - on_capture = lambda x: None - on_error = lambda x: None - - worker = ScreenshotWorker( - output_dir=tmpdir, - interval_sec=2.5, - verbose=True, - use_jpeg=False, - jpeg_quality=90, - on_capture=on_capture, - on_error=on_error, - ) - - assert worker.interval_sec == 2.5 - assert worker.verbose is True - assert worker.use_jpeg is False - assert worker.jpeg_quality == 90 - assert worker.on_capture is on_capture - assert worker.on_error is on_error - - def test_creates_output_directory(self): - """Test worker creates output directory if it doesn't exist.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - nested_dir = os.path.join(tmpdir, "nested", "deep", "dir") - assert not os.path.exists(nested_dir) - - worker = ScreenshotWorker(output_dir=nested_dir) - - assert os.path.exists(nested_dir) - assert worker.output_dir == Path(nested_dir) - - -class TestScreenshotWorkerLifecycle: - """Test ScreenshotWorker start/stop lifecycle.""" - - def test_start_sets_running_state(self): - """Test start() sets running state correctly.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - - # Mock _take_screenshot to avoid actual capture - worker._take_screenshot = MagicMock(return_value=None) - - assert worker.running is False - worker.start() - assert worker.running is True - assert worker.session_id is not None - assert worker.worker_thread is not None - assert worker.worker_thread.is_alive() - - worker.stop() - assert worker.running is False - - def test_start_with_custom_session_id(self): - """Test start() uses provided session_id.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - worker._take_screenshot = MagicMock(return_value=None) - - worker.start(session_id="my_custom_session") - assert worker.session_id == "my_custom_session" - worker.stop() - - def test_start_generates_session_id_from_timestamp(self): - """Test start() generates session_id from timestamp when not provided.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - worker._take_screenshot = MagicMock(return_value=None) - - worker.start() - # Session ID format: YYYYMMDD_HHMMSS - assert len(worker.session_id) == 15 - assert "_" in worker.session_id - worker.stop() - - def test_stop_when_not_running(self): - """Test stop() is safe when worker not running.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - # Should not raise - worker.stop() - assert worker.running is False - - def test_double_start_is_idempotent(self): - """Test calling start() twice doesn't create duplicate threads.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - worker._take_screenshot = MagicMock(return_value=None) - - worker.start() - first_thread = worker.worker_thread - first_session = worker.session_id - - worker.start() # Second call - assert worker.worker_thread is first_thread - assert worker.session_id == first_session - - worker.stop() - - def test_worker_thread_is_daemon(self): - """Test worker thread runs as daemon.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - worker._take_screenshot = MagicMock(return_value=None) - - worker.start() - assert worker.worker_thread.daemon is True - worker.stop() - - -class TestScreenshotWorkerStatus: - """Test ScreenshotWorker status reporting.""" - - def test_get_status_returns_all_fields(self): - """Test get_status() returns complete status dict.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker( - output_dir=tmpdir, interval_sec=2.0, use_jpeg=True, jpeg_quality=75 - ) - - status = worker.get_status() - - assert "running" in status - assert "session_id" in status - assert "screenshot_count" in status - assert "output_dir" in status - assert "interval_sec" in status - assert "use_jpeg" in status - assert "jpeg_quality" in status - - assert status["running"] is False - assert status["screenshot_count"] == 0 - assert status["interval_sec"] == 2.0 - assert status["use_jpeg"] is True - assert status["jpeg_quality"] == 75 - - def test_get_status_reflects_running_state(self): - """Test get_status() reflects current running state.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - worker._take_screenshot = MagicMock(return_value=None) - - assert worker.get_status()["running"] is False - - worker.start(session_id="test_session") - status = worker.get_status() - assert status["running"] is True - assert status["session_id"] == "test_session" - - worker.stop() - assert worker.get_status()["running"] is False - - -class TestScreenshotWorkerWSLDetection: - """Test WSL detection functionality.""" - - def test_is_wsl_on_linux_with_microsoft(self): - """Test _is_wsl() returns True on WSL.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - - # Mock the conditions for WSL - with patch.object(sys, "platform", "linux"): - with patch("os.uname") as mock_uname: - mock_uname.return_value = MagicMock( - release="5.15.90.1-microsoft-standard-WSL2" - ) - assert worker._is_wsl() is True - - def test_is_wsl_on_linux_without_microsoft(self): - """Test _is_wsl() returns False on regular Linux.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - - with patch.object(sys, "platform", "linux"): - with patch("os.uname") as mock_uname: - mock_uname.return_value = MagicMock(release="5.15.0-generic") - assert worker._is_wsl() is False - - def test_is_wsl_on_non_linux(self): - """Test _is_wsl() returns False on non-Linux platforms.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir) - - with patch.object(sys, "platform", "darwin"): - assert worker._is_wsl() is False - - with patch.object(sys, "platform", "win32"): - assert worker._is_wsl() is False - - -class TestScreenshotWorkerCallbacks: - """Test ScreenshotWorker callback functionality.""" - - def test_on_capture_callback_called(self): - """Test on_capture callback is called after successful capture.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - captured_paths = [] - worker = ScreenshotWorker( - output_dir=tmpdir, - interval_sec=0.1, - on_capture=lambda path: captured_paths.append(path), - ) - - # Mock _take_screenshot to return a fake path - worker._take_screenshot = MagicMock(return_value="/fake/path.jpg") - - worker.start() - time.sleep(0.25) # Allow a few captures - worker.stop() - - assert len(captured_paths) > 0 - assert all(path == "/fake/path.jpg" for path in captured_paths) - - def test_on_error_callback_called(self): - """Test on_error callback is called when capture raises exception.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - errors = [] - worker = ScreenshotWorker( - output_dir=tmpdir, - interval_sec=0.1, - on_error=lambda e: errors.append(e), - ) - - # Mock _take_screenshot to raise exception - worker._take_screenshot = MagicMock(side_effect=RuntimeError("Test error")) - - worker.start() - time.sleep(0.25) - worker.stop() - - assert len(errors) > 0 - assert all(isinstance(e, RuntimeError) for e in errors) - - -class TestCaptureManager: - """Test CaptureManager high-level interface.""" - - def test_initialization(self): - """Test CaptureManager initializes correctly.""" - from scitex.capture.capture import CaptureManager - - manager = CaptureManager() - assert manager.worker is None - - def test_start_capture_creates_worker(self): - """Test start_capture creates and starts worker.""" - from scitex.capture.capture import CaptureManager, ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - manager = CaptureManager() - - # Mock the worker's _take_screenshot - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - worker = manager.start_capture(output_dir=tmpdir) - - assert manager.worker is not None - assert manager.worker is worker - assert worker.running is True - - manager.stop_capture() - assert manager.worker is None - - def test_start_capture_with_parameters(self): - """Test start_capture passes parameters correctly.""" - from scitex.capture.capture import CaptureManager, ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - manager = CaptureManager() - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - worker = manager.start_capture( - output_dir=tmpdir, - interval=2.5, - jpeg=False, - quality=90, - verbose=True, - monitor_id=1, - capture_all=True, - ) - - assert worker.interval_sec == 2.5 - assert worker.use_jpeg is False - assert worker.jpeg_quality == 90 - assert worker.verbose is True - assert worker.monitor == 1 - assert worker.capture_all is True - - manager.stop_capture() - - def test_start_capture_when_already_running(self): - """Test start_capture returns existing worker if already running.""" - from scitex.capture.capture import CaptureManager, ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - manager = CaptureManager() - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - worker1 = manager.start_capture(output_dir=tmpdir) - worker2 = manager.start_capture(output_dir=tmpdir) - - assert worker1 is worker2 - manager.stop_capture() - - def test_stop_capture_when_not_running(self): - """Test stop_capture is safe when no worker running.""" - from scitex.capture.capture import CaptureManager - - manager = CaptureManager() - # Should not raise - manager.stop_capture() - assert manager.worker is None - - -class TestCaptureManagerSingleScreenshot: - """Test CaptureManager single screenshot functionality.""" - - def test_take_single_screenshot_with_mocked_capture(self): - """Test take_single_screenshot with mocked capture backend.""" - from scitex.capture.capture import CaptureManager, ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - manager = CaptureManager() - output_path = os.path.join(tmpdir, "test.jpg") - - # Create a mock screenshot file - def mock_take_screenshot(self): - fake_path = os.path.join(tmpdir, "single_0000_test.jpg") - Path(fake_path).touch() - return fake_path - - with patch.object( - ScreenshotWorker, "_take_screenshot", mock_take_screenshot - ): - with patch.object(ScreenshotWorker, "_is_wsl", return_value=False): - result = manager.take_single_screenshot( - output_path=output_path, jpeg=True, quality=85 - ) - - # Result should be the output_path if rename was successful - # or None if capture failed - # In mocked test, we just verify the method doesn't crash - assert result is None or isinstance(result, str) - - def test_take_single_screenshot_generates_default_path(self): - """Test take_single_screenshot generates path when none provided.""" - from scitex.capture.capture import CaptureManager, ScreenshotWorker - - manager = CaptureManager() - - # Mock to return None (no capture possible) - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - result = manager.take_single_screenshot() - assert result is None - - -class TestFilenameGeneration: - """Test filename generation for screenshots.""" - - def test_filename_format_jpeg(self): - """Test JPEG filename format includes session, count, timestamp.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir, use_jpeg=True) - worker.session_id = "20250104_120000" - worker.screenshot_count = 5 - - # Mock the capture methods to return False - worker._is_wsl = MagicMock(return_value=False) - worker._capture_native_screen = MagicMock(return_value=False) - - # Call _take_screenshot (will fail but we can check filename logic) - result = worker._take_screenshot() - - # Since both capture methods return False, result should be None - assert result is None - - def test_filename_format_png(self): - """Test PNG extension when use_jpeg is False.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - worker = ScreenshotWorker(output_dir=tmpdir, use_jpeg=False) - worker.session_id = "test_session" - worker.screenshot_count = 0 - - # Verify extension setting - ext = "jpg" if worker.use_jpeg else "png" - assert ext == "png" - - -if __name__ == "__main__": - import os - - import pytest - - pytest.main([os.path.abspath(__file__)]) - -# -------------------------------------------------------------------------------- -# Start of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/capture.py -# -------------------------------------------------------------------------------- -# #!/usr/bin/env python3 -# # -*- coding: utf-8 -*- -# # Timestamp: "2025-10-18 09:55:59 (ywatanabe)" -# # File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/capture.py -# # ---------------------------------------- -# from __future__ import annotations -# import os -# -# __FILE__ = "./src/scitex/capture/capture.py" -# __DIR__ = os.path.dirname(__FILE__) -# # ---------------------------------------- -# -# """ -# Core screenshot capture functionality. -# Optimized for WSL to Windows host screen capture. -# """ -# -# import subprocess -# import sys -# import threading -# import time -# from datetime import datetime -# from pathlib import Path -# from typing import Optional -# -# -# class ScreenshotWorker: -# """ -# Independent worker thread for continuous screenshot capture. -# Takes screenshots at configurable intervals with compression options. -# """ -# -# def __init__( -# self, -# output_dir: str = "/tmp/scitex_capture_screenshots", -# interval_sec: float = 1.0, -# verbose: bool = False, -# use_jpeg: bool = True, -# jpeg_quality: int = 60, -# on_capture=None, -# on_error=None, -# ): -# """ -# Initialize screenshot worker. -# -# Parameters -# ---------- -# output_dir : str -# Directory for saving screenshots -# interval_sec : float -# Seconds between screenshots (default: 1.0) -# verbose : bool -# Print screenshot paths in runtime log -# use_jpeg : bool -# Use JPEG compression for smaller files (default: True) -# jpeg_quality : int -# JPEG quality 1-100, lower = smaller files (default: 60) -# on_capture : callable, optional -# Callback function called with filepath after each capture -# on_error : callable, optional -# Callback function called with exception on errors -# """ -# self.output_dir = Path(output_dir) -# self.interval_sec = interval_sec -# self.verbose = verbose -# self.use_jpeg = use_jpeg -# self.jpeg_quality = jpeg_quality -# self.on_capture = on_capture -# self.on_error = on_error -# -# # Worker state -# self.running = False -# self.worker_thread = None -# self.screenshot_count = 0 -# self.session_id = None -# -# # Monitor capture settings -# self.monitor = 0 # Default to primary monitor (0-based indexing) -# self.capture_all = False # Default to single monitor -# -# # Create output directory -# self.output_dir.mkdir(parents=True, exist_ok=True) -# -# def start(self, session_id: str = None): -# """Start the screenshot worker thread.""" -# if self.running: -# if self.verbose: -# print("⚠️ Worker already running") -# return -# -# self.running = True -# self.screenshot_count = 0 -# self.session_id = session_id or datetime.now().strftime("%Y%m%d_%H%M%S") -# -# # Start worker thread -# self.worker_thread = threading.Thread( -# target=self._worker_loop, daemon=True, name="ScreenshotWorker" -# ) -# self.worker_thread.start() -# -# if self.verbose: -# ext = "jpg" if self.use_jpeg else "png" -# print( -# f"📸 Started: {self.output_dir}/{self.session_id}_NNNN_*.{ext} (interval: {self.interval_sec}s)" -# ) -# -# def stop(self): -# """Stop the screenshot worker thread.""" -# if not self.running: -# return -# -# self.running = False -# -# if self.worker_thread and self.worker_thread.is_alive(): -# self.worker_thread.join(timeout=2) -# -# if self.verbose: -# print( -# f"📸 Stopped: {self.screenshot_count} screenshots in {self.output_dir}" -# ) -# -# def _worker_loop(self): -# """Main worker loop that takes screenshots.""" -# -# next_capture_time = time.time() -# -# while self.running: -# current_time = time.time() -# -# # Check if it's time for next capture -# if current_time >= next_capture_time: -# try: -# screenshot_path = self._take_screenshot() -# -# if screenshot_path: -# if self.verbose: -# # Simple one-line output -# print(f"📸 {screenshot_path}") -# -# # Call on_capture callback if provided -# if self.on_capture: -# try: -# self.on_capture(screenshot_path) -# except Exception as cb_error: -# if self.verbose: -# print(f"⚠️ Callback error: {cb_error}") -# -# except Exception as e: -# if self.verbose: -# print(f"❌ Error: {e}") -# -# # Call on_error callback if provided -# if self.on_error: -# try: -# self.on_error(e) -# except Exception as cb_error: -# if self.verbose: -# print(f"⚠️ Error callback failed: {cb_error}") -# -# # Schedule next capture -# next_capture_time = current_time + self.interval_sec -# -# # Short sleep to avoid busy waiting, but allow responsive stopping -# time.sleep(0.01) -# -# def _take_screenshot(self) -> Optional[str]: -# """Take a single screenshot.""" -# try: -# # Generate filename with timestamp -# now = datetime.now() -# timestamp = now.strftime("%Y%m%d_%H%M%S_%f")[:-3] -# ext = "jpg" if self.use_jpeg else "png" -# filename = ( -# f"{self.session_id}_{self.screenshot_count:04d}_{timestamp}.{ext}" -# ) -# filepath = self.output_dir / filename -# -# # Try Windows PowerShell method for WSL -# if self._is_wsl(): -# if self._capture_windows_screen( -# filepath, -# monitor=self.monitor, -# capture_all=self.capture_all, -# ): -# self.screenshot_count += 1 -# return str(filepath) -# -# # Fallback to native screenshot tools -# if self._capture_native_screen(filepath): -# self.screenshot_count += 1 -# return str(filepath) -# -# return None -# -# except Exception as e: -# if self.verbose: -# print(f"❌ Screenshot failed: {e}") -# return None -# -# def _is_wsl(self) -> bool: -# """Check if running in WSL.""" -# return sys.platform == "linux" and "microsoft" in os.uname().release.lower() -# -# def _capture_windows_screen( -# self, filepath: Path, monitor: int = 1, capture_all: bool = False -# ) -> bool: -# """Capture Windows host screen from WSL with DPI awareness using external PowerShell scripts. -# -# Args: -# filepath: Path to save the screenshot -# monitor: Monitor number to capture (1-based index) -# capture_all: If True, capture all monitors combined -# """ -# try: -# # Try using external PowerShell script first -# script_dir = Path(__file__).parent / "powershell" -# if capture_all: -# script_path = script_dir / "capture_all_monitors.ps1" -# else: -# script_path = script_dir / "capture_single_monitor.ps1" -# -# # Check if script exists -# if script_path.exists(): -# # Find PowerShell executable -# ps_paths = [ -# "powershell.exe", -# "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", -# "/mnt/c/Windows/SysWOW64/WindowsPowerShell/v1.0/powershell.exe", -# ] -# -# ps_exe = None -# for path in ps_paths: -# try: -# test_result = subprocess.run( -# [path, "-Command", "echo test"], -# capture_output=True, -# timeout=1, -# ) -# if test_result.returncode == 0: -# ps_exe = path -# break -# except: -# continue -# -# if ps_exe: -# # Build PowerShell command -# if capture_all: -# cmd = [ -# ps_exe, -# "-NoProfile", -# "-ExecutionPolicy", -# "Bypass", -# "-File", -# str(script_path), -# "-OutputFormat", -# "base64", -# ] -# else: -# # Pass 0-based monitor index directly to PowerShell -# cmd = [ -# ps_exe, -# "-NoProfile", -# "-ExecutionPolicy", -# "Bypass", -# "-File", -# str(script_path), -# "-MonitorNumber", -# str(monitor), -# "-OutputFormat", -# "base64", -# ] -# -# result = subprocess.run( -# cmd, capture_output=True, text=True, timeout=5 -# ) -# -# if result.returncode == 0 and result.stdout.strip(): -# # Decode base64 PNG data -# import base64 -# -# png_data = base64.b64decode(result.stdout.strip()) -# -# # Save directly as JPEG if requested, otherwise as PNG -# if self.use_jpeg: -# try: -# import io -# -# from PIL import Image -# -# img = Image.open(io.BytesIO(png_data)) -# # Convert RGBA to RGB for JPEG -# if img.mode == "RGBA": -# rgb_img = Image.new( -# "RGB", img.size, (255, 255, 255) -# ) -# rgb_img.paste( -# img, mask=img.split()[3] -# ) # Use alpha channel as mask -# img = rgb_img -# img.save( -# str(filepath), -# "JPEG", -# quality=self.jpeg_quality, -# optimize=True, -# ) -# except ImportError: -# # PIL not available, save as PNG -# with open(str(filepath), "wb") as f: -# f.write(png_data) -# else: -# with open(str(filepath), "wb") as f: -# f.write(png_data) -# -# return filepath.exists() -# -# # Fallback to inline script -# return self._capture_windows_screen_inline(filepath) -# -# except Exception as e: -# if self.verbose: -# print(f"❌ Windows screen capture error: {e}") -# import traceback -# -# traceback.print_exc() -# return False -# -# def _capture_windows_screen_inline(self, filepath: Path) -> bool: -# """Fallback inline PowerShell capture (when .ps1 files not available).""" -# try: -# if self.verbose: -# print("🔍 Attempting inline PowerShell capture...") -# # Use base64 encoding to avoid path issues (most reliable for WSL) -# # Now with DPI awareness for proper high-resolution capture -# ps_script = """ -# Add-Type -AssemblyName System.Windows.Forms -# Add-Type -AssemblyName System.Drawing -# -# # Enable DPI awareness for proper high-resolution capture -# Add-Type @' -# using System; -# using System.Runtime.InteropServices; -# public class User32 { -# [DllImport("user32.dll")] -# public static extern bool SetProcessDPIAware(); -# } -# '@ -# $null = [User32]::SetProcessDPIAware() -# -# $screen = [System.Windows.Forms.Screen]::PrimaryScreen -# $bitmap = New-Object System.Drawing.Bitmap $screen.Bounds.Width, $screen.Bounds.Height -# $graphics = [System.Drawing.Graphics]::FromImage($bitmap) -# -# # Set high quality rendering -# $graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality -# $graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic -# $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality -# $graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality -# -# $graphics.CopyFromScreen($screen.Bounds.X, $screen.Bounds.Y, 0, 0, $bitmap.Size) -# -# $stream = New-Object System.IO.MemoryStream -# $bitmap.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) -# $bytes = $stream.ToArray() -# [Convert]::ToBase64String($bytes) -# -# $graphics.Dispose() -# $bitmap.Dispose() -# $stream.Dispose() -# """ -# -# # Find PowerShell executable -# ps_paths = [ -# # Check PATH first (might be in .win-bin or similar) -# "powershell.exe", -# # Standard WSL path -# "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", -# # Alternative locations -# "/mnt/c/Windows/SysWOW64/WindowsPowerShell/v1.0/powershell.exe", -# ] -# -# ps_exe = None -# for path in ps_paths: -# try: -# # Just check if the file exists and is executable -# test_path = ( -# Path(path) if not path.startswith("/mnt/") else Path(path) -# ) -# if path == "powershell.exe": -# # In PATH - use it directly -# ps_exe = path -# if self.verbose: -# print(f"✓ Found PowerShell in PATH") -# break -# elif test_path.exists() or Path(path).exists(): -# ps_exe = path -# if self.verbose: -# print(f"✓ Found PowerShell at {path}") -# break -# except: -# continue -# -# if not ps_exe: -# if self.verbose: -# print("❌ PowerShell executable not found") -# return False -# -# if self.verbose: -# print(f"✓ Using PowerShell: {ps_exe}") -# -# # Execute PowerShell -# cmd = [ps_exe, "-NoProfile", "-Command", ps_script] -# -# if self.verbose: -# print("🔄 Executing PowerShell script...") -# -# try: -# result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) -# -# if self.verbose: -# print(f"✓ PowerShell return code: {result.returncode}") -# if result.stderr: -# print(f"PowerShell stderr: {result.stderr[:500]}") -# if result.stdout: -# print(f"✓ PowerShell stdout length: {len(result.stdout)} chars") -# except subprocess.TimeoutExpired as e: -# if self.verbose: -# print(f"❌ PowerShell timeout after 10s") -# return False -# -# if result.returncode == 0 and result.stdout.strip(): -# # Decode base64 PNG data -# import base64 -# -# png_data = base64.b64decode(result.stdout.strip()) -# -# # Save directly as JPEG if requested, otherwise as PNG -# if self.use_jpeg: -# try: -# import io -# -# from PIL import Image -# -# # Load PNG from memory -# img = Image.open(io.BytesIO(png_data)) -# # Convert RGBA to RGB for JPEG -# if img.mode == "RGBA": -# rgb_img = Image.new("RGB", img.size, (255, 255, 255)) -# rgb_img.paste(img, mask=img.split()[3]) -# img = rgb_img -# # Save as JPEG with quality -# img.save( -# str(filepath), -# "JPEG", -# quality=self.jpeg_quality, -# optimize=True, -# ) -# except ImportError: -# # PIL not available, save as PNG -# with open(str(filepath), "wb") as f: -# f.write(png_data) -# else: -# # Save as PNG -# with open(str(filepath), "wb") as f: -# f.write(png_data) -# -# return filepath.exists() -# except Exception: -# pass -# return False -# -# def _capture_native_screen(self, filepath: Path) -> bool: -# """Capture screen using native tools.""" -# try: -# # Try mss first -# try: -# import mss -# -# with mss.mss() as sct: -# # Capture primary monitor -# monitor = ( -# sct.monitors[1] if len(sct.monitors) > 1 else sct.monitors[0] -# ) -# screenshot = sct.grab(monitor) -# -# if self.use_jpeg: -# # Convert to PIL for JPEG saving -# from PIL import Image -# -# img = Image.frombytes( -# "RGB", -# screenshot.size, -# screenshot.bgra, -# "raw", -# "BGRX", -# ) -# img.save(str(filepath), "JPEG", quality=self.jpeg_quality) -# else: -# mss.tools.to_png( -# screenshot.rgb, -# screenshot.size, -# output=str(filepath), -# ) -# -# return filepath.exists() -# except ImportError: -# pass -# -# # Try scrot -# if self.use_jpeg: -# cmd = [ -# "scrot", -# "-z", -# "-q", -# str(self.jpeg_quality), -# str(filepath), -# ] -# else: -# cmd = ["scrot", "-z", str(filepath)] -# -# result = subprocess.run(cmd, capture_output=True, timeout=2) -# return result.returncode == 0 and filepath.exists() -# -# except Exception as e: -# if self.verbose: -# print(f"❌ Native screen capture failed: {e}") -# return False -# -# def get_status(self) -> dict: -# """Get current worker status.""" -# return { -# "running": self.running, -# "session_id": self.session_id, -# "screenshot_count": self.screenshot_count, -# "output_dir": str(self.output_dir), -# "interval_sec": self.interval_sec, -# "use_jpeg": self.use_jpeg, -# "jpeg_quality": self.jpeg_quality, -# } -# -# -# class CaptureManager: -# """High-level interface for managing screen capture.""" -# -# def __init__(self): -# self.worker = None -# -# def start_capture( -# self, -# output_dir: str = "/tmp/scitex_capture_screenshots", -# interval: float = 1.0, -# jpeg: bool = True, -# quality: int = 60, -# on_capture=None, -# on_error=None, -# verbose: bool = False, -# monitor_id: int = 0, -# capture_all: bool = False, -# ) -> ScreenshotWorker: -# """Start continuous screen capture.""" -# if self.worker and self.worker.running: -# print("Capture already running") -# return self.worker -# -# self.worker = ScreenshotWorker( -# output_dir=output_dir, -# interval_sec=interval, -# use_jpeg=jpeg, -# jpeg_quality=quality, -# on_capture=on_capture, -# on_error=on_error, -# verbose=verbose, -# ) -# # Set monitor parameters -# self.worker.monitor = monitor_id -# self.worker.capture_all = capture_all -# self.worker.start() -# return self.worker -# -# def stop_capture(self): -# """Stop screen capture.""" -# if self.worker: -# self.worker.stop() -# self.worker = None -# -# def take_single_screenshot( -# self, -# output_path: str = None, -# jpeg: bool = True, -# quality: int = 85, -# monitor_id: int = 0, -# capture_all_monitors: bool = False, -# ) -> Optional[str]: -# """ -# Take a single screenshot. -# -# Args: -# output_path: Path to save screenshot -# jpeg: Use JPEG compression -# quality: JPEG quality (1-100) -# monitor_id: Monitor index to capture (0-based) -# capture_all_monitors: Capture all monitors combined -# -# Returns: -# Path to saved screenshot -# """ -# if output_path is None: -# timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") -# ext = "jpg" if jpeg else "png" -# output_path = f"/tmp/screenshot_{timestamp}.{ext}" -# -# worker = ScreenshotWorker( -# output_dir=str(Path(output_path).parent), -# use_jpeg=jpeg, -# jpeg_quality=quality, -# verbose=False, -# ) -# -# # Set monitor parameters -# worker.monitor = monitor_id -# worker.capture_all = capture_all_monitors -# -# # Take single screenshot -# worker.session_id = "single" -# worker.screenshot_count = 0 -# screenshot_path = worker._take_screenshot() -# -# if screenshot_path: -# # Rename to desired path -# Path(screenshot_path).rename(output_path) -# return output_path -# -# return None -# -# def get_info(self) -> dict: -# """ -# Enumerate all available monitors and virtual desktops. -# -# Returns: -# Dictionary with monitor information -# """ -# try: -# script_dir = Path(__file__).parent / "powershell" -# script_path = script_dir / "detect_monitors_and_desktops.ps1" -# -# if not script_path.exists(): -# return {"error": "Detection script not found"} -# -# # Find PowerShell -# ps_paths = [ -# "powershell.exe", -# "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", -# ] -# -# ps_exe = None -# for path in ps_paths: -# try: -# result = subprocess.run( -# [path, "-Command", "echo test"], -# capture_output=True, -# timeout=1, -# ) -# if result.returncode == 0: -# ps_exe = path -# break -# except: -# continue -# -# if not ps_exe: -# return {"error": "PowerShell not found"} -# -# # Execute detection script -# cmd = [ -# ps_exe, -# "-NoProfile", -# "-ExecutionPolicy", -# "Bypass", -# "-File", -# str(script_path), -# ] -# -# result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) -# -# if result.returncode == 0 and result.stdout.strip(): -# # Parse JSON from output (skip non-JSON lines) -# import json -# -# lines = result.stdout.strip().split("\n") -# for line in lines: -# line = line.strip() -# if line.startswith("{"): -# return json.loads(line) -# -# return {"error": "No JSON in output"} -# else: -# return { -# "error": (result.stderr if result.stderr else "Detection failed") -# } -# -# except Exception as e: -# return {"error": str(e)} -# -# def capture_window( -# self, -# window_handle: int, -# output_path: str = None, -# jpeg: bool = True, -# quality: int = 85, -# ) -> Optional[str]: -# """ -# Capture a specific window by its handle. -# -# Args: -# window_handle: Window handle (from get_info) -# output_path: Path to save screenshot -# jpeg: Use JPEG compression -# quality: JPEG quality (1-100) -# -# Returns: -# Path to saved screenshot or None -# """ -# try: -# script_dir = Path(__file__).parent / "powershell" -# script_path = script_dir / "capture_window_by_handle.ps1" -# -# if not script_path.exists(): -# return None -# -# # Find PowerShell -# ps_paths = [ -# "powershell.exe", -# "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", -# ] -# -# ps_exe = None -# for path in ps_paths: -# try: -# result = subprocess.run( -# [path, "-Command", "echo test"], -# capture_output=True, -# timeout=1, -# ) -# if result.returncode == 0: -# ps_exe = path -# break -# except: -# continue -# -# if not ps_exe: -# return None -# -# # Execute window capture script -# cmd = [ -# ps_exe, -# "-NoProfile", -# "-ExecutionPolicy", -# "Bypass", -# "-File", -# str(script_path), -# "-WindowHandle", -# str(window_handle), -# ] -# -# result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) -# -# if result.returncode == 0 and result.stdout.strip(): -# # Parse JSON from output -# import base64 -# import json -# -# lines = result.stdout.strip().split("\n") -# for line in lines: -# line = line.strip() -# if line.startswith("{"): -# data = json.loads(line) -# break -# else: -# return None -# -# if not data.get("Success"): -# return None -# -# # Generate output path if not provided -# if output_path is None: -# timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") -# ext = "jpg" if jpeg else "png" -# output_path = f"/tmp/window_{window_handle}_{timestamp}.{ext}" -# -# # Decode base64 image -# img_data = base64.b64decode(data.get("Base64Data", "")) -# -# # Save as JPEG or PNG -# if jpeg: -# try: -# import io -# -# from PIL import Image -# -# img = Image.open(io.BytesIO(img_data)) -# if img.mode == "RGBA": -# rgb_img = Image.new("RGB", img.size, (255, 255, 255)) -# rgb_img.paste(img, mask=img.split()[3]) -# img = rgb_img -# img.save(output_path, "JPEG", quality=quality, optimize=True) -# except ImportError: -# output_path = output_path.replace(".jpg", ".png").replace( -# ".jpeg", ".png" -# ) -# with open(output_path, "wb") as f: -# f.write(img_data) -# else: -# with open(output_path, "wb") as f: -# f.write(img_data) -# -# return output_path if Path(output_path).exists() else None -# -# except Exception as e: -# return None -# -# -# # EOF - -# -------------------------------------------------------------------------------- -# End of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/capture.py -# -------------------------------------------------------------------------------- diff --git a/tests/scitex/capture/test_cli.py b/tests/scitex/capture/test_cli.py deleted file mode 100644 index 8e50d0597..000000000 --- a/tests/scitex/capture/test_cli.py +++ /dev/null @@ -1,548 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.capture.cli module. - -Tests CLI functionality: -- Argument parsing -- --list, --info, --stop, --gif actions -- Default capture behavior -- Error handling -""" - -import os -import sys -import tempfile -from io import StringIO -from unittest.mock import MagicMock, patch - -import pytest - - -class TestCLIArgumentParsing: - """Test CLI argument parsing.""" - - def test_main_function_exists(self): - """Test main function exists and is callable.""" - from scitex.capture.cli import main - - assert callable(main) - - def test_help_argument(self): - """Test --help shows help and exits.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--help"]): - with pytest.raises(SystemExit) as exc_info: - main() - assert exc_info.value.code == 0 - - def test_quiet_flag_parsing(self): - """Test -q/--quiet flag is parsed.""" - import argparse - - parser = argparse.ArgumentParser() - parser.add_argument("-q", "--quiet", action="store_true") - - args = parser.parse_args(["-q"]) - assert args.quiet is True - - args = parser.parse_args(["--quiet"]) - assert args.quiet is True - - def test_monitor_argument_parsing(self): - """Test --monitor argument parsing.""" - import argparse - - parser = argparse.ArgumentParser() - parser.add_argument("--monitor", type=int, default=0) - - args = parser.parse_args(["--monitor", "2"]) - assert args.monitor == 2 - - def test_quality_argument_parsing(self): - """Test --quality argument parsing.""" - import argparse - - parser = argparse.ArgumentParser() - parser.add_argument("--quality", type=int, default=85) - - args = parser.parse_args(["--quality", "50"]) - assert args.quality == 50 - - def test_output_argument_parsing(self): - """Test -o/--output argument parsing.""" - import argparse - - parser = argparse.ArgumentParser() - parser.add_argument("-o", "--output", type=str) - - args = parser.parse_args(["-o", "/path/to/output.jpg"]) - assert args.output == "/path/to/output.jpg" - - -class TestCLIListAction: - """Test --list action.""" - - def test_list_action_returns_zero(self): - """Test --list returns 0 on success.""" - from scitex.capture.cli import main - - mock_info = { - "Windows": { - "Details": [ - { - "ProcessName": "test_app", - "Title": "Test Window", - "Handle": 12345, - "ProcessId": 1234, - } - ] - } - } - - with patch("sys.argv", ["capture", "--list"]): - with patch("scitex.capture.get_info", return_value=mock_info): - result = main() - assert result == 0 - - def test_list_action_empty_windows(self): - """Test --list with no windows.""" - from scitex.capture.cli import main - - mock_info = {"Windows": {"Details": []}} - - with patch("sys.argv", ["capture", "--list"]): - with patch("scitex.capture.get_info", return_value=mock_info): - result = main() - assert result == 0 - - -class TestCLIInfoAction: - """Test --info action.""" - - def test_info_action_returns_zero(self): - """Test --info returns 0 on success.""" - from scitex.capture.cli import main - - mock_info = { - "Monitors": { - "Count": 2, - "PrimaryMonitor": 0, - "Details": [ - { - "DeviceName": "Display1", - "IsPrimary": True, - "Bounds": {"Width": 1920, "Height": 1080}, - } - ], - }, - "Windows": {"VisibleCount": 5, "Details": []}, - "VirtualDesktops": {"Supported": True, "Note": "Test"}, - } - - with patch("sys.argv", ["capture", "--info"]): - with patch("scitex.capture.get_info", return_value=mock_info): - result = main() - assert result == 0 - - -class TestCLIStopAction: - """Test --stop action.""" - - def test_stop_action_returns_zero(self): - """Test --stop returns 0.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--stop"]): - with patch("scitex.capture.stop") as mock_stop: - result = main() - mock_stop.assert_called_once() - assert result == 0 - - -class TestCLIGifAction: - """Test --gif action.""" - - def test_gif_action_success(self): - """Test --gif returns 0 when GIF created.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--gif"]): - with patch("scitex.capture.gif", return_value="/path/to/output.gif"): - result = main() - assert result == 0 - - def test_gif_action_no_session(self): - """Test --gif returns 1 when no session found.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--gif"]): - with patch("scitex.capture.gif", return_value=None): - result = main() - assert result == 1 - - -class TestCLIDefaultCapture: - """Test default capture behavior.""" - - def test_default_capture_success(self): - """Test default capture returns 0 on success.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture"]): - with patch("scitex.capture.snap", return_value="/path/to/screenshot.jpg"): - result = main() - assert result == 0 - - def test_default_capture_failure(self): - """Test default capture returns 1 on failure.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture"]): - with patch("scitex.capture.snap", return_value=None): - result = main() - assert result == 1 - - def test_capture_with_message(self): - """Test capture with positional message argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "test message"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/path/to/screenshot.jpg" - result = main() - - mock_snap.assert_called_once() - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["message"] == "test message" - - def test_capture_with_output(self): - """Test capture with --output argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "-o", "/custom/path.jpg"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/custom/path.jpg" - result = main() - - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["path"] == "/custom/path.jpg" - - def test_capture_with_quality(self): - """Test capture with --quality argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--quality", "50"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/path.jpg" - result = main() - - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["quality"] == 50 - - def test_capture_with_monitor(self): - """Test capture with --monitor argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--monitor", "1"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/path.jpg" - result = main() - - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["monitor_id"] == 1 - - def test_capture_with_all(self): - """Test capture with --all argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--all"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/path.jpg" - result = main() - - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["all"] is True - - def test_capture_with_app(self): - """Test capture with --app argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--app", "chrome"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/path.jpg" - result = main() - - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["app"] == "chrome" - - def test_capture_with_url(self): - """Test capture with --url argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "--url", "localhost:8000"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/path.jpg" - result = main() - - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["url"] == "localhost:8000" - - def test_capture_quiet_mode(self): - """Test capture with --quiet argument.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture", "-q"]): - with patch("scitex.capture.snap") as mock_snap: - mock_snap.return_value = "/path.jpg" - result = main() - - call_kwargs = mock_snap.call_args[1] - assert call_kwargs["verbose"] is False - - -class TestCLIErrorHandling: - """Test CLI error handling.""" - - def test_exception_returns_one(self): - """Test exception during execution returns 1.""" - from scitex.capture.cli import main - - with patch("sys.argv", ["capture"]): - with patch("scitex.capture.snap", side_effect=RuntimeError("Test error")): - result = main() - assert result == 1 - - -class TestCLIModuleExports: - """Test module exports.""" - - def test_main_importable(self): - """Test main function can be imported.""" - from scitex.capture.cli import main - - assert main is not None - assert callable(main) - - -if __name__ == "__main__": - import os - - import pytest - - pytest.main([os.path.abspath(__file__)]) - -# -------------------------------------------------------------------------------- -# Start of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/cli.py -# -------------------------------------------------------------------------------- -# #!/usr/bin/env python3 -# # -*- coding: utf-8 -*- -# # Timestamp: "2025-10-18 09:55:58 (ywatanabe)" -# # File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/cli.py -# # ---------------------------------------- -# from __future__ import annotations -# import os -# -# __FILE__ = "./src/scitex/capture/cli.py" -# __DIR__ = os.path.dirname(__FILE__) -# # ---------------------------------------- -# -# """ -# CLI for scitex.capture - AI's Camera -# """ -# -# import argparse -# import sys -# -# -# def main(): -# """Main CLI entry point.""" -# parser = argparse.ArgumentParser( -# description="scitex.capture - AI's Camera: Capture screenshots from anywhere", -# formatter_class=argparse.RawDescriptionHelpFormatter, -# epilog=""" -# Examples: -# python -m scitex.capture # Capture current screen -# python -m scitex.capture --all # Capture all monitors -# python -m scitex.capture --app chrome # Capture Chrome window -# python -m scitex.capture --url 127.0.0.1:8000 # Capture URL -# python -m scitex.capture --monitor 1 # Capture monitor 1 -# python -m scitex.capture --list # List available windows -# -# python -m scitex.capture --start # Start monitoring -# python -m scitex.capture --stop # Stop monitoring -# python -m scitex.capture --gif # Create GIF from session -# python -m scitex.capture --mcp # Start MCP server -# """, -# ) -# -# # Capture options -# parser.add_argument("message", nargs="?", help="Optional message for filename") -# parser.add_argument("--all", action="store_true", help="Capture all monitors") -# parser.add_argument("--app", type=str, help="App name to capture (e.g., chrome)") -# parser.add_argument("--url", type=str, help="URL to capture (e.g., 127.0.0.1:8000)") -# parser.add_argument("--monitor", type=int, default=0, help="Monitor ID (0-based)") -# parser.add_argument("--quality", type=int, default=85, help="JPEG quality (1-100)") -# parser.add_argument("-o", "--output", type=str, help="Output path") -# -# # Actions -# parser.add_argument("--list", action="store_true", help="List available windows") -# parser.add_argument("--info", action="store_true", help="Show display info") -# parser.add_argument("--start", action="store_true", help="Start monitoring") -# parser.add_argument("--stop", action="store_true", help="Stop monitoring") -# parser.add_argument( -# "--gif", action="store_true", help="Create GIF from latest session" -# ) -# parser.add_argument("--mcp", action="store_true", help="Start MCP server") -# -# # Options -# parser.add_argument( -# "--interval", -# type=float, -# default=1.0, -# help="Monitoring interval in seconds", -# ) -# parser.add_argument("-q", "--quiet", action="store_true", help="Quiet mode") -# -# args = parser.parse_args() -# -# # Import scitex.capture after parsing to avoid import overhead for --help -# from scitex import capture -# -# verbose = not args.quiet -# -# try: -# # Handle actions -# if args.list: -# info = capture.get_info() -# windows = info.get("Windows", {}).get("Details", []) -# print(f"\n📱 Visible Windows ({len(windows)}):") -# print("=" * 60) -# for i, win in enumerate(windows, 1): -# print(f"{i}. [{win['ProcessName']}] {win['Title']}") -# print(f" Handle: {win['Handle']} | PID: {win['ProcessId']}") -# return 0 -# -# elif args.info: -# info = capture.get_info() -# monitors = info.get("Monitors", {}) -# windows = info.get("Windows", {}) -# vd = info.get("VirtualDesktops", {}) -# -# print("\n🖥️ Display Information") -# print("=" * 60) -# print(f"\n📺 Monitors: {monitors.get('Count')}") -# print(f" Primary: {monitors.get('PrimaryMonitor')}") -# -# for i, mon in enumerate(monitors.get("Details", [])): -# bounds = mon.get("Bounds", {}) -# print(f"\n Monitor {i}:") -# print(f" Device: {mon.get('DeviceName')}") -# print(f" Resolution: {bounds.get('Width')}x{bounds.get('Height')}") -# print(f" Primary: {mon.get('IsPrimary')}") -# -# print(f"\n🪟 Windows: {windows.get('VisibleCount')}") -# print(f" On current virtual desktop: {len(windows.get('Details', []))}") -# -# print(f"\n🖥️ Virtual Desktops:") -# print(f" Supported: {vd.get('Supported')}") -# print(f" Note: {vd.get('Note')}") -# -# return 0 -# -# elif args.start: -# print(f"📸 Starting monitoring (interval: {args.interval}s)...") -# capture.start( -# interval=args.interval, -# verbose=verbose, -# monitor_id=args.monitor, -# all=args.all, -# ) -# print( -# "✅ Monitoring started. Press Ctrl+C to stop, or run: python -m scitex.capture --stop" -# ) -# print(f"📁 Saving to: ~/.scitex/capture/") -# -# # Keep running -# try: -# import time -# -# while True: -# time.sleep(1) -# except KeyboardInterrupt: -# capture.stop() -# print("\n✅ Monitoring stopped") -# -# return 0 -# -# elif args.stop: -# capture.stop() -# print("✅ Monitoring stopped") -# return 0 -# -# elif args.gif: -# print("📹 Creating GIF from latest session...") -# path = capture.gif() -# if path: -# print(f"✅ GIF created: {path}") -# return 0 -# else: -# print("❌ No session found") -# return 1 -# -# elif args.mcp: -# print("🤖 Starting scitex.capture MCP server...") -# print("Add to Claude Code settings:") -# print("{") -# print(' "mcpServers": {') -# print(' "scitex-capture": {') -# print(' "command": "python",') -# print(' "args": ["-m", "scitex.capture", "--mcp"]') -# print(" }") -# print(" }") -# print("}") -# print() -# -# # Start MCP server -# import asyncio -# from .mcp_server import main as mcp_main -# -# asyncio.run(mcp_main()) -# return 0 -# -# # Default: capture screenshot -# else: -# path = capture.snap( -# message=args.message, -# path=args.output, -# quality=args.quality, -# monitor_id=args.monitor, -# all=args.all, -# app=args.app, -# url=args.url, -# verbose=verbose, -# ) -# -# if path: -# if not args.quiet: -# print(f"✅ {path}") -# return 0 -# else: -# print("❌ Screenshot failed") -# return 1 -# -# except KeyboardInterrupt: -# print("\n⚠️ Interrupted") -# return 130 -# except Exception as e: -# print(f"❌ Error: {e}") -# return 1 -# -# -# if __name__ == "__main__": -# sys.exit(main()) -# -# # EOF - -# -------------------------------------------------------------------------------- -# End of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/cli.py -# -------------------------------------------------------------------------------- diff --git a/tests/scitex/capture/test_gif.py b/tests/scitex/capture/test_gif.py deleted file mode 100644 index a25b89c6b..000000000 --- a/tests/scitex/capture/test_gif.py +++ /dev/null @@ -1,884 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.capture.gif module. - -Tests GIF creation functionality: -- GifCreator class methods -- create_gif_from_files() -- create_gif_from_session() -- create_gif_from_pattern() -- Session detection -""" - -import os -import sys -import tempfile -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - - -class TestGifCreatorInit: - """Test GifCreator initialization.""" - - def test_initialization(self): - """Test GifCreator initializes correctly.""" - from scitex.capture.gif import GifCreator - - creator = GifCreator() - assert creator is not None - - -class TestCreateGifFromFiles: - """Test create_gif_from_files functionality.""" - - def test_create_gif_with_valid_images(self): - """Test GIF creation with valid image files.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - # Create test images - image_paths = [] - for i in range(3): - img_path = os.path.join(tmpdir, f"frame_{i}.jpg") - img = Image.new("RGB", (100, 100), color=(255 - i * 50, i * 50, 0)) - img.save(img_path, "JPEG") - image_paths.append(img_path) - - output_path = os.path.join(tmpdir, "output.gif") - creator = GifCreator() - result = creator.create_gif_from_files( - image_paths=image_paths, output_path=output_path - ) - - assert result is not None - assert os.path.exists(result) - assert result.endswith(".gif") - - def test_create_gif_empty_paths(self): - """Test GIF creation with empty paths list.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "output.gif") - creator = GifCreator() - result = creator.create_gif_from_files( - image_paths=[], output_path=output_path - ) - - assert result is None - - def test_create_gif_nonexistent_paths(self): - """Test GIF creation with nonexistent image paths.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "output.gif") - creator = GifCreator() - result = creator.create_gif_from_files( - image_paths=["/nonexistent/image1.jpg", "/nonexistent/image2.jpg"], - output_path=output_path, - ) - - assert result is None - - def test_create_gif_with_duration(self): - """Test GIF creation with custom duration.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - # Create test images - image_paths = [] - for i in range(2): - img_path = os.path.join(tmpdir, f"frame_{i}.jpg") - img = Image.new("RGB", (50, 50), color="blue") - img.save(img_path, "JPEG") - image_paths.append(img_path) - - output_path = os.path.join(tmpdir, "output.gif") - creator = GifCreator() - result = creator.create_gif_from_files( - image_paths=image_paths, output_path=output_path, duration=1.0 - ) - - assert result is not None - - def test_create_gif_with_different_sizes(self): - """Test GIF creation with images of different sizes.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - # Create images with different sizes - img1_path = os.path.join(tmpdir, "frame_1.jpg") - img2_path = os.path.join(tmpdir, "frame_2.jpg") - - Image.new("RGB", (100, 100), "red").save(img1_path) - Image.new("RGB", (200, 200), "blue").save(img2_path) - - output_path = os.path.join(tmpdir, "output.gif") - creator = GifCreator() - result = creator.create_gif_from_files( - image_paths=[img1_path, img2_path], output_path=output_path - ) - - assert result is not None - - def test_create_gif_creates_output_directory(self): - """Test GIF creation creates parent directory.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - img_path = os.path.join(tmpdir, "frame.jpg") - Image.new("RGB", (50, 50), "green").save(img_path) - - # Output to nested nonexistent directory - output_path = os.path.join(tmpdir, "nested", "deep", "output.gif") - creator = GifCreator() - result = creator.create_gif_from_files( - image_paths=[img_path], output_path=output_path - ) - - assert result is not None - assert os.path.exists(os.path.dirname(output_path)) - - -class TestCreateGifFromSession: - """Test create_gif_from_session functionality.""" - - def test_create_gif_from_session_no_files(self): - """Test GIF creation from session with no matching files.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - creator = GifCreator() - result = creator.create_gif_from_session( - session_id="20250104_120000", screenshot_dir=tmpdir - ) - - assert result is None - - def test_create_gif_from_session_with_files(self): - """Test GIF creation from session with matching files.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - session_id = "20250104_120000" - - # Create session files - for i in range(3): - img_path = os.path.join(tmpdir, f"{session_id}_{i:04d}_120000000.jpg") - Image.new("RGB", (100, 100), "red").save(img_path) - - creator = GifCreator() - result = creator.create_gif_from_session( - session_id=session_id, screenshot_dir=tmpdir - ) - - assert result is not None - assert session_id in result - - def test_create_gif_from_session_png_fallback(self): - """Test GIF creation falls back to PNG files.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - session_id = "20250104_130000" - - # Create PNG files (no JPG) - for i in range(2): - img_path = os.path.join(tmpdir, f"{session_id}_{i:04d}_130000000.png") - Image.new("RGB", (100, 100), "blue").save(img_path) - - creator = GifCreator() - result = creator.create_gif_from_session( - session_id=session_id, screenshot_dir=tmpdir - ) - - assert result is not None - - def test_create_gif_from_session_max_frames(self): - """Test GIF creation with max_frames limit.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - session_id = "20250104_140000" - - # Create many session files - for i in range(10): - img_path = os.path.join(tmpdir, f"{session_id}_{i:04d}_140000000.jpg") - Image.new("RGB", (50, 50), "green").save(img_path) - - creator = GifCreator() - result = creator.create_gif_from_session( - session_id=session_id, screenshot_dir=tmpdir, max_frames=3 - ) - - assert result is not None - - -class TestCreateGifFromPattern: - """Test create_gif_from_pattern functionality.""" - - def test_create_gif_from_pattern_no_matches(self): - """Test GIF creation with no matching files.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - pattern = os.path.join(tmpdir, "nonexistent_*.jpg") - creator = GifCreator() - result = creator.create_gif_from_pattern(pattern=pattern) - - assert result is None - - def test_create_gif_from_pattern_with_matches(self): - """Test GIF creation with matching files.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - # Create matching files - for i in range(3): - img_path = os.path.join(tmpdir, f"screenshot_{i}.jpg") - Image.new("RGB", (100, 100), "purple").save(img_path) - - pattern = os.path.join(tmpdir, "screenshot_*.jpg") - output_path = os.path.join(tmpdir, "result.gif") - creator = GifCreator() - result = creator.create_gif_from_pattern( - pattern=pattern, output_path=output_path - ) - - assert result is not None - assert os.path.exists(result) - - def test_create_gif_from_pattern_auto_output_path(self): - """Test GIF creation generates output path automatically.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - # Create matching files - img_path = os.path.join(tmpdir, "test_image.jpg") - Image.new("RGB", (50, 50), "yellow").save(img_path) - - pattern = os.path.join(tmpdir, "test_*.jpg") - creator = GifCreator() - result = creator.create_gif_from_pattern(pattern=pattern) - - assert result is not None - assert "gif_summary_" in result - - def test_create_gif_from_pattern_max_frames(self): - """Test GIF creation from pattern with max_frames.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - # Create many files - for i in range(10): - img_path = os.path.join(tmpdir, f"img_{i:02d}.jpg") - Image.new("RGB", (50, 50), "orange").save(img_path) - - pattern = os.path.join(tmpdir, "img_*.jpg") - output_path = os.path.join(tmpdir, "limited.gif") - creator = GifCreator() - result = creator.create_gif_from_pattern( - pattern=pattern, output_path=output_path, max_frames=3 - ) - - assert result is not None - - -class TestGetRecentSessions: - """Test get_recent_sessions functionality.""" - - def test_get_recent_sessions_empty_dir(self): - """Test getting sessions from empty directory.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - creator = GifCreator() - result = creator.get_recent_sessions(screenshot_dir=tmpdir) - - assert result == [] - - def test_get_recent_sessions_nonexistent_dir(self): - """Test getting sessions from nonexistent directory.""" - from scitex.capture.gif import GifCreator - - creator = GifCreator() - result = creator.get_recent_sessions(screenshot_dir="/nonexistent/path") - - assert result == [] - - def test_get_recent_sessions_with_files(self): - """Test getting sessions with session files present.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - # Create session files with proper naming - session1 = "20250101_100000" - session2 = "20250102_110000" - - Path(os.path.join(tmpdir, f"{session1}_0001_100000000.jpg")).touch() - Path(os.path.join(tmpdir, f"{session1}_0002_100001000.jpg")).touch() - Path(os.path.join(tmpdir, f"{session2}_0001_110000000.jpg")).touch() - - creator = GifCreator() - result = creator.get_recent_sessions(screenshot_dir=tmpdir) - - assert len(result) == 2 - assert session2 in result # Newer session - assert session1 in result - - def test_get_recent_sessions_sorted_newest_first(self): - """Test sessions are sorted newest first.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - sessions = ["20250101_100000", "20250103_100000", "20250102_100000"] - - for sess in sessions: - Path(os.path.join(tmpdir, f"{sess}_0001_100000000.jpg")).touch() - - creator = GifCreator() - result = creator.get_recent_sessions(screenshot_dir=tmpdir) - - assert result[0] == "20250103_100000" # Newest - assert result[-1] == "20250101_100000" # Oldest - - -class TestCreateGifFromRecentSession: - """Test create_gif_from_recent_session functionality.""" - - def test_create_gif_from_recent_session_no_sessions(self): - """Test GIF creation when no sessions exist.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - creator = GifCreator() - result = creator.create_gif_from_recent_session(screenshot_dir=tmpdir) - - assert result is None - - def test_create_gif_from_recent_session_with_session(self): - """Test GIF creation from most recent session.""" - from scitex.capture.gif import GifCreator - - try: - from PIL import Image - except ImportError: - pytest.skip("PIL not available") - - with tempfile.TemporaryDirectory() as tmpdir: - session_id = "20250104_150000" - - # Create session files - for i in range(3): - img_path = os.path.join(tmpdir, f"{session_id}_{i:04d}_150000000.jpg") - Image.new("RGB", (100, 100), "cyan").save(img_path) - - creator = GifCreator() - result = creator.create_gif_from_recent_session(screenshot_dir=tmpdir) - - assert result is not None - - -class TestConvenienceFunctions: - """Test module-level convenience functions.""" - - def test_create_gif_from_session_function(self): - """Test create_gif_from_session convenience function.""" - from scitex.capture.gif import create_gif_from_session - - with tempfile.TemporaryDirectory() as tmpdir: - result = create_gif_from_session( - session_id="nonexistent", screenshot_dir=tmpdir - ) - assert result is None - - def test_create_gif_from_files_function(self): - """Test create_gif_from_files convenience function.""" - from scitex.capture.gif import create_gif_from_files - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "output.gif") - result = create_gif_from_files(image_paths=[], output_path=output_path) - assert result is None - - def test_create_gif_from_pattern_function(self): - """Test create_gif_from_pattern convenience function.""" - from scitex.capture.gif import create_gif_from_pattern - - with tempfile.TemporaryDirectory() as tmpdir: - pattern = os.path.join(tmpdir, "*.jpg") - result = create_gif_from_pattern(pattern=pattern) - assert result is None - - def test_create_gif_from_latest_session_function(self): - """Test create_gif_from_latest_session convenience function.""" - from scitex.capture.gif import create_gif_from_latest_session - - with tempfile.TemporaryDirectory() as tmpdir: - result = create_gif_from_latest_session(screenshot_dir=tmpdir) - assert result is None - - -class TestModuleExports: - """Test module exports.""" - - def test_gifcreator_importable(self): - """Test GifCreator class can be imported.""" - from scitex.capture.gif import GifCreator - - assert GifCreator is not None - - def test_all_functions_importable(self): - """Test all convenience functions can be imported.""" - from scitex.capture.gif import ( - create_gif_from_files, - create_gif_from_latest_session, - create_gif_from_pattern, - create_gif_from_session, - ) - - assert callable(create_gif_from_session) - assert callable(create_gif_from_files) - assert callable(create_gif_from_pattern) - assert callable(create_gif_from_latest_session) - - def test_functions_from_package_init(self): - """Test GIF functions accessible from package init.""" - from scitex.capture import ( - create_gif_from_files, - create_gif_from_latest_session, - create_gif_from_pattern, - create_gif_from_session, - ) - - assert callable(create_gif_from_session) - - -class TestPILNotAvailable: - """Test behavior when PIL is not available.""" - - def test_create_gif_without_pil(self): - """Test GIF creation handles missing PIL gracefully.""" - from scitex.capture.gif import GifCreator - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "output.gif") - - # Mock PIL import to fail - with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): - creator = GifCreator() - # Pass valid paths but PIL import should fail - result = creator.create_gif_from_files( - image_paths=["/some/path.jpg"], output_path=output_path - ) - - # Should return None when PIL is not available - assert result is None - - -if __name__ == "__main__": - import os - - import pytest - - pytest.main([os.path.abspath(__file__)]) - -# -------------------------------------------------------------------------------- -# Start of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/gif.py -# -------------------------------------------------------------------------------- -# #!/usr/bin/env python3 -# # -*- coding: utf-8 -*- -# # Timestamp: "2025-10-18 09:55:56 (ywatanabe)" -# # File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/gif.py -# # ---------------------------------------- -# from __future__ import annotations -# import os -# -# __FILE__ = "./src/scitex/capture/gif.py" -# __DIR__ = os.path.dirname(__FILE__) -# # ---------------------------------------- -# -# """ -# GIF creation functionality for CAM. -# Create animated GIFs from screenshot sequences for visual summaries. -# """ -# -# import glob -# import re -# from datetime import datetime -# from pathlib import Path -# from typing import List, Optional -# -# -# class GifCreator: -# """ -# Creates animated GIFs from screenshot sequences. -# Useful for creating visual summaries of monitoring sessions or workflows. -# """ -# -# def __init__(self): -# """Initialize GIF creator.""" -# pass -# -# def create_gif_from_session( -# self, -# session_id: str, -# output_path: Optional[str] = None, -# screenshot_dir: str = "~/.scitex/capture", -# duration: float = 0.5, -# optimize: bool = True, -# max_frames: Optional[int] = None, -# ) -> Optional[str]: -# """ -# Create a GIF from a monitoring session's screenshots. -# -# Args: -# session_id: Session ID from monitoring (e.g., "20250823_104523") -# output_path: Output GIF path (auto-generated if None) -# screenshot_dir: Directory containing screenshots -# duration: Duration per frame in seconds (default: 0.5) -# optimize: Optimize GIF for smaller file size (default: True) -# max_frames: Maximum number of frames to include (None = all) -# -# Returns: -# Path to created GIF file, or None if failed -# """ -# try: -# screenshot_dir = Path(screenshot_dir).expanduser() -# -# # Find all screenshots for this session -# pattern = f"{session_id}_*.jpg" -# jpg_files = list(screenshot_dir.glob(pattern)) -# -# # Also try PNG if no JPG files found -# if not jpg_files: -# pattern = f"{session_id}_*.png" -# jpg_files = list(screenshot_dir.glob(pattern)) -# -# if not jpg_files: -# print(f"No screenshots found for session {session_id}") -# return None -# -# # Sort by filename (which includes timestamp) -# jpg_files.sort() -# -# # Limit frames if specified -# if max_frames and len(jpg_files) > max_frames: -# # Take evenly spaced frames -# step = len(jpg_files) // max_frames -# jpg_files = jpg_files[::step][:max_frames] -# -# if output_path is None: -# output_path = screenshot_dir / f"{session_id}_summary.gif" -# else: -# output_path = Path(output_path) -# -# return self.create_gif_from_files( -# image_paths=[str(f) for f in jpg_files], -# output_path=str(output_path), -# duration=duration, -# optimize=optimize, -# ) -# -# except Exception as e: -# print(f"Error creating GIF from session: {e}") -# return None -# -# def create_gif_from_files( -# self, -# image_paths: List[str], -# output_path: str, -# duration: float = 0.5, -# optimize: bool = True, -# loop: int = 0, -# ) -> Optional[str]: -# """ -# Create a GIF from a list of image files. -# -# Args: -# image_paths: List of image file paths -# output_path: Output GIF path -# duration: Duration per frame in seconds (default: 0.5) -# optimize: Optimize GIF for smaller file size (default: True) -# loop: Number of loops (0 = infinite, default: 0) -# -# Returns: -# Path to created GIF file, or None if failed -# """ -# try: -# from PIL import Image -# -# if not image_paths: -# print("No image paths provided") -# return None -# -# # Load all images -# images = [] -# for path in image_paths: -# if not os.path.exists(path): -# print(f"Image not found: {path}") -# continue -# -# try: -# img = Image.open(path) -# # Convert to RGB if necessary (for consistency) -# if img.mode != "RGB": -# img = img.convert("RGB") -# images.append(img) -# except Exception as e: -# print(f"Error loading image {path}: {e}") -# continue -# -# if not images: -# print("No valid images found") -# return None -# -# # Ensure all images have the same size (resize to first image size) -# target_size = images[0].size -# for i in range(1, len(images)): -# if images[i].size != target_size: -# images[i] = images[i].resize(target_size, Image.Resampling.LANCZOS) -# -# # Create output directory if it doesn't exist -# output_path = Path(output_path) -# output_path.parent.mkdir(parents=True, exist_ok=True) -# -# # Save as GIF -# duration_ms = int(duration * 1000) # Convert to milliseconds -# -# images[0].save( -# str(output_path), -# format="GIF", -# save_all=True, -# append_images=images[1:], -# duration=duration_ms, -# loop=loop, -# optimize=optimize, -# ) -# -# if output_path.exists(): -# file_size = output_path.stat().st_size / 1024 # KB -# print( -# f"📹 GIF created: {output_path} ({len(images)} frames, {file_size:.1f}KB)" -# ) -# return str(output_path) -# else: -# return None -# -# except ImportError: -# print( -# "PIL (Pillow) is required for GIF creation. Install with: pip install Pillow" -# ) -# return None -# except Exception as e: -# print(f"Error creating GIF: {e}") -# return None -# -# def create_gif_from_pattern( -# self, -# pattern: str, -# output_path: Optional[str] = None, -# duration: float = 0.5, -# optimize: bool = True, -# max_frames: Optional[int] = None, -# ) -> Optional[str]: -# """ -# Create a GIF from files matching a glob pattern. -# -# Args: -# pattern: Glob pattern for image files (e.g., "/path/screenshots/*.jpg") -# output_path: Output GIF path (auto-generated if None) -# duration: Duration per frame in seconds (default: 0.5) -# optimize: Optimize GIF for smaller file size (default: True) -# max_frames: Maximum number of frames to include (None = all) -# -# Returns: -# Path to created GIF file, or None if failed -# """ -# try: -# # Find matching files -# files = glob.glob(pattern) -# files.sort() # Sort alphabetically -# -# if not files: -# print(f"No files found matching pattern: {pattern}") -# return None -# -# # Limit frames if specified -# if max_frames and len(files) > max_frames: -# step = len(files) // max_frames -# files = files[::step][:max_frames] -# -# if output_path is None: -# # Generate output path based on pattern -# pattern_dir = Path(pattern).parent -# timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") -# output_path = pattern_dir / f"gif_summary_{timestamp}.gif" -# -# return self.create_gif_from_files( -# image_paths=files, -# output_path=str(output_path), -# duration=duration, -# optimize=optimize, -# ) -# -# except Exception as e: -# print(f"Error creating GIF from pattern: {e}") -# return None -# -# def get_recent_sessions( -# self, screenshot_dir: str = "~/.scitex/capture" -# ) -> List[str]: -# """ -# Get list of recent monitoring session IDs. -# -# Args: -# screenshot_dir: Directory containing screenshots -# -# Returns: -# List of session IDs sorted by recency (newest first) -# """ -# try: -# screenshot_dir = Path(screenshot_dir).expanduser() -# -# if not screenshot_dir.exists(): -# return [] -# -# # Find all monitoring session files (format: SESSIONID_NNNN_timestamp.ext) -# session_pattern = re.compile(r"^(\d{8}_\d{6})_\d{4}_.*\.(jpg|png)$") -# -# sessions = set() -# for file in screenshot_dir.iterdir(): -# if file.is_file(): -# match = session_pattern.match(file.name) -# if match: -# sessions.add(match.group(1)) -# -# # Sort by session ID (which includes timestamp) -# return sorted(sessions, reverse=True) -# -# except Exception as e: -# print(f"Error getting recent sessions: {e}") -# return [] -# -# def create_gif_from_recent_session( -# self, -# screenshot_dir: str = "~/.scitex/capture", -# duration: float = 0.5, -# optimize: bool = True, -# max_frames: Optional[int] = None, -# ) -> Optional[str]: -# """ -# Create a GIF from the most recent monitoring session. -# -# Args: -# screenshot_dir: Directory containing screenshots -# duration: Duration per frame in seconds (default: 0.5) -# optimize: Optimize GIF for smaller file size (default: True) -# max_frames: Maximum number of frames to include (None = all) -# -# Returns: -# Path to created GIF file, or None if failed -# """ -# sessions = self.get_recent_sessions(screenshot_dir) -# -# if not sessions: -# print("No monitoring sessions found") -# return None -# -# latest_session = sessions[0] -# print(f"Creating GIF from latest session: {latest_session}") -# -# return self.create_gif_from_session( -# session_id=latest_session, -# screenshot_dir=screenshot_dir, -# duration=duration, -# optimize=optimize, -# max_frames=max_frames, -# ) -# -# -# # Convenience functions for easy usage -# def create_gif_from_session(session_id: str, **kwargs) -> Optional[str]: -# """Create GIF from monitoring session screenshots.""" -# creator = GifCreator() -# return creator.create_gif_from_session(session_id, **kwargs) -# -# -# def create_gif_from_files( -# image_paths: List[str], output_path: str, **kwargs -# ) -> Optional[str]: -# """Create GIF from list of image files.""" -# creator = GifCreator() -# return creator.create_gif_from_files(image_paths, output_path, **kwargs) -# -# -# def create_gif_from_pattern(pattern: str, **kwargs) -> Optional[str]: -# """Create GIF from files matching glob pattern.""" -# creator = GifCreator() -# return creator.create_gif_from_pattern(pattern, **kwargs) -# -# -# def create_gif_from_latest_session(**kwargs) -> Optional[str]: -# """Create GIF from the most recent monitoring session.""" -# creator = GifCreator() -# return creator.create_gif_from_recent_session(**kwargs) -# -# -# # EOF - -# -------------------------------------------------------------------------------- -# End of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/gif.py -# -------------------------------------------------------------------------------- diff --git a/tests/scitex/capture/test_grid.py b/tests/scitex/capture/test_grid.py deleted file mode 100644 index 6132ed838..000000000 --- a/tests/scitex/capture/test_grid.py +++ /dev/null @@ -1,503 +0,0 @@ -# Add your tests here - -if __name__ == "__main__": - import os - - import pytest - - pytest.main([os.path.abspath(__file__)]) - -# -------------------------------------------------------------------------------- -# Start of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/grid.py -# -------------------------------------------------------------------------------- -# #!/usr/bin/env python3 -# # Timestamp: "2026-01-01 19:50:00 (ywatanabe)" -# # File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/grid.py -# # ---------------------------------------- -# from __future__ import annotations -# -# import os -# -# __FILE__ = "./src/scitex/capture/grid.py" -# __DIR__ = os.path.dirname(__FILE__) -# # ---------------------------------------- -# -# """ -# Grid overlay functionality for screenshot coordinate mapping. -# -# Provides utilities to draw coordinate grids on screenshots, -# helping AI agents understand screen positions for precise targeting. -# """ -# -# from pathlib import Path -# from typing import Tuple -# -# -# def draw_grid_overlay( -# filepath: str, -# grid_spacing: int = 100, -# output_path: str = None, -# grid_color: Tuple[int, int, int] = (255, 0, 0), -# text_color: Tuple[int, int, int] = (255, 255, 0), -# line_width: int = 1, -# show_coordinates: bool = True, -# ) -> str: -# """ -# Draw a coordinate grid overlay on a screenshot image. -# -# This helps AI agents and users understand screen coordinates for -# precise click targeting and UI element identification. -# -# Parameters -# ---------- -# filepath : str -# Path to the input image -# grid_spacing : int -# Pixels between grid lines (default: 100) -# output_path : str, optional -# Output path (default: adds '_grid' suffix to input) -# grid_color : tuple -# RGB color for grid lines (default: red) -# text_color : tuple -# RGB color for coordinate labels (default: yellow) -# line_width : int -# Width of grid lines in pixels (default: 1) -# show_coordinates : bool -# Whether to show coordinate labels (default: True) -# -# Returns -# ------- -# str -# Path to the output image with grid overlay -# -# Examples -# -------- -# >>> from scitex.capture.grid import draw_grid_overlay -# >>> draw_grid_overlay("/path/to/screenshot.jpg") -# '/path/to/screenshot_grid.jpg' -# """ -# try: -# from PIL import Image, ImageDraw, ImageFont -# except ImportError: -# raise ImportError( -# "PIL (Pillow) is required for grid overlay. " -# "Install with: pip install Pillow" -# ) -# -# # Load image -# img = Image.open(filepath) -# draw = ImageDraw.Draw(img) -# width, height = img.size -# -# # Try to load a font for coordinate labels -# font = None -# if show_coordinates: -# font = _get_font(size=12) -# -# # Draw vertical lines -# for x in range(0, width, grid_spacing): -# draw.line([(x, 0), (x, height)], fill=grid_color, width=line_width) -# if show_coordinates and font: -# draw.text((x + 2, 10), str(x), fill=text_color, font=font) -# -# # Draw horizontal lines -# for y in range(0, height, grid_spacing): -# draw.line([(0, y), (width, y)], fill=grid_color, width=line_width) -# if show_coordinates and font: -# draw.text((10, y + 2), str(y), fill=text_color, font=font) -# -# # Generate output path -# if output_path is None: -# path_obj = Path(filepath) -# output_path = str(path_obj.parent / f"{path_obj.stem}_grid{path_obj.suffix}") -# -# # Save -# if filepath.lower().endswith((".jpg", ".jpeg")): -# img.save(output_path, "JPEG", quality=85) -# else: -# img.save(output_path) -# -# return output_path -# -# -# def _get_font(size: int = 12): -# """Get a monospace font for coordinate labels.""" -# try: -# from PIL import ImageFont -# except ImportError: -# return None -# -# # Try common system fonts -# font_paths = [ -# "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", -# "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", -# "/usr/share/fonts/truetype/freefont/FreeMono.ttf", -# "C:/Windows/Fonts/consola.ttf", -# "C:/Windows/Fonts/cour.ttf", -# "/System/Library/Fonts/Monaco.ttf", -# "/System/Library/Fonts/Menlo.ttc", -# ] -# -# for font_path in font_paths: -# if Path(font_path).exists(): -# try: -# return ImageFont.truetype(font_path, size) -# except: -# continue -# -# # Fallback to default -# try: -# return ImageFont.load_default() -# except: -# return None -# -# -# def add_monitor_info_overlay( -# filepath: str, -# monitor_info: dict, -# output_path: str = None, -# ) -> str: -# """ -# Add monitor boundary and info overlay to a multi-monitor screenshot. -# -# Parameters -# ---------- -# filepath : str -# Path to the input image -# monitor_info : dict -# Dictionary with monitor information from get_info() -# output_path : str, optional -# Output path (default: adds '_monitors' suffix) -# -# Returns -# ------- -# str -# Path to the output image with monitor overlay -# """ -# try: -# from PIL import Image, ImageDraw -# except ImportError: -# raise ImportError("PIL (Pillow) is required") -# -# img = Image.open(filepath) -# draw = ImageDraw.Draw(img) -# font = _get_font(size=14) -# -# # Draw monitor boundaries and labels -# monitors = monitor_info.get("Monitors", {}).get("Details", []) -# colors = [ -# (255, 0, 0), # Red -# (0, 255, 0), # Green -# (0, 0, 255), # Blue -# (255, 255, 0), # Yellow -# (255, 0, 255), # Magenta -# (0, 255, 255), # Cyan -# ] -# -# for i, mon in enumerate(monitors): -# color = colors[i % len(colors)] -# x = mon.get("X", 0) -# y = mon.get("Y", 0) -# w = mon.get("Width", 0) -# h = mon.get("Height", 0) -# -# # Offset for combined image (Y might be negative) -# # Find minimum Y to offset -# min_y = min(m.get("Y", 0) for m in monitors) if monitors else 0 -# offset_y = -min_y if min_y < 0 else 0 -# -# # Draw rectangle border -# rect_y = y + offset_y -# draw.rectangle( -# [(x, rect_y), (x + w - 1, rect_y + h - 1)], -# outline=color, -# width=3, -# ) -# -# # Draw label -# label = f"Monitor {i}: {w}x{h} @ ({x},{y})" -# if mon.get("Primary"): -# label += " [PRIMARY]" -# -# if font: -# draw.text((x + 10, rect_y + 10), label, fill=color, font=font) -# -# # Generate output path -# if output_path is None: -# path_obj = Path(filepath) -# output_path = str( -# path_obj.parent / f"{path_obj.stem}_monitors{path_obj.suffix}" -# ) -# -# # Save -# if filepath.lower().endswith((".jpg", ".jpeg")): -# img.save(output_path, "JPEG", quality=85) -# else: -# img.save(output_path) -# -# return output_path -# -# -# def draw_cursor_overlay( -# filepath: str, -# cursor_pos: Tuple[int, int] = None, -# output_path: str = None, -# marker_color: Tuple[int, int, int] = (0, 255, 0), -# marker_size: int = 20, -# show_coords: bool = True, -# capture_mode: str = "all", # "all" for all monitors, or monitor index "0", "1", etc. -# ) -> str: -# """ -# Draw cursor position marker on a screenshot. -# -# Helps verify cursor coordinates for UI automation debugging. -# -# Parameters -# ---------- -# filepath : str -# Path to the input image -# cursor_pos : tuple, optional -# System coordinates (x, y). If None, gets current cursor position. -# output_path : str, optional -# Output path (default: adds '_cursor' suffix) -# marker_color : tuple -# RGB color for cursor marker (default: green) -# marker_size : int -# Size of the crosshair marker (default: 20) -# show_coords : bool -# Whether to show coordinate text (default: True) -# capture_mode : str -# "all" for all-monitor capture, or "0", "1" etc. for specific monitor -# -# Returns -# ------- -# str -# Path to the output image with cursor overlay -# """ -# try: -# from PIL import Image, ImageDraw -# except ImportError: -# raise ImportError("PIL (Pillow) is required") -# -# # Get cursor position if not provided -# if cursor_pos is None: -# cursor_pos = _get_cursor_position() -# if cursor_pos is None: -# raise RuntimeError("Could not get cursor position") -# -# sys_x, sys_y = cursor_pos -# -# # Get display info to calculate proper offsets -# display_info = get_display_info() -# monitors = display_info.get("monitors", []) -# -# # Calculate image coordinate offset based on capture mode -# if capture_mode == "all" and monitors: -# # For all-monitor capture: offset by the minimum X and Y -# min_x = min(m.get("X", 0) for m in monitors) -# min_y = min(m.get("Y", 0) for m in monitors) -# img_x = sys_x - min_x -# img_y = sys_y - min_y -# elif monitors and capture_mode.isdigit(): -# # For single monitor capture: offset by that monitor's position -# mon_idx = int(capture_mode) -# if mon_idx < len(monitors): -# mon = monitors[mon_idx] -# img_x = sys_x - mon.get("X", 0) -# img_y = sys_y - mon.get("Y", 0) -# else: -# img_x, img_y = sys_x, sys_y -# else: -# # Fallback: no offset -# img_x, img_y = sys_x, sys_y -# -# img = Image.open(filepath) -# draw = ImageDraw.Draw(img) -# width, height = img.size -# -# # Find which monitor the cursor is on -# cursor_monitor = "?" -# for i, mon in enumerate(monitors): -# mx, my = mon.get("X", 0), mon.get("Y", 0) -# mw, mh = mon.get("Width", 0), mon.get("Height", 0) -# if mx <= sys_x < mx + mw and my <= sys_y < my + mh: -# cursor_monitor = str(i) -# break -# -# # Check if cursor is within image bounds -# if 0 <= img_x < width and 0 <= img_y < height: -# # Draw crosshair -# half = marker_size // 2 -# # Horizontal line -# draw.line( -# [(img_x - half, img_y), (img_x + half, img_y)], -# fill=marker_color, -# width=2, -# ) -# # Vertical line -# draw.line( -# [(img_x, img_y - half), (img_x, img_y + half)], -# fill=marker_color, -# width=2, -# ) -# # Center dot -# draw.ellipse( -# [(img_x - 3, img_y - 3), (img_x + 3, img_y + 3)], -# fill=marker_color, -# ) -# -# # Draw coordinate text with monitor info -# if show_coords: -# font = _get_font(size=12) -# text = f"Mon:{cursor_monitor} Sys:({sys_x},{sys_y}) Img:({img_x},{img_y})" -# if font: -# draw.text((img_x + 10, img_y + 10), text, fill=marker_color, font=font) -# else: -# # Cursor outside image - show info at corner -# font = _get_font(size=12) -# text = f"Outside image: Mon:{cursor_monitor} Sys:({sys_x},{sys_y}) Img:({img_x},{img_y})" -# if font: -# draw.text((10, height - 30), text, fill=(255, 0, 0), font=font) -# -# # Generate output path -# if output_path is None: -# path_obj = Path(filepath) -# output_path = str(path_obj.parent / f"{path_obj.stem}_cursor{path_obj.suffix}") -# -# # Save -# if filepath.lower().endswith((".jpg", ".jpeg")): -# img.save(output_path, "JPEG", quality=85) -# else: -# img.save(output_path) -# -# return output_path -# -# -# def _get_cursor_position() -> Tuple[int, int]: -# """Get current cursor position from Windows via PowerShell.""" -# import subprocess -# -# ps_script = """ -# Add-Type @" -# using System; -# using System.Runtime.InteropServices; -# public class CursorPos { -# [DllImport("user32.dll")] -# public static extern bool GetCursorPos(out POINT lpPoint); -# [StructLayout(LayoutKind.Sequential)] -# public struct POINT { public int X; public int Y; } -# } -# "@ -# $pos = New-Object CursorPos+POINT -# [CursorPos]::GetCursorPos([ref]$pos) | Out-Null -# Write-Output "$($pos.X),$($pos.Y)" -# """ -# try: -# result = subprocess.run( -# ["powershell.exe", "-Command", ps_script], -# capture_output=True, -# text=True, -# timeout=5, -# ) -# if result.returncode == 0: -# parts = result.stdout.strip().split(",") -# return (int(parts[0]), int(parts[1])) -# except Exception: -# pass -# return None -# -# -# def _get_dpi_scale() -> float: -# """Get Windows DPI scaling factor via PowerShell.""" -# import subprocess -# -# ps_script = """ -# Add-Type @" -# using System; -# using System.Runtime.InteropServices; -# public class DpiInfo { -# [DllImport("user32.dll")] -# public static extern IntPtr GetDC(IntPtr hwnd); -# [DllImport("gdi32.dll")] -# public static extern int GetDeviceCaps(IntPtr hdc, int nIndex); -# [DllImport("user32.dll")] -# public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc); -# public const int LOGPIXELSX = 88; -# } -# "@ -# $hdc = [DpiInfo]::GetDC([IntPtr]::Zero) -# $dpi = [DpiInfo]::GetDeviceCaps($hdc, 88) -# [DpiInfo]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null -# $scale = $dpi / 96.0 -# Write-Output $scale -# """ -# try: -# result = subprocess.run( -# ["powershell.exe", "-Command", ps_script], -# capture_output=True, -# text=True, -# timeout=5, -# ) -# if result.returncode == 0: -# return float(result.stdout.strip()) -# except Exception: -# pass -# return 1.0 -# -# -# def get_display_info() -> dict: -# """Get comprehensive display info including DPI, resolution, monitors.""" -# import subprocess -# -# ps_script = """ -# Add-Type -AssemblyName System.Windows.Forms -# $screens = [System.Windows.Forms.Screen]::AllScreens -# $info = @() -# foreach ($s in $screens) { -# $info += @{ -# Name = $s.DeviceName -# Primary = $s.Primary -# X = $s.Bounds.X -# Y = $s.Bounds.Y -# Width = $s.Bounds.Width -# Height = $s.Bounds.Height -# } -# } -# $info | ConvertTo-Json -Compress -# """ -# try: -# result = subprocess.run( -# ["powershell.exe", "-Command", ps_script], -# capture_output=True, -# text=True, -# timeout=5, -# ) -# if result.returncode == 0: -# import json -# -# monitors = json.loads(result.stdout.strip()) -# if not isinstance(monitors, list): -# monitors = [monitors] -# dpi_scale = _get_dpi_scale() -# return { -# "monitors": monitors, -# "dpi_scale": dpi_scale, -# "dpi_percent": int(dpi_scale * 100), -# } -# except Exception: -# pass -# return {"monitors": [], "dpi_scale": 1.0, "dpi_percent": 100} -# -# -# __all__ = [ -# "draw_grid_overlay", -# "add_monitor_info_overlay", -# "draw_cursor_overlay", -# "get_display_info", -# ] -# -# # EOF - -# -------------------------------------------------------------------------------- -# End of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/grid.py -# -------------------------------------------------------------------------------- diff --git a/tests/scitex/capture/test_session.py b/tests/scitex/capture/test_session.py deleted file mode 100644 index 3f14d98cc..000000000 --- a/tests/scitex/capture/test_session.py +++ /dev/null @@ -1,373 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.capture.session module. - -Tests Session context manager for automatic capture start/stop: -- Session initialization with parameters -- Context manager protocol (__enter__/__exit__) -- session() factory function -""" - -import os -import tempfile -from unittest.mock import MagicMock, patch - -import pytest - - -class TestSessionInit: - """Test Session class initialization.""" - - def test_default_initialization(self): - """Test Session initializes with default parameters.""" - from scitex.capture.session import Session - - sess = Session() - - assert sess.output_dir == "~/.scitex/capture/" - assert sess.interval == 1.0 - assert sess.jpeg is True - assert sess.quality == 60 - assert sess.on_capture is None - assert sess.on_error is None - assert sess.verbose is True - assert sess.monitor_id == 0 - assert sess.capture_all is False - assert sess.worker is None - - def test_custom_initialization(self): - """Test Session initializes with custom parameters.""" - from scitex.capture.session import Session - - on_capture = lambda x: None - on_error = lambda x: None - - sess = Session( - output_dir="/custom/path", - interval=2.5, - jpeg=False, - quality=90, - on_capture=on_capture, - on_error=on_error, - verbose=False, - monitor_id=1, - capture_all=True, - ) - - assert sess.output_dir == "/custom/path" - assert sess.interval == 2.5 - assert sess.jpeg is False - assert sess.quality == 90 - assert sess.on_capture is on_capture - assert sess.on_error is on_error - assert sess.verbose is False - assert sess.monitor_id == 1 - assert sess.capture_all is True - - -class TestSessionContextManager: - """Test Session context manager protocol.""" - - def test_enter_starts_monitoring(self): - """Test __enter__ starts capture monitoring.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - sess = Session(output_dir=tmpdir, verbose=False) - - result = sess.__enter__() - - assert result is sess - assert sess.worker is not None - assert sess.worker.running is True - - sess.__exit__(None, None, None) - assert sess.worker.running is False - - def test_exit_stops_monitoring(self): - """Test __exit__ stops capture monitoring.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - sess = Session(output_dir=tmpdir, verbose=False) - sess.__enter__() - - worker = sess.worker - assert worker.running is True - - result = sess.__exit__(None, None, None) - - assert result is False # Don't suppress exceptions - assert worker.running is False - - def test_exit_returns_false(self): - """Test __exit__ returns False (doesn't suppress exceptions).""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - sess = Session(output_dir=tmpdir, verbose=False) - sess.__enter__() - - # Test with exception info - result = sess.__exit__(ValueError, ValueError("test"), None) - assert result is False - - def test_context_manager_with_statement(self): - """Test Session works with 'with' statement.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - worker_ref = None - - with Session(output_dir=tmpdir, verbose=False) as sess: - worker_ref = sess.worker - assert sess.worker.running is True - - # After exiting context, worker should be stopped - assert worker_ref.running is False - - def test_context_manager_passes_parameters(self): - """Test Session passes parameters to start_monitor.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with Session( - output_dir=tmpdir, - interval=0.5, - jpeg=False, - quality=80, - verbose=False, - monitor_id=2, - capture_all=True, - ) as sess: - worker = sess.worker - - assert worker.interval_sec == 0.5 - assert worker.use_jpeg is False - assert worker.jpeg_quality == 80 - assert worker.verbose is False - assert worker.monitor == 2 - assert worker.capture_all is True - - -class TestSessionCallbacks: - """Test Session callback functionality.""" - - def test_on_capture_callback_passed(self): - """Test on_capture callback is passed to worker.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - captures = [] - callback = lambda p: captures.append(p) - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with Session( - output_dir=tmpdir, on_capture=callback, verbose=False - ) as sess: - assert sess.worker.on_capture is callback - - def test_on_error_callback_passed(self): - """Test on_error callback is passed to worker.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - errors = [] - callback = lambda e: errors.append(e) - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with Session( - output_dir=tmpdir, on_error=callback, verbose=False - ) as sess: - assert sess.worker.on_error is callback - - -class TestSessionExceptionHandling: - """Test Session behavior with exceptions.""" - - def test_exception_in_context_still_stops_worker(self): - """Test worker stops even when exception occurs in context.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import Session - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - sess = Session(output_dir=tmpdir, verbose=False) - worker_ref = None - - try: - with sess: - worker_ref = sess.worker - raise ValueError("Test exception") - except ValueError: - pass - - # Worker should still be stopped - assert worker_ref.running is False - - -class TestSessionFactoryFunction: - """Test session() factory function.""" - - def test_session_returns_session_instance(self): - """Test session() returns a Session instance.""" - from scitex.capture.session import Session, session - - result = session() - assert isinstance(result, Session) - - def test_session_passes_kwargs(self): - """Test session() passes kwargs to Session.__init__.""" - from scitex.capture.session import session - - with tempfile.TemporaryDirectory() as tmpdir: - sess = session( - output_dir=tmpdir, - interval=2.0, - jpeg=False, - quality=75, - verbose=False, - monitor_id=3, - capture_all=True, - ) - - assert sess.output_dir == tmpdir - assert sess.interval == 2.0 - assert sess.jpeg is False - assert sess.quality == 75 - assert sess.verbose is False - assert sess.monitor_id == 3 - assert sess.capture_all is True - - def test_session_factory_works_as_context_manager(self): - """Test session() result works as context manager.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.session import session - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with session(output_dir=tmpdir, verbose=False) as sess: - assert sess.worker is not None - assert sess.worker.running is True - - assert sess.worker.running is False - - -class TestModuleExports: - """Test module exports.""" - - def test_session_class_importable(self): - """Test Session class can be imported.""" - from scitex.capture.session import Session - - assert Session is not None - - def test_session_function_importable(self): - """Test session function can be imported.""" - from scitex.capture.session import session - - assert callable(session) - - def test_session_from_package_init(self): - """Test session is accessible from package init.""" - from scitex.capture import session - - assert callable(session) - - -if __name__ == "__main__": - import os - - import pytest - - pytest.main([os.path.abspath(__file__)]) - -# -------------------------------------------------------------------------------- -# Start of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/session.py -# -------------------------------------------------------------------------------- -# #!/usr/bin/env python3 -# # -*- coding: utf-8 -*- -# # Timestamp: "2025-10-18 09:55:53 (ywatanabe)" -# # File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/session.py -# # ---------------------------------------- -# from __future__ import annotations -# import os -# -# __FILE__ = "./src/scitex/capture/session.py" -# __DIR__ = os.path.dirname(__FILE__) -# # ---------------------------------------- -# -# -# class Session: -# """Context manager for CAM session with automatic start/stop.""" -# -# def __init__( -# self, -# output_dir: str = "~/.scitex/capture/", -# interval: float = 1.0, -# jpeg: bool = True, -# quality: int = 60, -# on_capture=None, -# on_error=None, -# verbose: bool = True, -# monitor_id: int = 0, -# capture_all: bool = False, -# ): -# """Initialize session parameters.""" -# self.output_dir = output_dir -# self.interval = interval -# self.jpeg = jpeg -# self.quality = quality -# self.on_capture = on_capture -# self.on_error = on_error -# self.verbose = verbose -# self.monitor_id = monitor_id -# self.capture_all = capture_all -# self.worker = None -# -# def __enter__(self): -# """Start monitoring when entering context.""" -# from .utils import start_monitor -# -# self.worker = start_monitor( -# output_dir=self.output_dir, -# interval=self.interval, -# jpeg=self.jpeg, -# quality=self.quality, -# on_capture=self.on_capture, -# on_error=self.on_error, -# verbose=self.verbose, -# monitor_id=self.monitor_id, -# capture_all=self.capture_all, -# ) -# return self -# -# def __exit__(self, exc_type, exc_val, exc_tb): -# """Stop monitoring when exiting context.""" -# from .utils import stop_monitor -# -# stop_monitor() -# return False -# -# -# def session(**kwargs): -# """Create a new session context manager.""" -# return Session(**kwargs) -# -# -# # EOF - -# -------------------------------------------------------------------------------- -# End of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/session.py -# -------------------------------------------------------------------------------- diff --git a/tests/scitex/capture/test_utils.py b/tests/scitex/capture/test_utils.py deleted file mode 100644 index d8443894d..000000000 --- a/tests/scitex/capture/test_utils.py +++ /dev/null @@ -1,1175 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for scitex.capture.utils module. - -Tests utility functions for screen capture: -- capture() function (main API) -- take_screenshot() simple interface -- start_monitor()/stop_monitor() for continuous capture -- Cache management -- Category detection -""" - -import os -import sys -import tempfile -import time -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - - -class TestCaptureFunction: - """Test the main capture() function.""" - - def test_capture_returns_none_on_failure(self): - """Test capture returns None when capture fails.""" - from scitex.capture.capture import ScreenshotWorker - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with patch.object(ScreenshotWorker, "_is_wsl", return_value=False): - from scitex.capture.utils import capture - - result = capture( - path=os.path.join(tmpdir, "test.jpg"), - verbose=False, - auto_categorize=False, - ) - assert result is None - - def test_capture_with_message(self): - """Test capture with message parameter.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import capture - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with patch.object(ScreenshotWorker, "_is_wsl", return_value=False): - result = capture( - message="test message", - path=os.path.join(tmpdir, "test.jpg"), - verbose=False, - ) - assert result is None - - -class TestCaptureMonitorSettings: - """Test capture function monitor settings.""" - - def test_capture_with_monitor_id(self): - """Test capture with specific monitor_id.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import capture - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with patch.object(ScreenshotWorker, "_is_wsl", return_value=False): - result = capture( - path=os.path.join(tmpdir, "test.jpg"), - monitor_id=1, - verbose=False, - ) - assert result is None - - def test_capture_all_monitors(self): - """Test capture with capture_all=True.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import capture - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with patch.object(ScreenshotWorker, "_is_wsl", return_value=False): - result = capture( - path=os.path.join(tmpdir, "test.jpg"), - capture_all=True, - verbose=False, - ) - assert result is None - - def test_capture_all_shorthand(self): - """Test capture with all=True shorthand.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import capture - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - with patch.object(ScreenshotWorker, "_is_wsl", return_value=False): - result = capture( - path=os.path.join(tmpdir, "test.jpg"), - all=True, - verbose=False, - ) - assert result is None - - -class TestCaptureURLCapture: - """Test URL capture functionality.""" - - def test_capture_url_returns_none_on_failure(self): - """Test URL capture returns None when all methods fail.""" - from scitex.capture.utils import capture - - # Mock playwright import to fail - with patch.dict(sys.modules, {"playwright.sync_api": None}): - with tempfile.TemporaryDirectory() as tmpdir: - result = capture( - url="http://localhost:8000", - path=os.path.join(tmpdir, "test.jpg"), - verbose=False, - ) - # Returns None when playwright not available and not on WSL - assert result is None - - -class TestCaptureAppCapture: - """Test app-specific capture functionality.""" - - def test_capture_app_not_found(self): - """Test capture when specified app is not found.""" - from scitex.capture.utils import _manager, capture - - with patch.object( - _manager, "get_info", return_value={"Windows": {"Details": []}} - ): - result = capture( - app="nonexistent_app", - verbose=False, - ) - assert result is None - - def test_capture_app_found_but_capture_fails(self): - """Test capture when app is found but capture fails.""" - from scitex.capture.utils import _manager, capture - - mock_windows = { - "Windows": { - "Details": [ - { - "ProcessName": "chrome", - "Title": "Google Chrome", - "Handle": 12345, - } - ] - } - } - - with patch.object(_manager, "get_info", return_value=mock_windows): - with patch.object(_manager, "capture_window", return_value=None): - result = capture( - app="chrome", - verbose=False, - ) - assert result is None - - -class TestTakeScreenshot: - """Test take_screenshot simple interface.""" - - def test_take_screenshot_returns_none_on_failure(self): - """Test take_screenshot returns None when capture fails.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import take_screenshot - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - result = take_screenshot() - assert result is None - - def test_take_screenshot_with_custom_path(self): - """Test take_screenshot with custom output path.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import take_screenshot - - with tempfile.TemporaryDirectory() as tmpdir: - output_path = os.path.join(tmpdir, "custom.jpg") - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - result = take_screenshot(output_path=output_path) - assert result is None - - def test_take_screenshot_with_quality(self): - """Test take_screenshot with quality parameter.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import take_screenshot - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - result = take_screenshot(jpeg=True, quality=50) - assert result is None - - -class TestStartStopMonitor: - """Test start_monitor and stop_monitor functions.""" - - def test_start_monitor_returns_worker(self): - """Test start_monitor returns a ScreenshotWorker.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import start_monitor, stop_monitor - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - worker = start_monitor( - output_dir=tmpdir, - interval=0.5, - verbose=False, - ) - - assert isinstance(worker, ScreenshotWorker) - assert worker.running is True - - stop_monitor() - assert worker.running is False - - def test_start_monitor_with_callbacks(self): - """Test start_monitor with on_capture and on_error callbacks.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import start_monitor, stop_monitor - - with tempfile.TemporaryDirectory() as tmpdir: - captures = [] - errors = [] - - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - worker = start_monitor( - output_dir=tmpdir, - on_capture=lambda p: captures.append(p), - on_error=lambda e: errors.append(e), - verbose=False, - ) - - assert worker.on_capture is not None - assert worker.on_error is not None - - stop_monitor() - - def test_start_monitor_with_monitor_settings(self): - """Test start_monitor with monitor_id and capture_all.""" - from scitex.capture.capture import ScreenshotWorker - from scitex.capture.utils import start_monitor, stop_monitor - - with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(ScreenshotWorker, "_take_screenshot", return_value=None): - worker = start_monitor( - output_dir=tmpdir, - monitor_id=2, - capture_all=True, - verbose=False, - ) - - assert worker.monitor == 2 - assert worker.capture_all is True - - stop_monitor() - - def test_stop_monitor_when_not_started(self): - """Test stop_monitor is safe when not started.""" - from scitex.capture.utils import stop_monitor - - # Should not raise - stop_monitor() - - -class TestCacheSizeManagement: - """Test cache size management functionality.""" - - def test_manage_cache_size_does_nothing_under_limit(self): - """Test cache management doesn't delete files under limit.""" - from scitex.capture.utils import _manage_cache_size - - with tempfile.TemporaryDirectory() as tmpdir: - cache_dir = Path(tmpdir) - - # Create small test files - for i in range(5): - (cache_dir / f"test_{i}.jpg").write_bytes(b"x" * 100) - - _manage_cache_size(cache_dir, max_size_gb=1.0) - - # All files should still exist - assert len(list(cache_dir.glob("*.jpg"))) == 5 - - def test_manage_cache_size_deletes_old_files(self): - """Test cache management deletes oldest files when over limit.""" - from scitex.capture.utils import _manage_cache_size - - with tempfile.TemporaryDirectory() as tmpdir: - cache_dir = Path(tmpdir) - - # Create files with different modification times - for i in range(5): - file_path = cache_dir / f"test_{i}.jpg" - file_path.write_bytes(b"x" * 1024 * 1024) # 1MB each - time.sleep(0.1) # Ensure different mtimes - - # Set limit to ~2MB (should delete 3 oldest files) - _manage_cache_size(cache_dir, max_size_gb=0.000002) - - # Some files should be deleted - remaining = list(cache_dir.glob("*.jpg")) - assert len(remaining) < 5 - - def test_manage_cache_size_nonexistent_dir(self): - """Test cache management handles nonexistent directory.""" - from scitex.capture.utils import _manage_cache_size - - # Should not raise - _manage_cache_size(Path("/nonexistent/path"), max_size_gb=1.0) - - def test_manage_cache_size_handles_png_files(self): - """Test cache management also handles PNG files.""" - from scitex.capture.utils import _manage_cache_size - - with tempfile.TemporaryDirectory() as tmpdir: - cache_dir = Path(tmpdir) - - # Create PNG files - for i in range(3): - (cache_dir / f"test_{i}.png").write_bytes(b"x" * 100) - - _manage_cache_size(cache_dir, max_size_gb=1.0) - - # All PNG files should still exist - assert len(list(cache_dir.glob("*.png"))) == 3 - - -class TestCategoryDetection: - """Test screenshot category detection.""" - - def test_detect_category_returns_stdout_by_default(self): - """Test _detect_category returns 'stdout' by default.""" - from scitex.capture.utils import _detect_category - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = os.path.join(tmpdir, "test.jpg") - - try: - from PIL import Image - - img = Image.new("RGB", (10, 10), color="white") - img.save(test_file) - - result = _detect_category(test_file) - assert result == "stdout" - except ImportError: - pytest.skip("PIL not available") - - def test_detect_category_detects_red_as_error(self): - """Test _detect_category detects red-dominant images as error.""" - from scitex.capture.utils import _detect_category - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = os.path.join(tmpdir, "test.jpg") - - try: - from PIL import Image - - # Create mostly red image (>5% red pixels) - img = Image.new("RGB", (100, 100), color=(255, 50, 50)) - img.save(test_file) - - result = _detect_category(test_file) - assert result == "error" - except ImportError: - pytest.skip("PIL not available") - - def test_detect_category_detects_error_from_filename(self): - """Test _detect_category detects error keywords in filename.""" - from scitex.capture.utils import _detect_category - - # Without a valid image, it falls back to filename-based detection - result = _detect_category("/path/to/error_screenshot.jpg") - assert result == "stderr" - - result = _detect_category("/path/to/fail_test.jpg") - assert result == "stderr" - - def test_detect_category_detects_warning_from_filename(self): - """Test _detect_category detects warning keywords in filename.""" - from scitex.capture.utils import _detect_category - - result = _detect_category("/path/to/warning_dialog.jpg") - assert result == "stderr" - - def test_detect_category_handles_missing_file(self): - """Test _detect_category handles missing file gracefully.""" - from scitex.capture.utils import _detect_category - - # Nonexistent file should return stdout (default) - result = _detect_category("/nonexistent/file.jpg") - assert result == "stdout" - - -class TestExceptionContextDetection: - """Test exception context detection.""" - - def test_is_in_exception_context_false_normally(self): - """Test _is_in_exception_context returns False normally.""" - from scitex.capture.utils import _is_in_exception_context - - assert _is_in_exception_context() is False - - def test_is_in_exception_context_true_in_except(self): - """Test _is_in_exception_context returns True in except block.""" - from scitex.capture.utils import _is_in_exception_context - - try: - raise ValueError("Test error") - except ValueError: - assert _is_in_exception_context() is True - - -class TestMessageMetadata: - """Test message metadata functionality.""" - - def test_add_message_metadata_with_pil(self): - """Test _add_message_metadata adds EXIF metadata with PIL.""" - from scitex.capture.utils import _add_message_metadata - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = os.path.join(tmpdir, "test.jpg") - - try: - from PIL import Image - - img = Image.new("RGB", (10, 10), color="white") - img.save(test_file) - - _add_message_metadata(test_file, "Test message") - - # Verify file still exists - assert os.path.exists(test_file) - except ImportError: - pytest.skip("PIL not available") - - def test_add_message_metadata_creates_text_file_fallback(self): - """Test _add_message_metadata creates text file when PIL fails.""" - from scitex.capture.utils import _add_message_metadata - - with tempfile.TemporaryDirectory() as tmpdir: - test_file = os.path.join(tmpdir, "test.jpg") - Path(test_file).touch() - - # Mock PIL to fail - with patch.dict(sys.modules, {"PIL": None, "PIL.Image": None}): - _add_message_metadata(test_file, "Test message") - - # Text file should be created - txt_file = Path(test_file).with_suffix(".txt") - assert txt_file.exists() - content = txt_file.read_text() - assert "Test message" in content - - -class TestModuleExports: - """Test module exports.""" - - def test_all_exports_accessible(self): - """Test all __all__ exports are accessible.""" - from scitex.capture import utils - - for name in utils.__all__: - assert hasattr(utils, name) - - def test_capture_is_exported(self): - """Test capture function is exported.""" - from scitex.capture.utils import capture - - assert callable(capture) - - def test_take_screenshot_is_exported(self): - """Test take_screenshot function is exported.""" - from scitex.capture.utils import take_screenshot - - assert callable(take_screenshot) - - def test_start_stop_monitor_exported(self): - """Test start_monitor and stop_monitor are exported.""" - from scitex.capture.utils import start_monitor, stop_monitor - - assert callable(start_monitor) - assert callable(stop_monitor) - - -if __name__ == "__main__": - import os - - import pytest - - pytest.main([os.path.abspath(__file__)]) - -# -------------------------------------------------------------------------------- -# Start of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/utils.py -# -------------------------------------------------------------------------------- -# #!/usr/bin/env python3 -# # -*- coding: utf-8 -*- -# # Timestamp: "2025-10-18 09:55:52 (ywatanabe)" -# # File: /home/ywatanabe/proj/scitex-code/src/scitex/capture/utils.py -# # ---------------------------------------- -# from __future__ import annotations -# import os -# -# __FILE__ = "./src/scitex/capture/utils.py" -# __DIR__ = os.path.dirname(__FILE__) -# # ---------------------------------------- -# -# import sys -# -# """ -# Utility functions for easy screen capture. -# """ -# -# from datetime import datetime -# from pathlib import Path -# from typing import Optional -# -# from .capture import CaptureManager, ScreenshotWorker -# -# # Global manager instance -# _manager = CaptureManager() -# -# -# def _manage_cache_size(cache_dir: Path, max_size_gb: float = 1.0): -# """ -# Manage cache directory size by removing old files if size exceeds limit. -# -# Parameters -# ---------- -# cache_dir : Path -# Directory to manage -# max_size_gb : float -# Maximum size in GB (default: 1.0) -# """ -# if not cache_dir.exists(): -# return -# -# max_size_bytes = max_size_gb * 1024 * 1024 * 1024 # Convert GB to bytes -# -# # Get all files with their sizes and modification times -# files = [] -# total_size = 0 -# -# for file_path in cache_dir.glob("*.jpg"): -# if file_path.is_file(): -# size = file_path.stat().st_size -# mtime = file_path.stat().st_mtime -# files.append((file_path, size, mtime)) -# total_size += size -# -# # Also check PNG files -# for file_path in cache_dir.glob("*.png"): -# if file_path.is_file(): -# size = file_path.stat().st_size -# mtime = file_path.stat().st_mtime -# files.append((file_path, size, mtime)) -# total_size += size -# -# # If under limit, nothing to do -# if total_size <= max_size_bytes: -# return -# -# # Sort by modification time (oldest first) -# files.sort(key=lambda x: x[2]) -# -# # Remove oldest files until under limit -# for file_path, size, _ in files: -# if total_size <= max_size_bytes: -# break -# try: -# file_path.unlink() -# total_size -= size -# except: -# pass # File might be in use -# -# -# def capture( -# message: str = None, -# path: str = None, -# quality: int = 85, -# auto_categorize: bool = True, -# verbose: bool = True, -# monitor_id: int = 0, -# capture_all: bool = False, -# all: bool = False, -# app: str = None, -# url: str = None, -# url_wait: int = 3, -# url_width: int = 1920, -# url_height: int = 1080, -# max_cache_gb: float = 1.0, -# ) -> str: -# """ -# Take a screenshot - monitor, window, browser, or everything. -# -# Parameters -# ---------- -# message : str, optional -# Message to include in filename -# path : str, optional -# Output path (default: ~/.scitex/capture/) -# quality : int -# JPEG quality (1-100) -# all : bool -# Capture all monitors -# app : str, optional -# App name to capture (e.g., "chrome", "code") -# url : str, optional -# URL to capture via browser (e.g., "http://127.0.0.1:8000/") -# url_wait : int -# Seconds to wait for page load (default: 3) -# url_width : int -# Browser window width for URL capture (default: 1920) -# url_height : int -# Browser window height for URL capture (default: 1080) -# monitor_id : int -# Monitor to capture (0-based, default: 0) -# -# Returns -# ------- -# str -# Path to saved screenshot -# -# Examples -# -------- -# >>> from scitex import capture -# >>> -# >>> capture.snap() # Current monitor -# >>> capture.snap(all=True) # All monitors -# >>> capture.snap(app="chrome") # Chrome window -# >>> capture.snap(url="http://localhost:8000") # Browser page -# """ -# # Handle URL capture -# if url: -# # Auto-add http:// if no protocol specified -# if not url.startswith(("http://", "https://", "file://")): -# url = f"http://{url}" -# -# # Try Playwright first (headless, non-interfering) -# try: -# from playwright.sync_api import sync_playwright -# -# if path is None: -# timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") -# url_slug = ( -# url.replace("://", "_").replace("/", "_").replace(":", "_")[:30] -# ) -# path = f"~/.scitex/capture/{timestamp_str}-url-{url_slug}.jpg" -# -# path = os.path.expanduser(path) -# -# if verbose: -# print(f"📸 Capturing URL: {url}") -# -# # Check if DISPLAY is set (WSL with X11 forward causes visible browser) -# import os as _os -# -# original_display = _os.environ.get("DISPLAY") -# -# # Force headless by unsetting DISPLAY temporarily -# if original_display: -# _os.environ.pop("DISPLAY", None) -# -# try: -# with sync_playwright() as p: -# # Use stealth args from scitex.browser -# stealth_args = [ -# "--no-sandbox", -# "--disable-dev-shm-usage", -# "--disable-blink-features=AutomationControlled", -# "--window-size=1920,1080", -# ] -# browser = p.chromium.launch(headless=True, args=stealth_args) -# context = browser.new_context( -# viewport={"width": url_width, "height": url_height} -# ) -# page = context.new_page() -# # Use domcontentloaded for faster capture, with longer timeout -# page.goto(url, wait_until="domcontentloaded", timeout=30000) -# # Wait additional time for rendering -# page.wait_for_timeout(url_wait * 1000) -# page.screenshot( -# path=path, -# type="jpeg", -# quality=quality, -# full_page=False, -# ) -# browser.close() -# finally: -# # Restore DISPLAY -# if original_display: -# _os.environ["DISPLAY"] = original_display -# -# if Path(path).exists(): -# if verbose: -# print(f"📸 URL: {path}") -# return path -# -# except ImportError: -# if verbose: -# print( -# "⚠️ Playwright not installed: pip install 'scitex[capture-browser]'" -# ) -# pass # Try PowerShell fallback -# except Exception as e: -# if verbose: -# print(f"⚠️ Playwright failed: {e}") -# pass # Try PowerShell fallback -# -# # For WSL: Fallback to Windows-side browser -# if sys.platform == "linux" and "microsoft" in os.uname().release.lower(): -# try: -# import base64 -# import json -# import subprocess -# -# # Generate output path -# if path is None: -# timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") -# url_slug = ( -# url.replace("://", "_").replace("/", "_").replace(":", "_")[:30] -# ) -# path = f"~/.scitex/capture/{timestamp_str}-url-{url_slug}.jpg" -# -# path = os.path.expanduser(path) -# -# if verbose: -# print(f"📸 Capturing URL on Windows: {url}") -# -# # Use PowerShell script on Windows host -# script_dir = Path(__file__).parent / "powershell" -# script_path = script_dir / "capture_url.ps1" -# -# if script_path.exists(): -# # Find PowerShell -# ps_paths = [ -# "powershell.exe", -# "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", -# ] -# ps_exe = None -# for p in ps_paths: -# try: -# result = subprocess.run( -# [p, "-Command", "echo test"], -# capture_output=True, -# timeout=1, -# ) -# if result.returncode == 0: -# ps_exe = p -# break -# except: -# continue -# -# if ps_exe: -# cmd = [ -# ps_exe, -# "-NoProfile", -# "-ExecutionPolicy", -# "Bypass", -# "-File", -# str(script_path), -# "-Url", -# url, -# "-WaitSeconds", -# str(url_wait), -# "-WindowWidth", -# str(url_width), -# "-WindowHeight", -# str(url_height), -# ] -# -# result = subprocess.run( -# cmd, capture_output=True, text=True, timeout=30 -# ) -# -# if result.returncode == 0 and result.stdout.strip(): -# # Parse JSON -# lines = result.stdout.strip().split("\n") -# for line in lines: -# if line.strip().startswith("{"): -# data = json.loads(line) -# if data.get("Success"): -# img_data = base64.b64decode( -# data.get("Base64Data", "") -# ) -# -# # Save as JPEG -# try: -# import io -# -# from PIL import Image -# -# img = Image.open(io.BytesIO(img_data)) -# if img.mode == "RGBA": -# rgb_img = Image.new( -# "RGB", -# img.size, -# (255, 255, 255), -# ) -# rgb_img.paste(img, mask=img.split()[3]) -# img = rgb_img -# img.save( -# path, -# "JPEG", -# quality=quality, -# optimize=True, -# ) -# -# if verbose: -# print(f"📸 URL: {path}") -# return path -# except ImportError: -# # Save as PNG fallback -# with open( -# path.replace(".jpg", ".png"), -# "wb", -# ) as f: -# f.write(img_data) -# return path.replace(".jpg", ".png") -# break -# -# except Exception as e: -# if verbose: -# print(f"⚠️ PowerShell URL capture failed: {e}") -# -# # If all methods failed -# if verbose: -# print( -# "❌ URL capture failed - Playwright not available and PowerShell failed" -# ) -# return None -# -# # Handle app-specific capture -# if app: -# info = _manager.get_info() -# windows = info.get("Windows", {}).get("Details", []) -# -# # Search for matching window -# app_lower = app.lower() -# matching_window = None -# -# for win in windows: -# process_name = win.get("ProcessName", "").lower() -# title = win.get("Title", "").lower() -# -# if app_lower in process_name or app_lower in title: -# matching_window = win -# break -# -# if matching_window: -# handle = matching_window.get("Handle") -# result_path = _manager.capture_window(handle, path) -# -# if result_path and verbose: -# print(f"📸 {matching_window.get('ProcessName')}: {result_path}") -# -# return result_path -# else: -# if verbose: -# print(f"❌ App '{app}' not found in visible windows") -# return None -# -# # Handle 'all' shorthand -# if all: -# capture_all = True -# -# # Take screenshot first to analyze it -# timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] -# temp_dir = "/tmp/scitex_capture_temp" -# Path(temp_dir).mkdir(exist_ok=True) -# -# # Take screenshot to temp location -# use_jpeg = ( -# True if path is None or path.lower().endswith((".jpg", ".jpeg")) else False -# ) -# worker = ScreenshotWorker( -# output_dir=temp_dir, -# use_jpeg=use_jpeg, -# jpeg_quality=quality, -# verbose=verbose, # Use the verbose parameter passed by user -# ) -# -# worker.session_id = "capture" -# worker.screenshot_count = 0 -# worker.monitor = monitor_id -# worker.capture_all = capture_all -# temp_path = worker._take_screenshot() -# -# if not temp_path: -# return None -# -# # Detect category if auto_categorize enabled -# category = "stdout" -# if auto_categorize: -# # First check if we're in an exception context -# if _is_in_exception_context(): -# category = "stderr" -# # Add exception info to message -# import traceback -# -# exc_info = traceback.format_exc(limit=3) -# if message: -# message = f"{message}\n{exc_info}" -# else: -# message = exc_info -# else: -# # Try visual detection -# category = _detect_category(temp_path) -# -# # Build monitor/scope tag for filename -# scope_tag = "" -# if capture_all: -# scope_tag = "-all-monitors" -# elif monitor_id > 0: -# scope_tag = f"-monitor{monitor_id}" -# # monitor_id=0 (primary) gets no tag for cleaner default names -# -# # Normalize message for filename -# normalized_msg = "" -# if message: -# # Remove special chars, keep only alphanumeric and spaces -# import re -# -# normalized = re.sub(r"[^\w\s-]", "", message.split("\n")[0]) # First line only -# normalized = re.sub(r"[-\s]+", "-", normalized).strip("-") -# normalized_msg = f"-{normalized[:50]}" if normalized else "" # Limit length -# -# # Add category suffix -# category_suffix = f"-{category}" -# -# # Handle path with category and message -# if path is None: -# # Include monitor/scope info in filename -# path = f"~/.scitex/capture/.jpg" -# -# # Expand user home -# path = os.path.expanduser(path) -# -# # Replace placeholders -# if "" in path: -# path = path.replace("", timestamp) -# if "" in path: -# path = path.replace("", scope_tag) -# if "" in path: -# path = path.replace("", normalized_msg) -# if "" in path: -# path = path.replace("", category_suffix) -# -# # Ensure directory exists -# output_dir = Path(path).parent -# output_dir.mkdir(parents=True, exist_ok=True) -# -# # Move to final location -# final_path = Path(path) -# Path(temp_path).rename(final_path) -# -# # Add message with category as metadata -# if message or category != "stdout": -# metadata = ( -# f"[{category.upper()}] {message}" if message else f"[{category.upper()}]" -# ) -# _add_message_metadata(str(final_path), metadata) -# -# # Manage cache size (remove old files if needed) -# cache_dir = Path(os.path.expanduser("~/.scitex/capture")) -# if cache_dir.exists(): -# _manage_cache_size(cache_dir, max_cache_gb) -# -# # Print path for user feedback (useful in interactive sessions) -# final_path_str = str(final_path) -# if verbose: -# try: -# if category == "stderr": -# print(f"📸 stderr: {final_path_str}") -# else: -# print(f"📸 stdout: {final_path_str}") -# except: -# # In case print fails in some environments -# pass -# -# return final_path_str -# -# -# def take_screenshot( -# output_path: str = None, jpeg: bool = True, quality: int = 85 -# ) -> Optional[str]: -# """ -# Take a single screenshot (simple interface). -# -# Parameters -# ---------- -# output_path : str, optional -# Where to save the screenshot -# jpeg : bool -# Use JPEG format (True) or PNG (False) -# quality : int -# JPEG quality (1-100) -# -# Returns -# ------- -# str or None -# Path to saved screenshot -# """ -# return _manager.take_single_screenshot(output_path, jpeg, quality) -# -# -# def start_monitor( -# output_dir: str = "~/.scitex/capture/", -# interval: float = 1.0, -# jpeg: bool = True, -# quality: int = 60, -# on_capture=None, -# on_error=None, -# verbose: bool = True, -# monitor_id: int = 0, -# capture_all: bool = False, -# ) -> ScreenshotWorker: -# """ -# Start continuous screenshot monitoring. -# -# Parameters -# ---------- -# output_dir : str -# Directory for screenshots (default: ~/.scitex/capture/) -# interval : float -# Seconds between captures -# jpeg : bool -# Use JPEG compression -# quality : int -# JPEG quality (1-100) -# on_capture : callable, optional -# Function called with filepath after each capture -# on_error : callable, optional -# Function called with exception on errors -# verbose : bool -# Print status messages -# monitor_id : int -# Monitor number to capture (0-based index, default: 0 for primary monitor) -# capture_all : bool -# If True, capture all monitors combined (default: False) -# -# Returns -# ------- -# ScreenshotWorker -# The worker instance -# -# Examples -# -------- -# >>> # Simple monitoring -# >>> capture.start() -# -# >>> # With event hooks -# >>> capture.start( -# ... on_capture=lambda path: print(f"Saved: {path}"), -# ... on_error=lambda e: logging.error(e) -# ... ) -# -# >>> # Detect specific screen content -# >>> def check_error_dialog(path): -# ... if "error" in analyze_image(path): -# ... send_alert(f"Error detected: {path}") -# >>> capture.start(on_capture=check_error_dialog) -# """ -# # Expand user home directory -# output_dir = os.path.expanduser(output_dir) -# -# return _manager.start_capture( -# output_dir=output_dir, -# interval=interval, -# jpeg=jpeg, -# quality=quality, -# on_capture=on_capture, -# on_error=on_error, -# verbose=verbose, -# monitor_id=monitor_id, -# capture_all=capture_all, -# ) -# -# -# def stop_monitor(): -# """Stop continuous screenshot monitoring.""" -# _manager.stop_capture() -# -# -# def _is_in_exception_context() -> bool: -# """ -# Check if we're currently in an exception handler. -# """ -# import sys -# -# # Check if there's an active exception -# exc_info = sys.exc_info() -# return exc_info[0] is not None -# -# -# def _detect_category(filepath: str) -> str: -# """ -# Detect screenshot category based on content. -# Simple heuristic based on common error indicators. -# """ -# try: -# # Try OCR-based detection if available -# from PIL import Image -# -# img = Image.open(filepath) -# -# # Simple color-based heuristics -# # Red dominant = likely error -# # Yellow/orange dominant = likely warning -# pixels = img.convert("RGB").getdata() -# red_count = sum(1 for r, g, b in pixels if r > 200 and g < 100 and b < 100) -# yellow_count = sum(1 for r, g, b in pixels if r > 200 and g > 150 and b < 100) -# -# total_pixels = len(pixels) -# red_ratio = red_count / total_pixels if total_pixels > 0 else 0 -# yellow_ratio = yellow_count / total_pixels if total_pixels > 0 else 0 -# -# # Thresholds for detection -# if red_ratio > 0.05: # More than 5% red pixels -# return "error" -# elif yellow_ratio > 0.05: # More than 5% yellow pixels -# return "warning" -# -# except: -# pass -# -# # Check filename for common error keywords -# filename_lower = str(filepath).lower() -# if any(word in filename_lower for word in ["error", "fail", "exception", "crash"]): -# return "stderr" -# elif any(word in filename_lower for word in ["warn", "alert", "caution"]): -# return "stderr" # Warnings also go to stderr -# -# return "stdout" -# -# -# def _add_message_metadata(filepath: str, message: str): -# """Add message as metadata to image file.""" -# try: -# # Try to add EXIF comment using PIL -# from PIL import Image -# -# img = Image.open(filepath) -# -# # Add comment to image metadata -# exif = img.getexif() -# exif[0x9286] = message # UserComment EXIF tag -# -# # Save with metadata -# img.save(filepath, exif=exif) -# except: -# # If PIL not available, create companion text file -# text_path = Path(filepath).with_suffix(".txt") -# text_path.write_text(f"{datetime.now().isoformat()}: {message}\n") -# -# -# # Convenience exports -# __all__ = [ -# "capture", -# "take_screenshot", -# "start_monitor", -# "stop_monitor", -# ] -# -# # EOF - -# -------------------------------------------------------------------------------- -# End of Source Code from: /home/ywatanabe/proj/scitex-code/src/scitex/capture/utils.py -# --------------------------------------------------------------------------------