From 587e3a5f70c79399a78c06598d3351873471a2f4 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 20 Nov 2025 02:19:49 -0500 Subject: [PATCH 1/2] =?UTF-8?q?Release=20v0.7=20=E2=80=94=20more=20improve?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- WARP.md | 223 ------------------ faststack/ChangeLog.md | 15 -- faststack/README.md | 2 +- faststack/faststack.egg-info/PKG-INFO | 12 +- faststack/faststack/imaging/jpeg.py | 11 +- faststack/faststack/imaging/prefetch.py | 162 +++++++++---- faststack/faststack/imaging/prefetch.py.bak | 127 ++++++++++ .../faststack/io/executable_validator.py | 6 +- faststack/faststack/io/helicon.py | 2 +- faststack/faststack/qml/Components.qml | 34 ++- faststack/faststack/qml/Main.qml | 4 +- .../tests/test_executable_validator.py | 7 +- faststack/faststack/ui/provider.py | 20 ++ 14 files changed, 328 insertions(+), 300 deletions(-) delete mode 100644 WARP.md create mode 100644 faststack/faststack/imaging/prefetch.py.bak diff --git a/.gitignore b/.gitignore index 9cdc77f..eb9f77c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ Thumbs.db .vscode/ .idea/ -prompt.md \ No newline at end of file +prompt.md +WARP.md diff --git a/WARP.md b/WARP.md deleted file mode 100644 index d5ab2f7..0000000 --- a/WARP.md +++ /dev/null @@ -1,223 +0,0 @@ -# WARP.md - -This file provides guidance to WARP (warp.dev) when working with code in this repository. - -## Project Overview - -FastStack is an ultra-fast JPEG viewer for Windows designed for culling and selecting RAW files for focus stacking. It displays JPEGs as proxies for RAW files, optimized for instant next/prev navigation, smooth zoom/pan, and integration with Helicon Focus for processing focus stacks. - -**Key Technologies:** -- Python 3.11+ with PySide6 (Qt6) for UI -- Qt Quick (QML) for the rendering layer -- PyTurboJPEG (libjpeg-turbo) for fast JPEG decoding -- Byte-aware LRU caching for image data -- ThreadPoolExecutor for concurrent prefetching - -## Development Commands - -### Running the Application - -```powershell -# Development run with path argument -python -m faststack.app "C:\path\to\images" - -# Or from the inner faststack directory -python -m faststack.app "C:\path\to\images" - -# Run from the installed package entry point (if installed) -faststack "C:\path\to\images" -``` - -### Testing - -```powershell -# Run all tests -pytest - -# Run tests from the faststack directory -pytest faststack/faststack/tests - -# Run specific test file -pytest faststack/faststack/tests/test_cache.py - -# Run with verbose output -pytest -v -``` - -### Building Distribution - -```powershell -# Build standalone Windows executable using PyInstaller -pyinstaller faststack/faststack.spec - -# Output will be in dist/FastStack/FastStack.exe -``` - -### Installing Dependencies - -```powershell -# Install from requirements.txt (preferred for dev) -pip install -r faststack/requirements.txt - -# Or install the package in editable mode -pip install -e faststack/ -``` - -## Architecture - -### Core Module Structure - -The codebase follows a modular architecture split into distinct concerns: - -**faststack/faststack/** (main package): -- `app.py` - Main entry point, `AppController` manages application state and coordinates all subsystems -- `config.py` - Configuration management via INI file (`%APPDATA%\faststack\faststack.ini`) -- `models.py` - Core data models (`ImageFile`, `EntryMetadata`, `DecodedImage`, `Sidecar`) -- `logging_setup.py` - Application logging setup - -**faststack/faststack/imaging/** - Image processing and caching: -- `jpeg.py` - JPEG decoding with PyTurboJPEG (fallback to Pillow), supports resized decoding -- `cache.py` - Byte-aware LRU cache (`ByteLRUCache`) that tracks memory usage in bytes -- `prefetch.py` - Background prefetching using `ThreadPoolExecutor` with generation-based cancellation - -**faststack/faststack/io/** - I/O operations: -- `indexer.py` - Directory scanning, JPG→RAW pairing by stem and timestamp proximity (±2s) -- `sidecar.py` - Manages `faststack.json` sidecar file with atomic writes -- `watcher.py` - Filesystem watching for directory changes -- `helicon.py` - Launches Helicon Focus with selected RAW files - -**faststack/faststack/ui/** - UI components: -- `provider.py` - `ImageProvider` for Qt image handling, `UIState` for QML bindings -- `keystrokes.py` - Keyboard event handling (`Keybinder`) - -**faststack/faststack/qml/** - QML UI files: -- `Main.qml` - Main window and image viewer -- `Components.qml` - Reusable QML components -- `SettingsDialog.qml` - Settings dialog -- `FilterDialog.qml` - Filtering interface - -### Key Architectural Patterns - -#### 1. Two-Tier Caching with Display-Aware Prefetching - -The app uses a sophisticated caching strategy: -- **Display generation tracking**: When window size or zoom state changes, `display_generation` increments, invalidating cached images -- **Cache keys**: Format `{index}_{display_generation}` ensures stale images are not reused -- **Prefetch radius**: Configurable (default 4), decodes images at indices `[i-N, i+N]` -- **Generation-based cancellation**: When navigating, `prefetcher.generation` increments, and workers check this before caching to avoid stale work - -#### 2. Zero-Copy Image Pipeline - -To minimize memory overhead: -- JPEG decoding produces contiguous `numpy` arrays (uint8, h×w×3) -- QImage is created with a direct pointer to the numpy buffer (`QImage(buf, w, h, w*3, Format_RGB888)`) -- The numpy array reference is kept alive for the QImage lifetime to prevent dangling pointers -- This approach is implemented via `DecodedImage.buffer` which stores a `memoryview` - -#### 3. RAW-JPG Pairing Logic - -`indexer.py` pairs JPEGs with RAWs by: -1. Scanning directory for JPGs and RAWs -2. Matching by stem (e.g., `IMG_0123.JPG` → `IMG_0123.CR3`) -3. Validating timestamp proximity (±2 seconds) to handle burst shooting -4. Supporting multiple RAW formats: `.CR3`, `.CR2`, `.ARW`, `.NEF`, `.ORF`, `.RW2`, `.RAF`, `.DNG` - -#### 4. Sidecar Metadata Management - -All user edits are non-destructive, stored in `faststack.json`: -- Schema version 2 format -- Tracks: flags, rejections, stack IDs, stacking status/date -- Atomic writes: write to temp file, then replace -- Filesystem watcher paused during writes to avoid recursion - -#### 5. Performance Optimizations - -Key techniques for sub-10ms cache hits: -- **Memory-mapped file I/O**: `mmap` for faster JPEG loading -- **PyTurboJPEG scaling factors**: Use hardware-accelerated downscaling (1/8, 1/4, 1/2) before Pillow resizing -- **Debounced resize handling**: 150ms debounce for window resize events -- **Optimal thread pool sizing**: `min(cpu_count() * 2, 8)` workers for I/O-bound JPEG decoding -- **Selective cache clearing**: Only clear when display dimensions or zoom state changes -- **BILINEAR resampling**: For large downscales (>4x), use faster BILINEAR instead of LANCZOS - -## Development Guidelines - -### Adding New Keyboard Shortcuts - -Edit `ui/keystrokes.py` (`Keybinder.handle_key_press()`) to add new key bindings. The method maps Qt key events to AppController methods. - -### Modifying Cache Behavior - -The cache size and prefetch radius are configurable in `%APPDATA%\faststack\faststack.ini`: -```ini -[core] -cache_size_gb = 1.5 -prefetch_radius = 4 -``` - -Access via `config.getfloat('core', 'cache_size_gb')` or `config.getint('core', 'prefetch_radius')`. - -### Adding Support for New RAW Formats - -Add extensions to `RAW_EXTENSIONS` set in `io/indexer.py`: -```python -RAW_EXTENSIONS = { - ".ORF", ".RW2", ".CR2", ".CR3", ".ARW", ".NEF", ".RAF", ".DNG", - ".orf", ".rw2", ".cr2", ".cr3", ".arw", ".nef", ".raf", ".dng", -} -``` - -### Extending Sidecar Metadata - -To add new metadata fields: -1. Update `EntryMetadata` dataclass in `models.py` -2. Update `Sidecar.version` in `models.py` if breaking changes -3. Handle migration logic in `SidecarManager.load()` in `io/sidecar.py` - -### Working with the UI (QML) - -- QML files are in `qml/` directory -- `Main.qml` is the entry point, connected to `AppController` via Qt signals/slots -- UI state is exposed via `UIState` object in `ui/provider.py` -- Use `@Slot` decorator for methods callable from QML -- Emit `dataChanged` signal from AppController to trigger UI updates - -### PyInstaller Builds - -The `faststack.spec` file handles packaging: -- Collects all PySide6 data files -- Includes turbojpeg binaries from `turbojpeg.lib_path` -- Adds hidden imports for `PySide6.QtQml` -- Produces single-folder distribution in `dist/FastStack/` - -To add resources or fix missing imports, edit `faststack.spec`. - -## Common Pitfalls - -### Image Decode Performance Issues - -If decoding is slow: -- Verify PyTurboJPEG is installed correctly (check logs for "PyTurboJPEG is available") -- Check if `turbojpeg.dll` is found (Windows) - should be in PyInstaller build -- Consider increasing prefetch radius for smoother navigation at cost of memory - -### Cache Not Invalidating on Window Resize - -The app uses debounced resize handling. If cache seems stale: -- Check `display_generation` is incrementing (logged as "Display size changed to...") -- Verify `cache_key = f"{index}_{display_generation}"` format in `prefetch.py` -- Ensure `sync_ui_state()` is called after generation increment - -### Sidecar File Corruption - -If `faststack.json` becomes corrupted: -- The app will log error and start with empty sidecar -- Consider implementing backup strategy in `SidecarManager.save()` -- Version number mismatch triggers fresh start - -### Threading Issues with Prefetcher - -When adding features that interact with the prefetcher: -- Always increment `generation` when invalidating work -- Check `self.generation != local_generation` before cache operations -- Use `cancel_all()` before clearing image list or display changes diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index f8b38cc..12c473e 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -7,21 +7,6 @@ - **Ctrl+0 Zoom Reset:** Added keyboard shortcut to reset zoom and pan to fit window (like Photoshop), with visual feedback. - **Active Filter Indicator:** Footer now displays active filename filter in yellow bold text for better visibility. - **Directory Path Display:** Title bar now shows the current working directory path, centered between menu and window controls. -- **WARP.md Documentation:** Created comprehensive development guide for Warp AI assistant with architecture, commands, and development patterns. - -### Security -- **Executable Path Validation:** Added comprehensive validation for Photoshop and Helicon Focus executables: - - Validates executable exists and is in safe location (Program Files) - - Checks file type (.exe on Windows) - - Warns about executables outside known safe paths - - Detects directory traversal attempts -- **Subprocess Security Hardening:** - - Replaced unsafe `.split()` with `shlex.split()` for proper argument parsing - - Explicitly set `shell=False` to prevent shell injection attacks - - Added file descriptor management (`stdin`, `stdout`, `stderr` redirection) - - Validates file paths before subprocess execution - - Uses `close_fds=True` to prevent information leakage -- **Input Validation:** Added pre-execution validation for image file paths in both Photoshop and Helicon Focus launchers. ### Fixed - **Property Name Mismatch:** Corrected `get_stack_summary` to `stackSummary` in UIState to match QML property naming conventions. diff --git a/faststack/README.md b/faststack/README.md index 8f2ce7e..0a88d08 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 0.4 - November 2, 2025 +# Version 0.7 - November 20, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking. diff --git a/faststack/faststack.egg-info/PKG-INFO b/faststack/faststack.egg-info/PKG-INFO index 832e8b8..879a364 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: faststack -Version: 0.6 +Version: 0.7 Summary: Ultra-fast JPG Viewer for Focus Stacking Selection Author-email: Alan Rockefeller Classifier: Programming Language :: Python :: 3 @@ -21,7 +21,7 @@ Dynamic: license-file # FastStack -# Version 0.4 - November 2, 2025 +# Version 0.7 - November 20, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking. @@ -38,6 +38,11 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). - **Sidecar Metadata:** Saves flags, rejections, and stack groupings to a non-destructive `faststack.json` file. - **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus path via a settings dialog and a persistent `.ini` file. +- **Photoshop Integration:** Edit current image in Photoshop (E key) +- **Clipboard Support:** Copy image path to clipboard (Ctrl+C) +- **Image Filtering:** Filter images by filename +- **Drag & Drop:** Drag images to external applications +- **Theme Support:** Toggle between light and dark themes ## Installation & Usage @@ -62,3 +67,6 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `Space`: Toggle Flag - `X`: Toggle Reject - `Enter`: Launch Helicon Focus with selected RAWs +- `E`: Edit in Photoshop +- `Ctrl+C`: Copy image path to clipboard +- `C`: Clear all stacks diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index 049e884..9672749 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -10,7 +10,7 @@ # Attempt to import PyTurboJPEG try: - from turbojpeg import TurboJPEG, TJFLAG_FASTDCT, TJPF_RGB + from turbojpeg import TurboJPEG, TJPF_RGB jpeg_decoder = TurboJPEG() TURBO_AVAILABLE = True log.info("PyTurboJPEG is available. Using for JPEG decoding.") @@ -23,8 +23,9 @@ def decode_jpeg_rgb(jpeg_bytes: bytes) -> Optional[np.ndarray]: """Decodes JPEG bytes into an RGB numpy array.""" if TURBO_AVAILABLE and jpeg_decoder: try: - # The flags prevent upsampling of chroma channels, which is faster. - return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=TJFLAG_FASTDCT) + # Decode with proper color space handling (no TJFLAG_FASTDCT) + # This ensures proper YCbCr->RGB conversion with correct gamma + return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=0) except Exception as e: log.exception(f"PyTurboJPEG failed to decode image: {e}. Trying Pillow.") # Fall through to Pillow fallback @@ -55,7 +56,7 @@ def decode_jpeg_thumb_rgb( jpeg_bytes, scaling_factor=scaling_factor, pixel_format=TJPF_RGB, - flags=TJFLAG_FASTDCT, + flags=0, # Proper color space handling ) if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim: img = Image.fromarray(decoded) @@ -115,7 +116,7 @@ def decode_jpeg_resized( jpeg_bytes, scaling_factor=scale_factor, pixel_format=TJPF_RGB, - flags=TJFLAG_FASTDCT + flags=0 # Proper color space handling ) # Only use Pillow for final resize if needed diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 24a7491..ff4722b 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -1,34 +1,49 @@ -"""Handles prefetching and decoding of adjacent images in a background thread pool.""" +"""Handles prefetching and decoding of adjacent images in a background thread pool. + +This version bypasses PyTurboJPEG and decodes images using QImage, with a +Pillow fallback to ensure JPEGs still load even if Qt's image plugins are +unavailable. +""" import logging import os from concurrent.futures import ThreadPoolExecutor, Future from typing import List, Dict, Optional, Callable -import mmap + +import numpy as np +from PySide6.QtGui import QImage +from PySide6.QtCore import Qt from faststack.models import ImageFile, DecodedImage -from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized log = logging.getLogger(__name__) + class Prefetcher: - def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable): + def __init__( + self, + image_files: List[ImageFile], + cache_put: Callable, + prefetch_radius: int, + get_display_info: Callable, + ): self.image_files = image_files self.cache_put = cache_put self.prefetch_radius = prefetch_radius self.get_display_info = get_display_info - # Use CPU count for I/O-bound JPEG decoding - # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound - optimal_workers = min((os.cpu_count() or 1) * 2, 8) # Cap at 8 - + + # Use CPU count for I/O-bound decoding; cap at 8 workers + optimal_workers = min((os.cpu_count() or 1) * 2, 8) + self.executor = ThreadPoolExecutor( max_workers=optimal_workers, - thread_name_prefix="Prefetcher" + thread_name_prefix="Prefetcher", ) self.futures: Dict[int, Future] = {} self.generation = 0 def set_image_files(self, image_files: List[ImageFile]): + """Update the image list and cancel any outstanding work.""" if self.image_files != image_files: self.image_files = image_files self.cancel_all() @@ -38,7 +53,7 @@ def update_prefetch(self, current_index: int): self.generation += 1 log.debug(f"Updating prefetch for index {current_index}, generation {self.generation}") - # Cancel stale futures + # Cancel futures outside the window stale_keys = [] for index, future in self.futures.items(): if not self._is_in_prefetch_range(index, current_index): @@ -58,54 +73,115 @@ def update_prefetch(self, current_index: int): def submit_task(self, index: int, generation: int) -> Optional[Future]: """Submits a decoding task for a given index.""" if index in self.futures and not self.futures[index].done(): - return self.futures[index] # Already submitted + return self.futures[index] # Already submitted + + if not self.image_files: + return None image_file = self.image_files[index] display_width, display_height, display_generation = self.get_display_info() - future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) + future = self.executor.submit( + self._decode_and_cache, + image_file, + index, + generation, + display_width, + display_height, + display_generation, + ) self.futures[index] = future log.debug(f"Submitted prefetch task for index {index}") return future - def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[int, int]]: - """The actual work done by the thread pool.""" - local_generation = self.generation # Capture current generation for this worker - + def _decode_and_cache( + self, + image_file: ImageFile, + index: int, + generation: int, + display_width: int, + display_height: int, + display_generation: int, + ) -> Optional[tuple[int, int]]: + """ + Worker-thread function: load the image (prefer QImage, fall back to Pillow), + resize, and cache as a DecodedImage. + """ + local_generation = self.generation # capture snapshot + + # Drop stale tasks early if generation != local_generation: log.debug(f"Skipping stale task for index {index} (gen {generation} != {local_generation})") return None try: - # Memory-mapped file reading (faster than traditional read) - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - jpeg_bytes = mmapped[:] - - buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) - if buffer is not None: - # Re-check generation before caching to prevent race conditions - if self.generation != local_generation: - log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") - return None - - h, w, _ = buffer.shape - # In a real Qt app, we would create the QImage here in the main thread - # For now, we'll just store the raw buffer data. - decoded_image = DecodedImage( - buffer=buffer.data, - width=w, - height=h, - bytes_per_line=w * 3, - format=None # Placeholder for QImage.Format.Format_RGB888 + # First try QImage + qimg = QImage(str(image_file.path)) + + if qimg.isNull(): + log.warning(f"QImage failed to load {image_file.path}, falling back to Pillow.") + from PIL import Image + + img = Image.open(str(image_file.path)) + + # If display size is known, downscale via Pillow first + if display_width > 0 and display_height > 0: + img.thumbnail((display_width, display_height), Image.Resampling.LANCZOS) + + rgb = np.array(img.convert("RGB")) + h, w, _ = rgb.shape + bytes_per_line = w * 3 + # Flatten to 1D; provider will use (width, height, bytes_per_line) + arr = rgb.reshape(-1).copy() + else: + # Optional resize to fit display area; if width/height are 0, keep original size + if display_width > 0 and display_height > 0: + qimg = qimg.scaled( + display_width, + display_height, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + + # Ensure we have RGB888 format (3 bytes per pixel, no alpha) + if qimg.format() != QImage.Format_RGB888: + qimg = qimg.convertToFormat(QImage.Format_RGB888) + + w = qimg.width() + h = qimg.height() + bytes_per_line = qimg.bytesPerLine() # may be >= w * 3 (includes padding) + + # Access raw bits; PySide6 gives a memoryview, so we just use frombuffer + ptr = qimg.bits() # memoryview + # Read exactly h * bytes_per_line bytes and copy so memory is owned by NumPy + arr = np.frombuffer(ptr, dtype=np.uint8, count=h * bytes_per_line).copy() + + # Re-check generation before caching to avoid race conditions + if self.generation != local_generation: + log.debug( + f"Generation changed for index {index} before caching. " + f"Skipping cache_put (gen {generation} -> {self.generation})." ) - cache_key = f"{index}_{display_generation}" - self.cache_put(cache_key, decoded_image) - log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}") - return index, display_generation + return None + + decoded_image = DecodedImage( + buffer=arr, # numpy array supports buffer protocol + width=w, + height=h, + bytes_per_line=bytes_per_line, + format=None, # always treated as RGB888 in provider + ) + cache_key = f"{index}_{display_generation}" + self.cache_put(cache_key, decoded_image) + log.debug( + f"Decoded and cached image at index {index} " + f"(w={w}, h={h}, bpl={bytes_per_line}) for display gen {display_generation}" + ) + return index, display_generation + except Exception as e: - log.error(f"Error decoding image {image_file.path} at index {index}: {e}") - + log.error(f"Error decoding image {image_file.path} at index {index}: {e}", exc_info=True) + return None def _is_in_prefetch_range(self, index: int, current_index: int) -> bool: diff --git a/faststack/faststack/imaging/prefetch.py.bak b/faststack/faststack/imaging/prefetch.py.bak new file mode 100644 index 0000000..24a7491 --- /dev/null +++ b/faststack/faststack/imaging/prefetch.py.bak @@ -0,0 +1,127 @@ +"""Handles prefetching and decoding of adjacent images in a background thread pool.""" + +import logging +import os +from concurrent.futures import ThreadPoolExecutor, Future +from typing import List, Dict, Optional, Callable +import mmap + +from faststack.models import ImageFile, DecodedImage +from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized + +log = logging.getLogger(__name__) + +class Prefetcher: + def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable): + self.image_files = image_files + self.cache_put = cache_put + self.prefetch_radius = prefetch_radius + self.get_display_info = get_display_info + # Use CPU count for I/O-bound JPEG decoding + # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound + optimal_workers = min((os.cpu_count() or 1) * 2, 8) # Cap at 8 + + self.executor = ThreadPoolExecutor( + max_workers=optimal_workers, + thread_name_prefix="Prefetcher" + ) + self.futures: Dict[int, Future] = {} + self.generation = 0 + + def set_image_files(self, image_files: List[ImageFile]): + if self.image_files != image_files: + self.image_files = image_files + self.cancel_all() + + def update_prefetch(self, current_index: int): + """Updates the prefetching queue based on the current image index.""" + self.generation += 1 + log.debug(f"Updating prefetch for index {current_index}, generation {self.generation}") + + # Cancel stale futures + stale_keys = [] + for index, future in self.futures.items(): + if not self._is_in_prefetch_range(index, current_index): + future.cancel() + stale_keys.append(index) + for key in stale_keys: + del self.futures[key] + + # Submit new tasks + start = max(0, current_index - self.prefetch_radius) + end = min(len(self.image_files), current_index + self.prefetch_radius + 1) + + for i in range(start, end): + if i not in self.futures: + self.submit_task(i, self.generation) + + def submit_task(self, index: int, generation: int) -> Optional[Future]: + """Submits a decoding task for a given index.""" + if index in self.futures and not self.futures[index].done(): + return self.futures[index] # Already submitted + + image_file = self.image_files[index] + display_width, display_height, display_generation = self.get_display_info() + + future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) + self.futures[index] = future + log.debug(f"Submitted prefetch task for index {index}") + return future + + def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[int, int]]: + """The actual work done by the thread pool.""" + local_generation = self.generation # Capture current generation for this worker + + if generation != local_generation: + log.debug(f"Skipping stale task for index {index} (gen {generation} != {local_generation})") + return None + + try: + # Memory-mapped file reading (faster than traditional read) + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + jpeg_bytes = mmapped[:] + + buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) + if buffer is not None: + # Re-check generation before caching to prevent race conditions + if self.generation != local_generation: + log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") + return None + + h, w, _ = buffer.shape + # In a real Qt app, we would create the QImage here in the main thread + # For now, we'll just store the raw buffer data. + decoded_image = DecodedImage( + buffer=buffer.data, + width=w, + height=h, + bytes_per_line=w * 3, + format=None # Placeholder for QImage.Format.Format_RGB888 + ) + cache_key = f"{index}_{display_generation}" + self.cache_put(cache_key, decoded_image) + log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}") + return index, display_generation + except Exception as e: + log.error(f"Error decoding image {image_file.path} at index {index}: {e}") + + return None + + def _is_in_prefetch_range(self, index: int, current_index: int) -> bool: + """Checks if an index is within the current prefetch window.""" + return abs(index - current_index) <= self.prefetch_radius + + def cancel_all(self): + """Cancels all pending prefetch tasks.""" + log.info("Cancelling all prefetch tasks.") + self.generation += 1 + for future in self.futures.values(): + future.cancel() + self.futures.clear() + + def shutdown(self): + """Shuts down the thread pool executor.""" + log.info("Shutting down prefetcher thread pool.") + self.cancel_all() + self.executor.shutdown(wait=False) diff --git a/faststack/faststack/io/executable_validator.py b/faststack/faststack/io/executable_validator.py index b78b0b1..501c7f0 100644 --- a/faststack/faststack/io/executable_validator.py +++ b/faststack/faststack/io/executable_validator.py @@ -44,7 +44,7 @@ def validate_executable_path( try: path = Path(exe_path).resolve() except (ValueError, OSError) as e: - log.error(f"Invalid path format: {exe_path}: {e}") + log.exception(f"Invalid path format: {exe_path}") return False, f"Invalid path format: {e}" # Check if file exists @@ -88,8 +88,8 @@ def validate_executable_path( normalized = os.path.normpath(exe_path) if ".." in normalized or normalized != str(path): log.warning(f"Suspicious path detected: {exe_path}") - except Exception as e: - log.error(f"Error normalizing path: {e}") + except (ValueError, OSError) as e: + log.exception("Error normalizing path") return False, f"Path validation error: {e}" return True, None diff --git a/faststack/faststack/io/helicon.py b/faststack/faststack/io/helicon.py index 98bbfe2..8d7144a 100644 --- a/faststack/faststack/io/helicon.py +++ b/faststack/faststack/io/helicon.py @@ -66,7 +66,7 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: parsed_args = shlex.split(extra_args, posix=(os.name != 'nt')) args.extend(parsed_args) except ValueError as e: - log.error(f"Invalid helicon args format: {e}") + log.exception(f"Invalid helicon args format: {e}") return False, None log.info(f"Launching Helicon Focus with {len(raw_files)} files.") diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 224be5b..2b60b4a 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -87,23 +87,53 @@ Item { anchors.fill: parent acceptedButtons: Qt.LeftButton - // Simple drag-to-pan placeholder + // Drag-to-pan with drag-and-drop when dragging outside window property real lastX: 0 property real lastY: 0 + property real startX: 0 + property real startY: 0 + property bool isDraggingOutside: false + property int dragThreshold: 10 // Minimum distance before checking for outside drag onPressed: function(mouse) { lastX = mouse.x lastY = mouse.y + startX = mouse.x + startY = mouse.y + isDraggingOutside = false } onPositionChanged: function(mouse) { - if (pressed) { + if (pressed && !isDraggingOutside) { + // Check if we've moved beyond the threshold + var dx = mouse.x - startX + var dy = mouse.y - startY + var distance = Math.sqrt(dx*dx + dy*dy) + + if (distance > dragThreshold) { + // Check if mouse is outside the window bounds + var globalPos = mapToItem(null, mouse.x, mouse.y) + + if (globalPos.x < 0 || globalPos.y < 0 || + globalPos.x > loupeView.width || globalPos.y > loupeView.height) { + // Mouse is outside window - initiate drag-and-drop + isDraggingOutside = true + controller.start_drag_current_image() + return + } + } + + // Normal pan behavior panTransform.x += (mouse.x - lastX) panTransform.y += (mouse.y - lastY) lastX = mouse.x lastY = mouse.y } } + + onReleased: function(mouse) { + isDraggingOutside = false + } // Wheel for zoom onWheel: function(wheel) { diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index a2fdf3a..2ee2124 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -140,6 +140,7 @@ ApplicationWindow { var delta = Qt.point(mouse.x - lastMousePos.x, mouse.y - lastMousePos.y) root.x += delta.x root.y += delta.y + lastMousePos = Qt.point(mouse.x, mouse.y) } } @@ -208,7 +209,8 @@ ApplicationWindow { font.pixelSize: 12 elide: Text.ElideMiddle Layout.maximumWidth: 600 - Layout.alignment: Qt.AlignVCenter + Layout.alignment: Qt.AlignTop | Qt.AlignHCenter + Layout.topMargin: 5 horizontalAlignment: Text.AlignHCenter } diff --git a/faststack/faststack/tests/test_executable_validator.py b/faststack/faststack/tests/test_executable_validator.py index ac0815d..2a80a0d 100644 --- a/faststack/faststack/tests/test_executable_validator.py +++ b/faststack/faststack/tests/test_executable_validator.py @@ -62,9 +62,10 @@ def test_suspicious_path_with_traversal(): mock_path_instance.__str__ = lambda self: r"C:\Windows\System32\malware.exe" # The normalized path will differ from input, triggering warning - is_valid, error = validate_executable_path(suspicious_path) - # Should still pass but with a warning logged - # (in production, you might want to make this fail) + with patch('faststack.io.executable_validator._is_subpath', return_value=False): + is_valid, error = validate_executable_path(suspicious_path) + # Warning is logged for suspicious path, but doesn't fail with allow_custom_paths=True + assert is_valid # Default allow_custom_paths=True means it passes with warning def test_non_exe_file(): diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 86b4609..f4153ee 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -7,6 +7,13 @@ from faststack.models import DecodedImage +# Try to import QColorSpace if available (Qt 6+) +try: + from PySide6.QtGui import QColorSpace + HAS_COLOR_SPACE = True +except ImportError: + HAS_COLOR_SPACE = False + log = logging.getLogger(__name__) @@ -35,6 +42,19 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: image_data.bytes_per_line, QImage.Format.Format_RGB888 ) + # Set sRGB color space for proper color management (if available) + # This tells Qt: "this decoded image data is in sRGB color space" + if HAS_COLOR_SPACE: + try: + cs = QColorSpace.fromNamedColorSpace( + QColorSpace.NamedColorSpace.SRgb + ) + qimg.setColorSpace(cs) + log.debug("Applied sRGB color space to image") + except Exception as e: + log.warning(f"Failed to set color space: {e}") + else: + log.debug("QColorSpace not available in this PySide6 version") # keep buffer alive qimg.original_buffer = image_data.buffer return qimg From bb62a69722c688ca020285d938770709f8fad7fc Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 20 Nov 2025 02:26:37 -0500 Subject: [PATCH 2/2] =?UTF-8?q?Release=20v0.7=20=E2=80=94=20more=20improve?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/faststack/imaging/prefetch.py | 162 ++++++-------------- faststack/faststack/imaging/prefetch.py.bak | 127 --------------- 2 files changed, 43 insertions(+), 246 deletions(-) delete mode 100644 faststack/faststack/imaging/prefetch.py.bak diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index ff4722b..24a7491 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -1,49 +1,34 @@ -"""Handles prefetching and decoding of adjacent images in a background thread pool. - -This version bypasses PyTurboJPEG and decodes images using QImage, with a -Pillow fallback to ensure JPEGs still load even if Qt's image plugins are -unavailable. -""" +"""Handles prefetching and decoding of adjacent images in a background thread pool.""" import logging import os from concurrent.futures import ThreadPoolExecutor, Future from typing import List, Dict, Optional, Callable - -import numpy as np -from PySide6.QtGui import QImage -from PySide6.QtCore import Qt +import mmap from faststack.models import ImageFile, DecodedImage +from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized log = logging.getLogger(__name__) - class Prefetcher: - def __init__( - self, - image_files: List[ImageFile], - cache_put: Callable, - prefetch_radius: int, - get_display_info: Callable, - ): + def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable): self.image_files = image_files self.cache_put = cache_put self.prefetch_radius = prefetch_radius self.get_display_info = get_display_info - - # Use CPU count for I/O-bound decoding; cap at 8 workers - optimal_workers = min((os.cpu_count() or 1) * 2, 8) - + # Use CPU count for I/O-bound JPEG decoding + # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound + optimal_workers = min((os.cpu_count() or 1) * 2, 8) # Cap at 8 + self.executor = ThreadPoolExecutor( max_workers=optimal_workers, - thread_name_prefix="Prefetcher", + thread_name_prefix="Prefetcher" ) self.futures: Dict[int, Future] = {} self.generation = 0 def set_image_files(self, image_files: List[ImageFile]): - """Update the image list and cancel any outstanding work.""" if self.image_files != image_files: self.image_files = image_files self.cancel_all() @@ -53,7 +38,7 @@ def update_prefetch(self, current_index: int): self.generation += 1 log.debug(f"Updating prefetch for index {current_index}, generation {self.generation}") - # Cancel futures outside the window + # Cancel stale futures stale_keys = [] for index, future in self.futures.items(): if not self._is_in_prefetch_range(index, current_index): @@ -73,115 +58,54 @@ def update_prefetch(self, current_index: int): def submit_task(self, index: int, generation: int) -> Optional[Future]: """Submits a decoding task for a given index.""" if index in self.futures and not self.futures[index].done(): - return self.futures[index] # Already submitted - - if not self.image_files: - return None + return self.futures[index] # Already submitted image_file = self.image_files[index] display_width, display_height, display_generation = self.get_display_info() - future = self.executor.submit( - self._decode_and_cache, - image_file, - index, - generation, - display_width, - display_height, - display_generation, - ) + future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) self.futures[index] = future log.debug(f"Submitted prefetch task for index {index}") return future - def _decode_and_cache( - self, - image_file: ImageFile, - index: int, - generation: int, - display_width: int, - display_height: int, - display_generation: int, - ) -> Optional[tuple[int, int]]: - """ - Worker-thread function: load the image (prefer QImage, fall back to Pillow), - resize, and cache as a DecodedImage. - """ - local_generation = self.generation # capture snapshot - - # Drop stale tasks early + def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[int, int]]: + """The actual work done by the thread pool.""" + local_generation = self.generation # Capture current generation for this worker + if generation != local_generation: log.debug(f"Skipping stale task for index {index} (gen {generation} != {local_generation})") return None try: - # First try QImage - qimg = QImage(str(image_file.path)) - - if qimg.isNull(): - log.warning(f"QImage failed to load {image_file.path}, falling back to Pillow.") - from PIL import Image - - img = Image.open(str(image_file.path)) - - # If display size is known, downscale via Pillow first - if display_width > 0 and display_height > 0: - img.thumbnail((display_width, display_height), Image.Resampling.LANCZOS) - - rgb = np.array(img.convert("RGB")) - h, w, _ = rgb.shape - bytes_per_line = w * 3 - # Flatten to 1D; provider will use (width, height, bytes_per_line) - arr = rgb.reshape(-1).copy() - else: - # Optional resize to fit display area; if width/height are 0, keep original size - if display_width > 0 and display_height > 0: - qimg = qimg.scaled( - display_width, - display_height, - Qt.KeepAspectRatio, - Qt.SmoothTransformation, - ) - - # Ensure we have RGB888 format (3 bytes per pixel, no alpha) - if qimg.format() != QImage.Format_RGB888: - qimg = qimg.convertToFormat(QImage.Format_RGB888) - - w = qimg.width() - h = qimg.height() - bytes_per_line = qimg.bytesPerLine() # may be >= w * 3 (includes padding) - - # Access raw bits; PySide6 gives a memoryview, so we just use frombuffer - ptr = qimg.bits() # memoryview - # Read exactly h * bytes_per_line bytes and copy so memory is owned by NumPy - arr = np.frombuffer(ptr, dtype=np.uint8, count=h * bytes_per_line).copy() - - # Re-check generation before caching to avoid race conditions - if self.generation != local_generation: - log.debug( - f"Generation changed for index {index} before caching. " - f"Skipping cache_put (gen {generation} -> {self.generation})." + # Memory-mapped file reading (faster than traditional read) + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + jpeg_bytes = mmapped[:] + + buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) + if buffer is not None: + # Re-check generation before caching to prevent race conditions + if self.generation != local_generation: + log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") + return None + + h, w, _ = buffer.shape + # In a real Qt app, we would create the QImage here in the main thread + # For now, we'll just store the raw buffer data. + decoded_image = DecodedImage( + buffer=buffer.data, + width=w, + height=h, + bytes_per_line=w * 3, + format=None # Placeholder for QImage.Format.Format_RGB888 ) - return None - - decoded_image = DecodedImage( - buffer=arr, # numpy array supports buffer protocol - width=w, - height=h, - bytes_per_line=bytes_per_line, - format=None, # always treated as RGB888 in provider - ) - cache_key = f"{index}_{display_generation}" - self.cache_put(cache_key, decoded_image) - log.debug( - f"Decoded and cached image at index {index} " - f"(w={w}, h={h}, bpl={bytes_per_line}) for display gen {display_generation}" - ) - return index, display_generation - + cache_key = f"{index}_{display_generation}" + self.cache_put(cache_key, decoded_image) + log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}") + return index, display_generation except Exception as e: - log.error(f"Error decoding image {image_file.path} at index {index}: {e}", exc_info=True) - + log.error(f"Error decoding image {image_file.path} at index {index}: {e}") + return None def _is_in_prefetch_range(self, index: int, current_index: int) -> bool: diff --git a/faststack/faststack/imaging/prefetch.py.bak b/faststack/faststack/imaging/prefetch.py.bak deleted file mode 100644 index 24a7491..0000000 --- a/faststack/faststack/imaging/prefetch.py.bak +++ /dev/null @@ -1,127 +0,0 @@ -"""Handles prefetching and decoding of adjacent images in a background thread pool.""" - -import logging -import os -from concurrent.futures import ThreadPoolExecutor, Future -from typing import List, Dict, Optional, Callable -import mmap - -from faststack.models import ImageFile, DecodedImage -from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized - -log = logging.getLogger(__name__) - -class Prefetcher: - def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable): - self.image_files = image_files - self.cache_put = cache_put - self.prefetch_radius = prefetch_radius - self.get_display_info = get_display_info - # Use CPU count for I/O-bound JPEG decoding - # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound - optimal_workers = min((os.cpu_count() or 1) * 2, 8) # Cap at 8 - - self.executor = ThreadPoolExecutor( - max_workers=optimal_workers, - thread_name_prefix="Prefetcher" - ) - self.futures: Dict[int, Future] = {} - self.generation = 0 - - def set_image_files(self, image_files: List[ImageFile]): - if self.image_files != image_files: - self.image_files = image_files - self.cancel_all() - - def update_prefetch(self, current_index: int): - """Updates the prefetching queue based on the current image index.""" - self.generation += 1 - log.debug(f"Updating prefetch for index {current_index}, generation {self.generation}") - - # Cancel stale futures - stale_keys = [] - for index, future in self.futures.items(): - if not self._is_in_prefetch_range(index, current_index): - future.cancel() - stale_keys.append(index) - for key in stale_keys: - del self.futures[key] - - # Submit new tasks - start = max(0, current_index - self.prefetch_radius) - end = min(len(self.image_files), current_index + self.prefetch_radius + 1) - - for i in range(start, end): - if i not in self.futures: - self.submit_task(i, self.generation) - - def submit_task(self, index: int, generation: int) -> Optional[Future]: - """Submits a decoding task for a given index.""" - if index in self.futures and not self.futures[index].done(): - return self.futures[index] # Already submitted - - image_file = self.image_files[index] - display_width, display_height, display_generation = self.get_display_info() - - future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) - self.futures[index] = future - log.debug(f"Submitted prefetch task for index {index}") - return future - - def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[int, int]]: - """The actual work done by the thread pool.""" - local_generation = self.generation # Capture current generation for this worker - - if generation != local_generation: - log.debug(f"Skipping stale task for index {index} (gen {generation} != {local_generation})") - return None - - try: - # Memory-mapped file reading (faster than traditional read) - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - jpeg_bytes = mmapped[:] - - buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) - if buffer is not None: - # Re-check generation before caching to prevent race conditions - if self.generation != local_generation: - log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") - return None - - h, w, _ = buffer.shape - # In a real Qt app, we would create the QImage here in the main thread - # For now, we'll just store the raw buffer data. - decoded_image = DecodedImage( - buffer=buffer.data, - width=w, - height=h, - bytes_per_line=w * 3, - format=None # Placeholder for QImage.Format.Format_RGB888 - ) - cache_key = f"{index}_{display_generation}" - self.cache_put(cache_key, decoded_image) - log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}") - return index, display_generation - except Exception as e: - log.error(f"Error decoding image {image_file.path} at index {index}: {e}") - - return None - - def _is_in_prefetch_range(self, index: int, current_index: int) -> bool: - """Checks if an index is within the current prefetch window.""" - return abs(index - current_index) <= self.prefetch_radius - - def cancel_all(self): - """Cancels all pending prefetch tasks.""" - log.info("Cancelling all prefetch tasks.") - self.generation += 1 - for future in self.futures.values(): - future.cancel() - self.futures.clear() - - def shutdown(self): - """Shuts down the thread pool executor.""" - log.info("Shutting down prefetcher thread pool.") - self.cancel_all() - self.executor.shutdown(wait=False)