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/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