diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..399a98d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,52 @@ +# Strip CRLF on commit (working tree may still be CRLF on Windows mounts) +* text=auto eol=lf filter=crlf + + +# Explicitly treat these as text (and also run the CRLF->LF clean filter) +*.py text eol=lf filter=crlf +*.sh text eol=lf filter=crlf +*.bash text eol=lf filter=crlf +*.fish text eol=lf filter=crlf +*.qml text eol=lf filter=crlf +*.md text eol=lf filter=crlf +*.txt text eol=lf filter=crlf +*.yml text eol=lf filter=crlf +*.yaml text eol=lf filter=crlf +*.json text eol=lf filter=crlf +*.toml text eol=lf filter=crlf +*.ini text eol=lf filter=crlf +*.cfg text eol=lf filter=crlf + + +# Keep binaries untouched (no eol conversions) +*.png binary +*.jpg binary +*.jpeg binary +*.tif binary +*.tiff binary +*.webp binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.7z binary +*.gz binary +*.bz2 binary +*.xz binary +*.whl binary +*.so binary +*.dll binary +*.dylib binary +*.tiff binary +*.nef binary +*.cr2 binary +*.cr3 binary +*.arw binary +*.dng binary +*.orf binary +*.rw2 binary +*.raf binary +*.heic binary +*.mp4 binary +*.mov binary + diff --git a/ChangeLog.md b/ChangeLog.md index 6a90e17..ed007d3 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,290 +1,290 @@ -# ChangeLog - -Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. - - -## 1.5.2 (2026-01-25) - -### Added -- **Highlight recovery telemetry + UI indicators** - - New highlight state analysis in the editor pipeline (headroom/clipping/near-white metrics) and a UI signal (`highlightStateChanged`) to keep it live. - - Image editor dialog now shows **Headroom** and **Clipped** indicators under the histogram when relevant. -- **Unified EXIF orientation handling** - - Editor now **bakes EXIF orientation into pixels on load** (Pillow original + float master buffer), and saving **sanitizes Orientation to 1** to prevent double-rotation in viewers. - - Prefetcher now applies EXIF orientation in a **single unified post-decode block**, reusing pre-read EXIF when available. -- **Optional OpenCV dependency support** - - Centralized optional OpenCV usage (`optional_deps`) with fallbacks (e.g., Pillow-based Gaussian blur fallback when OpenCV is unavailable). - - Tests updated to skip/patch appropriately when OpenCV isn’t installed. - -### Changed -- **Save flow restored to “old behavior”** - - Saving now: **closes editor → clears editor state → refreshes image list → reselects saved image → clears cache/prefetches → syncs UI**. - - Save errors now surface as user-visible status messages with safer exception handling. -- **Histogram rendering and layout improvements** - - Histogram panel height increased; channel labels expanded (Red/Green/Blue); non-minimal histogram display enabled. - - Single-channel histogram drawing now downsamples using **max-pooling** when canvas width is smaller than bin count to reduce aliasing/spikes. -- **Slider double-click reset robustness** - - Replaced heuristic double-click logic with `TapHandler` double-tap reset and removed competing `slider.value` writers for more deterministic behavior. -- **Color/edit pipeline tuning** - - Contrast and saturation slider sensitivity reduced (scaled effect). - - Headroom “safety” shoulder moved to linear space (`_apply_headroom_shoulder`) replacing the old sRGB-side shoulder. - - Auto-levels now kicks the preview worker for immediate visual feedback; histogram updates are guarded by visibility in more places. - -### Fixed -- **EXIF orientation “double rotation” bugs** - - Saving now consistently drops/sanitizes EXIF when orientation can’t be safely serialized, preventing incorrect viewer rotations. - - Developed JPG sidecar EXIF from a paired JPEG is sanitized for Orientation as well. -- **Prefetch stability under rapid scrolling** - - ImageProvider keepalive queue increased (32 → 128) to reduce crashes from QML/texture lifetime mismatches during thrash. -- **RawTherapee Windows path detection** - - Improved version selection by sorting detected installs via a version-aware path component key (e.g., `5.10` > `5.9`). - - -## [1.5.1] - 2026-01-23 - -### Added -- Added experimental RAW processing via Rawtherapee -- Added explicit **JPEG vs RAW editing modes** with UI + signals to keep QML and backend in sync (`editSourceModeChanged`, `saveBehaviorMessage`). RAW mode can develop to a 16-bit working TIFF and optionally write a `*-developed.jpg` output while leaving the original JPEG untouched. -- Added **RAW development workflow** via RawTherapee **CLI** (`rawtherapee-cli`) with configurable extra args, better error reporting, output validation, and timeout handling. -- Added editor quality upgrades: **16-bit aware editing pipeline** using float32 working buffers, sRGB↔linear conversions for “true headroom” edits, OpenCV Gaussian blur helpers, and new **Texture** control. -- Added editor metadata display in the window title (filename + detected bit depth). -- Added robust undo/restore helper (`_restore_backup_safe`) to better handle locked files and tricky restore scenarios. -- Added support for indexing and displaying `*-developed.jpg` images and **orphaned RAWs** in the browser list; updated pairing test expectations accordingly. - -### Changed -- Reworked README installation instructions: - - macOS recommended flow with **Python 3.12** (Homebrew) + venv + `pip install .` - - Simplified run command (`faststack`) and clarified Windows/Linux steps. -- Switched RawTherapee path detection defaults from GUI executable to **CLI executable** on Windows/macOS/Linux. -- Improved Prefetcher decode behavior by using TurboJPEG **only for JPEGs**, with a Pillow fallback for non-JPEG formats or decode failures. -- Centralized navigation state changes (`_set_current_index`) and ensured edit mode resets appropriately on navigation (defaults back to JPEG unless RAW-only). - -### Fixed -- Fixed editor memory usage by clearing large editor buffers when the editor closes and resetting cached preview state. -- Fixed a QML slider double-click reset edge case where the slider could remain in a pressed/dragging state (force release via a short disable/reenable tick). -- Fixed histogram scheduling/thread-safety issues by tightening locking around pending/inflight state and improving failure handling when preview data is missing or executor submission fails. - - - -## [1.5.0] - 2025-12-01 - -- Fixed rotating images via the crop interface. -- Control-1 zooms to 1:1 magnification (100%). Control-2 to 200, etc to control-4 (400%). - -## [1.4.0] - 2025-12-01 - -- Changed how image caching works for even faster display. -- Pressing H brings up a RGB histogram which is designed to show even a little bit of highlight clipping and updates as you zoom in. -- Added batch delete with confirmation dialog. -- Added the --cachedebug command line argument which gives info on the image cache in the status bar. Doesn't seem to slow down the program at all, just takes up room in the status bar.- Added a setting that switches between image display optimized for speed or quality. -- **Auto-Levels:** Automatic image enhancement with configurable threshold and strength (L key) -- **Image Metadata:** Extract and display EXIF metadata (I key) -- **Image Processing:** Auto white balance, texture enhancement, and straightening -- **Crop Operations:** Fixed crop functionality with rotation support - -## [1.3.0] - 2025-11-23 - -- Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. -- Sorts images by time. -- Added the Stack Source Raws feature in the Action menu - if you import your images with stackcopy.py --lightroomimport (https://github.com/AlanRockefeller/faststack) and you are viewing a photo stacked in-camera, this feature will open the raw images that made this stack in Helicon Focus. -- Some fixes to the image cache - it doesn't expire when it shouldn't, does expire when it should, and warns you when the cache is full so you can consider increasing the cache size in settings. - - -## [1.2.0] - 2025-11-22 - -- Fixed menus, they now work well and look cool. -- Updated auto white balance to make it better, and put some controls for it in the settings - -## [1.1.0] - 2025-11-22 - -### Major Features -- **Built-in Image Editor:** Full-featured image editor with draggable window - - Exposure, highlights, shadows, whites, blacks, brightness, contrast - - White balance (Blue/Yellow and Magenta/Green axes) - - Auto White Balance button using grey world assumption - - Saturation, vibrance, clarity, sharpness - - Vignette effect - - Rotation (90°, 180°, 270°) - - EXIF metadata preservation (GPS, camera settings, timestamps) - - Press `E` to open editor, `Ctrl+S` to save - - Sequential backup naming (filename-backup.jpg, filename-backup2.jpg, etc.) - -- **Quick Auto White Balance:** Press `A` key for instant auto white balance - - Uses grey world assumption algorithm - - Automatically saves with backup - - Full undo support with Ctrl+Z - -- **Enhanced Batch Display:** Batch counter shows total selected images - - `B` key toggles images in/out of batch selection - -### UI/UX Improvements -- **Updated Key Bindings Dialog:** Added documentation for new features - - Auto white balance (A key) - - Image editor toggle (E key) - -## [1.0.0] - 2025-11-21 - -### Major Features -- **Batch Selection System:** New batch selection mode for drag-and-drop operations - - `{` to begin batch, `}` to end batch, `\` to clear all batches - - `X` or `S` keys remove individual images from batches/stacks (shrinks or splits ranges) - - Batches automatically cleared after successful drag operation - - Multiple files can now be dragged to browsers and external applications simultaneously -- **Manual Flag Toggles:** Added keyboard shortcuts to manually control metadata flags - - `U` toggles uploaded flag - - `Ctrl+E` toggles edited flag - - `Ctrl+S` toggles stacked flag -- **Edited Flag Tracking:** New metadata flag for images edited in Photoshop - - Displays "Edited on [date]" in status bar (green) - - Can be manually toggled with `Ctrl+E` -- **Jump to Image Dialog:** Press `G` to jump directly to any image by number - - Dynamic input field sizing based on image count - - Proper keyboard event capture while dialog is open - -### UI/UX Improvements -- **Auto Zoom Reset:** Image view automatically resets to fit-window after drag operations -- **Smooth Window Dragging:** Fixed flickering when dragging title bar by using global coordinates -- **Status Bar Enhancements:** - - Added batch info display (green badge showing position/count) - - Added uploaded status display - - Added edited status display - -### Bug Fixes -- **Multi-file Drag:** Simplified drag implementation to work correctly with Chrome and other browsers - -## [0.9.0] - 2025-11-20 - -### Performance Improvements -- **Zero-Copy JPEG Read:** Eliminated memory copy by passing mmap directly to decoders, reducing I/O time by 25-60% for large JPEGs. -- **Filter Performance:** Cached image list in memory to eliminate disk scans on every filter keystroke (100-1000x faster for large directories). -- **Smart Cache Management:** Removed unnecessary cache clearing on resize/zoom - LRU naturally evicts old entries while allowing instant reuse. -- **Generation Thrashing Fix:** Navigation no longer increments generation counter, preventing cache invalidation on every keystroke. -- **Directional Prefetching:** Asymmetric prefetch now biases 70% ahead and 30% behind in travel direction for faster sequential browsing. -- **ICC Transform Caching:** Cached ICC color transforms to eliminate repeated transform builds during color-managed viewing. -- **TurboJPEG for ICC:** ICC color path now uses TurboJPEG for decode+resize, then Pillow only for color conversion. - -### Features -- **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent. -- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis.- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). -- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis. -- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). - -## [0.8.0] - 2025-11-20- Backspace key now deletes images (in addition to Delete key). Control-Z restores. -- Photoshop integration now automatically uses RAW files when available, falling back to JPG. -- We now have some new color modes in the view menu to make the images in your monitor reflect reality. ICC profile mode works best on my system - try it if the images are over-saturated - or turn down the saturation in saturation mode. Test it out by loading an image in Faststack and Photoshop or another image viewer and make sure the colors look the same. - -## [0.7.0] - 2025-11-20 - -### Added -- **High-DPI Display Support:** Images now render at full physical pixel resolution on 4K displays by accounting for `devicePixelRatio` in display size calculations. -- **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. - -### Fixed -- **Property Name Mismatch:** Corrected `get_stack_summary` to `stackSummary` in UIState to match QML property naming conventions. -- **FilterDialog Theme Support:** Enhanced FilterDialog with proper Material theme support and background styling for consistent dark/light mode appearance. -- **Missing Signal Emissions:** Added `stackSummaryChanged` signal emission when stacks are created, cleared, or processed. - -### Changed -- **Improved Error Handling:** Replaced broad `Exception` catches with specific exception types (`OSError`, `subprocess.SubprocessError`, `FileNotFoundError`, `IOError`, `PermissionError`). -- **Better Logging:** Changed `log.error()` to `log.exception()` to include full tracebacks for debugging. -- **Argument Parsing:** Now uses `shlex.split()` with platform-aware parsing (Windows vs POSIX) for proper handling of quoted paths and special characters. - -### Testing -- **Executable Validator Tests:** Added comprehensive test suite for executable path validation with 8 test cases covering various security scenarios. - -## [0.6.0] - 2025-11-03 - -### Fixed -- Resolved an issue where the prefetch range was not being applied correctly after changing the prefetch radius in settings. -- Corrected `decode_jpeg_thumb_rgb` to ensure that thumbnails generated by PyTurboJPEG do not exceed the `max_dim` by falling back to Pillow resizing when necessary. -- Addressed excessive metadata queries during application startup by deferring UI synchronization until after images are loaded. -- Fixed a bug where the zoom state callback was not firing, leading to low-resolution images being served when zoomed in. -- Resolved a QML error "Cannot assign to non-existent property 'scaleTransform'" by correctly placing the scale change handlers within the `Scale` transform. -- Handled the empty image files case in preloading to prevent unnecessary processing and correctly update the UI. - -## [0.5.0] - 2025-11-03 - -### Added -- Load full-resolution images when zooming in for maximum detail. -- Call Helicon Focus for each defined stack when multiple stacks are present. - -### Changed -- The filesystem watcher is now less sensitive to spurious modification events, reducing unnecessary refreshes. -- The preloading process now shares the same thread pool as the prefetcher for better resource utilization. -- Stacks are now cleared automatically after being sent to Helicon Focus. - -### Fixed -- Corrected a `ValueError` in `PyTurboJPEG` caused by unsupported scaling factors. -- Resolved an `AttributeError` in the JPEG scaling factor calculation. -- Fixed an issue where panning the image was not working correctly. -- Addressed a bug where panning speed was incorrect at high zoom levels. -- Ensured that stale prefetcher futures are cancelled when the display size changes. - -### Performance -- Improved image decoding performance by using `PyTurboJPEG` for resized decoding. -- Tuned the number of prefetcher thread pool workers based on system CPU cores. -- Replaced synchronous file reads with memory-mapped I/O for faster image loading. -- Optimized image resizing by using `BILINEAR` resampling for large downscales. -- Debounced display size change notifications to reduce redundant UI updates. - -## Version 0.4 - -### Todo - -Make it use the full res image when zooming in -When multiple stacks are selected, call Helicon multiple times -After Helicon is called, clear the stacks -Fix S key - I guess it should remove an image from the stack? Clarify what it does now. - -### New Features -- **Two-tier caching system:** Implemented a two-tier caching system to prefetch display-sized images, significantly improving performance and reducing GPU memory usage. -- **"Preload All Images" feature:** Added a new menu option under "Actions" to preload all images in the current directory into the cache, ensuring quick access even for unviewed images. -- **Progress bar for preloading:** Introduced a visual progress bar in the footer to display the status of the "Preload All Images" operation. - -### Changes -- **Theming improvements:** Adjusted the Material theme to ensure the menubar background is black in dark mode, providing a more consistent user experience. -- **Window behavior:** Changed the application window to a borderless fullscreen mode, allowing for normal Alt-Tab behavior and better integration with the operating system. - -## Version 0.3 - -### New Features -- Implemented a "Settings" dialog with the following configurable options: - - Helicon Focus executable path (with validation). - - Image cache size (in GB). - - Image prefetch radius. - - Application theme (Dark/Light). - - Default image directory. - -## Version 0.2 - -### New Features -- Added an "Actions" menu with the following options: - - "Run Stacks": Launch Helicon Focus with selected files or all stacks. - - "Clear Stacks": Clear all defined stacks. - - "Show Stacks": Display a dialog with information about the defined stacks. -- Pressing the 'S' key now adds or removes a RAW file from the selection for processing. -- Implemented tracking for stacked images: - - `EntryMetadata` now includes `stacked` (boolean) and `stacked_date` (string) fields. - - `launch_helicon` records stacking status and date upon successful launch. - - The footer in `Main.qml` displays "Stacked: [date]" for previously stacked images. - -### Changes -- Pressing the 'Enter' key will now launch Helicon Focus with the selected RAW files. If no files are selected, it will launch with all defined stacks. -- Refactored the theme toggling logic in `Main.qml` to use a boolean `isDarkTheme` property for more robustness. - -### Bug Fixes -- Fixed an issue where both the main "Enter" key and the numeric keypad "Enter" key were not consistently recognized. -- The "Show Stacks" and "Key Bindings" dialogs now correctly follow the application's theme (light/dark mode). -- Fixed a bug that caused the "Show Stacks" dialog to be blank. -- Resolved a `NameError` caused by using `Optional` without importing it. -- Corrected an import error for `EntryMetadata` in the tests. -- Updated a test to assert the correct default version number. -- Fixed a `TypeError` in tests caused by a missing `stack_id` field in the `EntryMetadata` model. -- Resolved a QML issue where `anchors.fill` conflicted with manual positioning, preventing panning and zooming. -- Corrected the `launch_helicon` method to only clear the `selected_raws` set if Helicon Focus is launched successfully. -- Resolved `TypeError` and `Invalid property assignment` errors in QML related to settings dialog initialization and property bindings. -- Fixed QML warnings related to invalid anchor usage in `Main.qml`. -- Fixed missing minimize, maximize, and close buttons by correctly configuring the custom title bar. -- Resolved QML warnings about `mouse` parameter not being declared in `MouseArea` signal handlers. +# ChangeLog + +Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. + + +## 1.5.2 (2026-01-25) + +### Added +- **Highlight recovery telemetry + UI indicators** + - New highlight state analysis in the editor pipeline (headroom/clipping/near-white metrics) and a UI signal (`highlightStateChanged`) to keep it live. + - Image editor dialog now shows **Headroom** and **Clipped** indicators under the histogram when relevant. +- **Unified EXIF orientation handling** + - Editor now **bakes EXIF orientation into pixels on load** (Pillow original + float master buffer), and saving **sanitizes Orientation to 1** to prevent double-rotation in viewers. + - Prefetcher now applies EXIF orientation in a **single unified post-decode block**, reusing pre-read EXIF when available. +- **Optional OpenCV dependency support** + - Centralized optional OpenCV usage (`optional_deps`) with fallbacks (e.g., Pillow-based Gaussian blur fallback when OpenCV is unavailable). + - Tests updated to skip/patch appropriately when OpenCV isn’t installed. + +### Changed +- **Save flow restored to “old behavior”** + - Saving now: **closes editor → clears editor state → refreshes image list → reselects saved image → clears cache/prefetches → syncs UI**. + - Save errors now surface as user-visible status messages with safer exception handling. +- **Histogram rendering and layout improvements** + - Histogram panel height increased; channel labels expanded (Red/Green/Blue); non-minimal histogram display enabled. + - Single-channel histogram drawing now downsamples using **max-pooling** when canvas width is smaller than bin count to reduce aliasing/spikes. +- **Slider double-click reset robustness** + - Replaced heuristic double-click logic with `TapHandler` double-tap reset and removed competing `slider.value` writers for more deterministic behavior. +- **Color/edit pipeline tuning** + - Contrast and saturation slider sensitivity reduced (scaled effect). + - Headroom “safety” shoulder moved to linear space (`_apply_headroom_shoulder`) replacing the old sRGB-side shoulder. + - Auto-levels now kicks the preview worker for immediate visual feedback; histogram updates are guarded by visibility in more places. + +### Fixed +- **EXIF orientation “double rotation” bugs** + - Saving now consistently drops/sanitizes EXIF when orientation can’t be safely serialized, preventing incorrect viewer rotations. + - Developed JPG sidecar EXIF from a paired JPEG is sanitized for Orientation as well. +- **Prefetch stability under rapid scrolling** + - ImageProvider keepalive queue increased (32 → 128) to reduce crashes from QML/texture lifetime mismatches during thrash. +- **RawTherapee Windows path detection** + - Improved version selection by sorting detected installs via a version-aware path component key (e.g., `5.10` > `5.9`). + + +## [1.5.1] - 2026-01-23 + +### Added +- Added experimental RAW processing via Rawtherapee +- Added explicit **JPEG vs RAW editing modes** with UI + signals to keep QML and backend in sync (`editSourceModeChanged`, `saveBehaviorMessage`). RAW mode can develop to a 16-bit working TIFF and optionally write a `*-developed.jpg` output while leaving the original JPEG untouched. +- Added **RAW development workflow** via RawTherapee **CLI** (`rawtherapee-cli`) with configurable extra args, better error reporting, output validation, and timeout handling. +- Added editor quality upgrades: **16-bit aware editing pipeline** using float32 working buffers, sRGB↔linear conversions for “true headroom” edits, OpenCV Gaussian blur helpers, and new **Texture** control. +- Added editor metadata display in the window title (filename + detected bit depth). +- Added robust undo/restore helper (`_restore_backup_safe`) to better handle locked files and tricky restore scenarios. +- Added support for indexing and displaying `*-developed.jpg` images and **orphaned RAWs** in the browser list; updated pairing test expectations accordingly. + +### Changed +- Reworked README installation instructions: + - macOS recommended flow with **Python 3.12** (Homebrew) + venv + `pip install .` + - Simplified run command (`faststack`) and clarified Windows/Linux steps. +- Switched RawTherapee path detection defaults from GUI executable to **CLI executable** on Windows/macOS/Linux. +- Improved Prefetcher decode behavior by using TurboJPEG **only for JPEGs**, with a Pillow fallback for non-JPEG formats or decode failures. +- Centralized navigation state changes (`_set_current_index`) and ensured edit mode resets appropriately on navigation (defaults back to JPEG unless RAW-only). + +### Fixed +- Fixed editor memory usage by clearing large editor buffers when the editor closes and resetting cached preview state. +- Fixed a QML slider double-click reset edge case where the slider could remain in a pressed/dragging state (force release via a short disable/reenable tick). +- Fixed histogram scheduling/thread-safety issues by tightening locking around pending/inflight state and improving failure handling when preview data is missing or executor submission fails. + + + +## [1.5.0] - 2025-12-01 + +- Fixed rotating images via the crop interface. +- Control-1 zooms to 1:1 magnification (100%). Control-2 to 200, etc to control-4 (400%). + +## [1.4.0] - 2025-12-01 + +- Changed how image caching works for even faster display. +- Pressing H brings up a RGB histogram which is designed to show even a little bit of highlight clipping and updates as you zoom in. +- Added batch delete with confirmation dialog. +- Added the --cachedebug command line argument which gives info on the image cache in the status bar. Doesn't seem to slow down the program at all, just takes up room in the status bar.- Added a setting that switches between image display optimized for speed or quality. +- **Auto-Levels:** Automatic image enhancement with configurable threshold and strength (L key) +- **Image Metadata:** Extract and display EXIF metadata (I key) +- **Image Processing:** Auto white balance, texture enhancement, and straightening +- **Crop Operations:** Fixed crop functionality with rotation support + +## [1.3.0] - 2025-11-23 + +- Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. +- Sorts images by time. +- Added the Stack Source Raws feature in the Action menu - if you import your images with stackcopy.py --lightroomimport (https://github.com/AlanRockefeller/faststack) and you are viewing a photo stacked in-camera, this feature will open the raw images that made this stack in Helicon Focus. +- Some fixes to the image cache - it doesn't expire when it shouldn't, does expire when it should, and warns you when the cache is full so you can consider increasing the cache size in settings. + + +## [1.2.0] - 2025-11-22 + +- Fixed menus, they now work well and look cool. +- Updated auto white balance to make it better, and put some controls for it in the settings + +## [1.1.0] - 2025-11-22 + +### Major Features +- **Built-in Image Editor:** Full-featured image editor with draggable window + - Exposure, highlights, shadows, whites, blacks, brightness, contrast + - White balance (Blue/Yellow and Magenta/Green axes) + - Auto White Balance button using grey world assumption + - Saturation, vibrance, clarity, sharpness + - Vignette effect + - Rotation (90°, 180°, 270°) + - EXIF metadata preservation (GPS, camera settings, timestamps) + - Press `E` to open editor, `Ctrl+S` to save + - Sequential backup naming (filename-backup.jpg, filename-backup2.jpg, etc.) + +- **Quick Auto White Balance:** Press `A` key for instant auto white balance + - Uses grey world assumption algorithm + - Automatically saves with backup + - Full undo support with Ctrl+Z + +- **Enhanced Batch Display:** Batch counter shows total selected images + - `B` key toggles images in/out of batch selection + +### UI/UX Improvements +- **Updated Key Bindings Dialog:** Added documentation for new features + - Auto white balance (A key) + - Image editor toggle (E key) + +## [1.0.0] - 2025-11-21 + +### Major Features +- **Batch Selection System:** New batch selection mode for drag-and-drop operations + - `{` to begin batch, `}` to end batch, `\` to clear all batches + - `X` or `S` keys remove individual images from batches/stacks (shrinks or splits ranges) + - Batches automatically cleared after successful drag operation + - Multiple files can now be dragged to browsers and external applications simultaneously +- **Manual Flag Toggles:** Added keyboard shortcuts to manually control metadata flags + - `U` toggles uploaded flag + - `Ctrl+E` toggles edited flag + - `Ctrl+S` toggles stacked flag +- **Edited Flag Tracking:** New metadata flag for images edited in Photoshop + - Displays "Edited on [date]" in status bar (green) + - Can be manually toggled with `Ctrl+E` +- **Jump to Image Dialog:** Press `G` to jump directly to any image by number + - Dynamic input field sizing based on image count + - Proper keyboard event capture while dialog is open + +### UI/UX Improvements +- **Auto Zoom Reset:** Image view automatically resets to fit-window after drag operations +- **Smooth Window Dragging:** Fixed flickering when dragging title bar by using global coordinates +- **Status Bar Enhancements:** + - Added batch info display (green badge showing position/count) + - Added uploaded status display + - Added edited status display + +### Bug Fixes +- **Multi-file Drag:** Simplified drag implementation to work correctly with Chrome and other browsers + +## [0.9.0] - 2025-11-20 + +### Performance Improvements +- **Zero-Copy JPEG Read:** Eliminated memory copy by passing mmap directly to decoders, reducing I/O time by 25-60% for large JPEGs. +- **Filter Performance:** Cached image list in memory to eliminate disk scans on every filter keystroke (100-1000x faster for large directories). +- **Smart Cache Management:** Removed unnecessary cache clearing on resize/zoom - LRU naturally evicts old entries while allowing instant reuse. +- **Generation Thrashing Fix:** Navigation no longer increments generation counter, preventing cache invalidation on every keystroke. +- **Directional Prefetching:** Asymmetric prefetch now biases 70% ahead and 30% behind in travel direction for faster sequential browsing. +- **ICC Transform Caching:** Cached ICC color transforms to eliminate repeated transform builds during color-managed viewing. +- **TurboJPEG for ICC:** ICC color path now uses TurboJPEG for decode+resize, then Pillow only for color conversion. + +### Features +- **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent. +- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis.- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). +- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis. +- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). + +## [0.8.0] - 2025-11-20- Backspace key now deletes images (in addition to Delete key). Control-Z restores. +- Photoshop integration now automatically uses RAW files when available, falling back to JPG. +- We now have some new color modes in the view menu to make the images in your monitor reflect reality. ICC profile mode works best on my system - try it if the images are over-saturated - or turn down the saturation in saturation mode. Test it out by loading an image in Faststack and Photoshop or another image viewer and make sure the colors look the same. + +## [0.7.0] - 2025-11-20 + +### Added +- **High-DPI Display Support:** Images now render at full physical pixel resolution on 4K displays by accounting for `devicePixelRatio` in display size calculations. +- **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. + +### Fixed +- **Property Name Mismatch:** Corrected `get_stack_summary` to `stackSummary` in UIState to match QML property naming conventions. +- **FilterDialog Theme Support:** Enhanced FilterDialog with proper Material theme support and background styling for consistent dark/light mode appearance. +- **Missing Signal Emissions:** Added `stackSummaryChanged` signal emission when stacks are created, cleared, or processed. + +### Changed +- **Improved Error Handling:** Replaced broad `Exception` catches with specific exception types (`OSError`, `subprocess.SubprocessError`, `FileNotFoundError`, `IOError`, `PermissionError`). +- **Better Logging:** Changed `log.error()` to `log.exception()` to include full tracebacks for debugging. +- **Argument Parsing:** Now uses `shlex.split()` with platform-aware parsing (Windows vs POSIX) for proper handling of quoted paths and special characters. + +### Testing +- **Executable Validator Tests:** Added comprehensive test suite for executable path validation with 8 test cases covering various security scenarios. + +## [0.6.0] - 2025-11-03 + +### Fixed +- Resolved an issue where the prefetch range was not being applied correctly after changing the prefetch radius in settings. +- Corrected `decode_jpeg_thumb_rgb` to ensure that thumbnails generated by PyTurboJPEG do not exceed the `max_dim` by falling back to Pillow resizing when necessary. +- Addressed excessive metadata queries during application startup by deferring UI synchronization until after images are loaded. +- Fixed a bug where the zoom state callback was not firing, leading to low-resolution images being served when zoomed in. +- Resolved a QML error "Cannot assign to non-existent property 'scaleTransform'" by correctly placing the scale change handlers within the `Scale` transform. +- Handled the empty image files case in preloading to prevent unnecessary processing and correctly update the UI. + +## [0.5.0] - 2025-11-03 + +### Added +- Load full-resolution images when zooming in for maximum detail. +- Call Helicon Focus for each defined stack when multiple stacks are present. + +### Changed +- The filesystem watcher is now less sensitive to spurious modification events, reducing unnecessary refreshes. +- The preloading process now shares the same thread pool as the prefetcher for better resource utilization. +- Stacks are now cleared automatically after being sent to Helicon Focus. + +### Fixed +- Corrected a `ValueError` in `PyTurboJPEG` caused by unsupported scaling factors. +- Resolved an `AttributeError` in the JPEG scaling factor calculation. +- Fixed an issue where panning the image was not working correctly. +- Addressed a bug where panning speed was incorrect at high zoom levels. +- Ensured that stale prefetcher futures are cancelled when the display size changes. + +### Performance +- Improved image decoding performance by using `PyTurboJPEG` for resized decoding. +- Tuned the number of prefetcher thread pool workers based on system CPU cores. +- Replaced synchronous file reads with memory-mapped I/O for faster image loading. +- Optimized image resizing by using `BILINEAR` resampling for large downscales. +- Debounced display size change notifications to reduce redundant UI updates. + +## Version 0.4 + +### Todo + +Make it use the full res image when zooming in +When multiple stacks are selected, call Helicon multiple times +After Helicon is called, clear the stacks +Fix S key - I guess it should remove an image from the stack? Clarify what it does now. + +### New Features +- **Two-tier caching system:** Implemented a two-tier caching system to prefetch display-sized images, significantly improving performance and reducing GPU memory usage. +- **"Preload All Images" feature:** Added a new menu option under "Actions" to preload all images in the current directory into the cache, ensuring quick access even for unviewed images. +- **Progress bar for preloading:** Introduced a visual progress bar in the footer to display the status of the "Preload All Images" operation. + +### Changes +- **Theming improvements:** Adjusted the Material theme to ensure the menubar background is black in dark mode, providing a more consistent user experience. +- **Window behavior:** Changed the application window to a borderless fullscreen mode, allowing for normal Alt-Tab behavior and better integration with the operating system. + +## Version 0.3 + +### New Features +- Implemented a "Settings" dialog with the following configurable options: + - Helicon Focus executable path (with validation). + - Image cache size (in GB). + - Image prefetch radius. + - Application theme (Dark/Light). + - Default image directory. + +## Version 0.2 + +### New Features +- Added an "Actions" menu with the following options: + - "Run Stacks": Launch Helicon Focus with selected files or all stacks. + - "Clear Stacks": Clear all defined stacks. + - "Show Stacks": Display a dialog with information about the defined stacks. +- Pressing the 'S' key now adds or removes a RAW file from the selection for processing. +- Implemented tracking for stacked images: + - `EntryMetadata` now includes `stacked` (boolean) and `stacked_date` (string) fields. + - `launch_helicon` records stacking status and date upon successful launch. + - The footer in `Main.qml` displays "Stacked: [date]" for previously stacked images. + +### Changes +- Pressing the 'Enter' key will now launch Helicon Focus with the selected RAW files. If no files are selected, it will launch with all defined stacks. +- Refactored the theme toggling logic in `Main.qml` to use a boolean `isDarkTheme` property for more robustness. + +### Bug Fixes +- Fixed an issue where both the main "Enter" key and the numeric keypad "Enter" key were not consistently recognized. +- The "Show Stacks" and "Key Bindings" dialogs now correctly follow the application's theme (light/dark mode). +- Fixed a bug that caused the "Show Stacks" dialog to be blank. +- Resolved a `NameError` caused by using `Optional` without importing it. +- Corrected an import error for `EntryMetadata` in the tests. +- Updated a test to assert the correct default version number. +- Fixed a `TypeError` in tests caused by a missing `stack_id` field in the `EntryMetadata` model. +- Resolved a QML issue where `anchors.fill` conflicted with manual positioning, preventing panning and zooming. +- Corrected the `launch_helicon` method to only clear the `selected_raws` set if Helicon Focus is launched successfully. +- Resolved `TypeError` and `Invalid property assignment` errors in QML related to settings dialog initialization and property bindings. +- Fixed QML warnings related to invalid anchor usage in `Main.qml`. +- Fixed missing minimize, maximize, and close buttons by correctly configuring the custom title bar. +- Resolved QML warnings about `mouse` parameter not being declared in `MouseArea` signal handlers. diff --git a/MANIFEST.in b/MANIFEST.in index 6a3767a..b169f15 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ -recursive-include faststack/qml * -include LICENSE -include README.md +recursive-include faststack/qml * +include LICENSE +include README.md diff --git a/README.md b/README.md index b353329..8551109 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,101 @@ -# FastStack - -# Version 1.5 - January 1, 2026 -# By Alan Rockefeller - -Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. - -This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive prefetching, and byte-aware LRU caches to provide a fluid experience when reviewing thousands of images. - -## Features - -- **Crop:** Added the ability to crop and rotate images via the cr(O)p hotkey (or right mouse click). It can be a freeform crop, or constrained to several popular aspect ratios. -- **Zoom & Pan:** Smooth zooming and panning. -- **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). -- **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). -- **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. -- **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) -- **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance, load the raw into Photoshop with the P key.- **Photoshop Integration:** Edit current image in Photoshop (P key) - always uses RAW files when available. -- **Clipboard Support:** Copy image path to clipboard (Ctrl+C) -- **Image Filtering:** Filter images by filename -- **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. -- **Theme Support:** Toggle between light and dark themes -- **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) -- **Has Memory:** Starts where you left off, tells you which images have been edited, stacked and uploaded. -- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). -- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus / Photoshop paths via a settings dialog and a persistent `.ini` file. -- **Accurate Colors:** Uses monitor ICC profile to display colors correctly. -- **RGB Histogram:** Pressing H brings up a RGB histogram which is designed to show even a little bit of highlight clipping and updates as you zoom in. - -## Installation - -### macOS (Recommended) -FastStack performs best on Python 3.12 due to PySide6 compatibility. - -1. **Install Python 3.12 (via Homebrew):** - ```bash - brew install python@3.12 - ``` - -2. **Create and Activate a Virtual Environment:** - ```bash - python3.12 -m venv venv - source venv/bin/activate - ``` - -3. **Install FastStack:** - ```bash - # From source directory - python -m pip install -U pip - python -m pip install . - ``` - *Note: If you encounter issues with `opencv-python` or `PySide6` on newer Python versions (3.13+), please stick to Python 3.12.* - -4. **Run:** - ```bash - faststack - ``` - -### Windows / Linux -```bash -python -m venv venv -# Activate venv (Windows: venv\Scripts\activate, Linux: source venv/bin/activate) -pip install . -faststack -``` - -## Keyboard Shortcuts - -- `J` / `Right Arrow`: Next Image -- `K` / `Left Arrow`: Previous Image -- `G`: Jump to Image Number -- `I`: Show EXIF Data -- `S`: Toggle current image in/out of stack -- `X`: Remove current image from batch/stack -- `B`: Toggle current image in/out of batch -- `[`: Begin new stack group -- `]`: End current stack group -- `C`: Clear all stacks -- `{`: Begin new drag & drop batch -- `}`: End current drag & drop batch -- `\`: Clear drag & drop batch -- `U`: Toggle uploaded flag -- `Ctrl+E`: Toggle edited flag -- `Ctrl+S`: Toggle stacked flag -- `Enter`: Launch Helicon Focus with selected RAWs -- `P`: Edit in Photoshop (uses RAW file if available) -- `O` (or Right-Click): Toggle crop mode (Enter to execute, Esc to cancel) -- `Delete` / `Backspace`: Move image to recycle bin -- `Ctrl+Z`: Undo last action (delete, auto white balance, or crop) -- `A`: Quick auto white balance (saves automatically) -- `Ctrl+Shift+B`: Quick auto white balance (alternate) -- `L`: Quick auto levels (saves automatically) -- `E`: Toggle Image Editor -- `Esc`: Close active dialog, editor, or cancel crop -- `H`: Toggle histogram window -- `Ctrl+C`: Copy image path to clipboard -- `Ctrl+0`: Reset zoom and pan to fit window -- `Ctrl+1`: Zoom to 100% -- `Ctrl+2`: Zoom to 200% -- `Ctrl+3`: Zoom to 300% -- `Ctrl+4`: Zoom to 400% +# FastStack + +# Version 1.5 - January 1, 2026 +# By Alan Rockefeller + +Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. + +This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive prefetching, and byte-aware LRU caches to provide a fluid experience when reviewing thousands of images. + +## Features + +- **Crop:** Added the ability to crop and rotate images via the cr(O)p hotkey (or right mouse click). It can be a freeform crop, or constrained to several popular aspect ratios. +- **Zoom & Pan:** Smooth zooming and panning. +- **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). +- **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). +- **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. +- **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) +- **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance, load the raw into Photoshop with the P key.- **Photoshop Integration:** Edit current image in Photoshop (P key) - always uses RAW files when available. +- **Clipboard Support:** Copy image path to clipboard (Ctrl+C) +- **Image Filtering:** Filter images by filename +- **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. +- **Theme Support:** Toggle between light and dark themes +- **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) +- **Has Memory:** Starts where you left off, tells you which images have been edited, stacked and uploaded. +- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). +- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus / Photoshop paths via a settings dialog and a persistent `.ini` file. +- **Accurate Colors:** Uses monitor ICC profile to display colors correctly. +- **RGB Histogram:** Pressing H brings up a RGB histogram which is designed to show even a little bit of highlight clipping and updates as you zoom in. + +## Installation + +### macOS (Recommended) +FastStack performs best on Python 3.12 due to PySide6 compatibility. + +1. **Install Python 3.12 (via Homebrew):** + ```bash + brew install python@3.12 + ``` + +2. **Create and Activate a Virtual Environment:** + ```bash + python3.12 -m venv venv + source venv/bin/activate + ``` + +3. **Install FastStack:** + ```bash + # From source directory + python -m pip install -U pip + python -m pip install . + ``` + *Note: If you encounter issues with `opencv-python` or `PySide6` on newer Python versions (3.13+), please stick to Python 3.12.* + +4. **Run:** + ```bash + faststack + ``` + +### Windows / Linux +```bash +python -m venv venv +# Activate venv (Windows: venv\Scripts\activate, Linux: source venv/bin/activate) +pip install . +faststack +``` + +## Keyboard Shortcuts + +- `J` / `Right Arrow`: Next Image +- `K` / `Left Arrow`: Previous Image +- `G`: Jump to Image Number +- `I`: Show EXIF Data +- `S`: Toggle current image in/out of stack +- `X`: Remove current image from batch/stack +- `B`: Toggle current image in/out of batch +- `[`: Begin new stack group +- `]`: End current stack group +- `C`: Clear all stacks +- `{`: Begin new drag & drop batch +- `}`: End current drag & drop batch +- `\`: Clear drag & drop batch +- `U`: Toggle uploaded flag +- `Ctrl+E`: Toggle edited flag +- `Ctrl+S`: Toggle stacked flag +- `Enter`: Launch Helicon Focus with selected RAWs +- `P`: Edit in Photoshop (uses RAW file if available) +- `O` (or Right-Click): Toggle crop mode (Enter to execute, Esc to cancel) +- `Delete` / `Backspace`: Move image to recycle bin +- `Ctrl+Z`: Undo last action (delete, auto white balance, or crop) +- `A`: Quick auto white balance (saves automatically) +- `Ctrl+Shift+B`: Quick auto white balance (alternate) +- `L`: Quick auto levels (saves automatically) +- `E`: Toggle Image Editor +- `Esc`: Close active dialog, editor, or cancel crop +- `H`: Toggle histogram window +- `Ctrl+C`: Copy image path to clipboard +- `Ctrl+0`: Reset zoom and pan to fit window +- `Ctrl+1`: Zoom to 100% +- `Ctrl+2`: Zoom to 200% +- `Ctrl+3`: Zoom to 300% +- `Ctrl+4`: Zoom to 400% diff --git a/debug_al.py b/debug_al.py index 5b9a0e0..02cfc88 100644 --- a/debug_al.py +++ b/debug_al.py @@ -1,21 +1,21 @@ - -import numpy as np -from PIL import Image -from faststack.imaging.editor import ImageEditor - -def debug_run(): - editor = ImageEditor() - w, h = 200, 200 - arr = np.zeros((h, w, 3), dtype=np.uint8) - arr[:] = 200 - arr[0, 0, 0] = 255 - - img = Image.fromarray(arr, 'RGB') - editor.original_image = img - editor._preview_image = img - - blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) - print(f"RESULT: p_high={p_high}") - -if __name__ == "__main__": - debug_run() + +import numpy as np +from PIL import Image +from faststack.imaging.editor import ImageEditor + +def debug_run(): + editor = ImageEditor() + w, h = 200, 200 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 200 + arr[0, 0, 0] = 255 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) + print(f"RESULT: p_high={p_high}") + +if __name__ == "__main__": + debug_run() diff --git a/faststack/app.py b/faststack/app.py index 703be33..85c83d6 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1,4302 +1,4299 @@ -"""Main application entry point for FastStack.""" - -import logging -import sys -import math -import struct -import shlex -import time -import argparse -from pathlib import Path -from typing import Optional, List, Dict, Any, Tuple -from datetime import date -import os -import shutil -import uuid -import functools -# Must set before importing PySide6 -os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" - -# Type Aliases for readability -DeletePair = Tuple[Optional[Path], Optional[Path]] # (src_path, recycle_bin_path) -DeleteRecord = Tuple[DeletePair, DeletePair] # (jpg_pair, raw_pair) - -import concurrent.futures -import threading -import subprocess -from faststack.ui.provider import ImageProvider, UIState -import PySide6 -from PySide6.QtGui import QDrag, QPixmap -from PySide6.QtCore import ( - QUrl, - QTimer, - QObject, - QEvent, - Signal, - Slot, - QMimeData, - Qt, - QPoint, - QCoreApplication -) -from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox -from PySide6.QtQml import QQmlApplicationEngine -from PIL import Image -Image.MAX_IMAGE_PIXELS = 200_000_000 # 200 megapixels, enough for most photos -# ⬇️ these are the ones that went missing -from faststack.config import config -from faststack.logging_setup import setup_logging -from faststack.models import ImageFile, DecodedImage, EntryMetadata -from faststack.io.indexer import find_images -from faststack.io.sidecar import SidecarManager -from faststack.io.watcher import Watcher -from faststack.io.helicon import launch_helicon_focus -from faststack.io.executable_validator import validate_executable_path -from faststack.imaging.cache import ByteLRUCache, get_decoded_image_size, build_cache_key -from faststack.imaging.prefetch import Prefetcher, clear_icc_caches -from faststack.ui.provider import ImageProvider -from faststack.ui.keystrokes import Keybinder -from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS, create_backup_file -from faststack.imaging.metadata import get_exif_data -import re -import numpy as np -from faststack.io.indexer import RAW_EXTENSIONS - -def make_hdrop(paths): - """ - Build a real CF_HDROP (DROPFILES) payload for Windows drag-and-drop. - paths: list[str] - """ - files_part = ("\0".join(paths) + "\0\0").encode("utf-16le") - - # DROPFILES header (20 bytes): bool: - """Checks if a working TIFF path is valid for editing.""" - try: - return path.exists() and path.stat().st_size > 0 - except OSError: - return False - - def get_active_edit_path(self, index: int) -> Path: - """ - Determines the correct file path to use for editing/exporting based on current mode. - - Rules: - 1. If index invalid, raise IndexError or return None (caller handles). - 2. If image is RAW-only (no paired JPEG and path is RAW ext), force "raw" mode functionality. - (Note: ImageFile.path is usually the JPEG if it exists. If it's a RAW file, it means orphaned RAW). - 3. If mode is "jpeg": return jpg_path (visual/original). - 4. If mode is "raw": - - Check for valid developed TIFF. If yes, return it. - - If no TIFF, return the RAW path itself (RawTherapee will need to develop it, - or we load it if we support direct RAW - here we likely return raw_path so - load_image_for_editing can decide to develop it). - """ - if index < 0 or index >= len(self.image_files): - raise IndexError("Invalid image index") - - img = self.image_files[index] - - # Check if we are strictly RAW-only (orphaned RAW or just RAW opened) - # ImageFile.path is the main file. ImageFile.raw_pair is the sidecar RAW. - # If raw_pair is None but path is a RAW extension, it's RAW-only. - is_raw_only = False - from faststack.io.indexer import RAW_EXTENSIONS - if img.raw_pair is None and img.path.suffix.lower() in RAW_EXTENSIONS: - is_raw_only = True - - mode = self.current_edit_source_mode - if is_raw_only: - mode = "raw" - - if mode == "jpeg": - return img.path - - # Mode is RAW - if img.has_working_tif and self.is_valid_working_tif(img.working_tif_path): - return img.working_tif_path - - if img.raw_pair: - return img.raw_pair - - # Fallback for RAW-only case where path is the RAW - return img.path - - @Slot(str) - def apply_filter(self, filter_string: str): - filter_string = filter_string.strip() - - if not filter_string: - self.clear_filter() - return - - self._filter_string = filter_string - self._filter_enabled = True - self._apply_filter_to_cached_list() # Fast in-memory filtering - self.dataChanged.emit() - self.ui_state.filterStringChanged.emit() # Notify UI of filter change - - # reset to start of filtered list - self.current_index = 0 - self.sync_ui_state() - self._do_prefetch(self.current_index) - - @Slot(result=str) - def get_filter_string(self): - # return current string, or "" if filter off - return self._filter_string - - @Slot() - def clear_filter(self): - if not self._filter_enabled and not self._filter_string: - return - self._filter_enabled = False - self._filter_string = "" - self._apply_filter_to_cached_list() # Fast in-memory filtering - self.dataChanged.emit() - self.ui_state.filterStringChanged.emit() # Notify UI of filter change - self.current_index = min(self.current_index, max(0, len(self.image_files) - 1)) - self.sync_ui_state() - self._do_prefetch(self.current_index) - - - - def get_display_info(self): - if self.is_zoomed: - return 0, 0, self.display_generation - - return self.display_width, self.display_height, self.display_generation - - def on_display_size_changed(self, width: int, height: int): - """Debounces display size change events to prevent spamming resizes.""" - log.debug(f"on_display_size_changed called with {width}x{height}. Current: {self.display_width}x{self.display_height}") - if width <= 0 or height <= 0: - log.debug("Ignoring invalid resize event") - return - - # Debounce resize events - self.pending_width = width - self.pending_height = height - self.resize_timer.start(150) # 150ms debounce - - def _handle_resize(self): - """Actual resize handler, called after debounce period.""" - log.info("Display size changed to: %dx%d (physical pixels)", self.pending_width, self.pending_height) - self.display_width = self.pending_width - self.display_height = self.pending_height - self.display_generation += 1 # Invalidates old entries via cache key - - # Mark display as ready after first size report - is_first_resize = not self.display_ready - if is_first_resize: - self.display_ready = True - log.info("Display size now stable, enabling prefetch") - - self.prefetcher.cancel_all() # Cancel stale tasks to avoid wasted work - - # On first resize, execute deferred prefetch; on subsequent resizes, do normal prefetch - if is_first_resize and self.pending_prefetch_index is not None: - self.prefetcher.update_prefetch(self.pending_prefetch_index) - self.pending_prefetch_index = None - else: - self.prefetcher.update_prefetch(self.current_index) - - self.sync_ui_state() # To refresh the image - - @Slot(bool) - def set_zoomed(self, zoomed: bool): - if self.is_zoomed != zoomed: - if _debug_mode: - log.info(f"AppController.set_zoomed: {self.is_zoomed} -> {zoomed}") - self.is_zoomed = zoomed - self.is_zoomed_changed.emit(zoomed) - log.info("Zoom state changed to: %s", zoomed) - self.display_generation += 1 # Invalidates old entries via cache key - - # Invalidate current image to force reload with new resolution logic - if self.image_files and self.main_window: - # Force QML to reload the image by updating the URL generation - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.main_window.update() # Force repaint - - # -- Zoom Shortcuts -- - def zoom_100(self): - log.info("Zoom 100% requested") - self.ui_state.request_absolute_zoom(1.0) - # self.set_zoomed(True) - Handled by QML smart zoom logic - - def zoom_200(self): - log.info("Zoom 200% requested") - self.ui_state.request_absolute_zoom(2.0) - # self.set_zoomed(True) - Handled by QML smart zoom logic - - def zoom_300(self): - log.info("Zoom 300% requested") - self.ui_state.request_absolute_zoom(3.0) - # self.set_zoomed(True) - Handled by QML smart zoom logic - - def zoom_400(self): - log.info("Zoom 400% requested") - self.ui_state.request_absolute_zoom(4.0) - # self.set_zoomed(True) - Handled by QML smart zoom logic - # NOTE: We don't clear the cache here. The generation increment is enough. - # Cache keys include display_generation, so zoomed/unzoomed images become - # naturally unreachable and LRU will evict them. This lets us instantly - # reuse cached images if user toggles zoom on/off repeatedly. - self.prefetcher.cancel_all() # Cancel stale tasks to avoid wasted work - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - self.ui_state.isZoomedChanged.emit() - - def eventFilter(self, watched, event) -> bool: - # Don't handle key events when a dialog is open - if self._dialog_open: - return False - - if watched == self.main_window and event.type() == QEvent.Type.KeyPress: - # QML handles Crop Enter/Esc keys now. - # We defer to QML to avoid double-triggering or focus conflicts. - # handled = self.keybinder.handle_key_press(event) ... - - # When cropping (or editing), let QML handle Enter/Esc and related keys. - # Otherwise keybinder can swallow them before QML sees them. - if getattr(self.ui_state, "isCropping", False) or getattr(self.ui_state, "isEditorOpen", False): - return False - - handled = self.keybinder.handle_key_press(event) - if handled: - return True - return super().eventFilter(watched, event) - - def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optional[int] = None): - """Helper to defer prefetch until display size is stable. - - Args: - index: The index to prefetch around - is_navigation: True if called from user navigation (arrow keys, etc.) - direction: 1 for forward, -1 for backward, None to use last direction - """ - # If navigation occurs during resize debounce, cancel timer and apply resize immediately - # to ensure prefetch uses correct dimensions - if is_navigation and self.resize_timer.isActive(): - self.resize_timer.stop() - self._handle_resize() - - if not self.display_ready: - log.debug("Display not ready, deferring prefetch for index %d", index) - self.pending_prefetch_index = index - return - self.prefetcher.update_prefetch(index, is_navigation=is_navigation, direction=direction) - - def load(self): - """Loads images, sidecar data, and starts services.""" - self.refresh_image_list() # Initial scan from disk - if not self.image_files: - self.current_index = 0 - else: - self.current_index = max(0, min(self.sidecar.data.last_index, len(self.image_files) - 1)) - self.stacks = self.sidecar.data.stacks # Load stacks from sidecar - self.dataChanged.emit() # Emit after stacks are loaded - self.watcher.start() - self._do_prefetch(self.current_index) - - # Defer initial UI sync until after images are loaded - self.sync_ui_state() - - - def refresh_image_list(self): - """Rescans the directory for images from disk and updates cache. - - This does a full disk scan and should only be called when: - - Application starts (load()) - - Directory watcher detects file changes - - User explicitly refreshes - - For filtering, use _apply_filter_to_cached_list() instead. - """ - self._all_images = find_images(self.image_dir) - self._apply_filter_to_cached_list() - - def _apply_filter_to_cached_list(self): - """Applies current filter to cached image list without disk I/O.""" - if self._filter_enabled and self._filter_string: - needle = self._filter_string.lower() - self.image_files = [ - img for img in self._all_images - if needle in img.path.stem.lower() - ] - else: - self.image_files = self._all_images - - self.prefetcher.set_image_files(self.image_files) - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.ui_state.imageCountChanged.emit() - - def get_decoded_image(self, index: int) -> Optional[DecodedImage]: - """Retrieves a decoded image, blocking until ready to ensure correct display. - - This blocks the UI thread on cache miss, but that's acceptable for an image viewer - where users expect to see the correct image immediately. The prefetcher minimizes - cache misses by decoding adjacent images in advance. - """ - if not self.image_files or index < 0 or index >= len(self.image_files): - log.warning("get_decoded_image called with empty image_files or out of bounds index.") - return None - - # Debug preview condition - if self.ui_state.isEditorOpen or self.ui_state.isCropping: - # Robust path comparison - editor_path = self.image_editor.current_filepath - file_path = self.image_files[index].path - - match = False - if editor_path and file_path: - try: - match = Path(editor_path).resolve() == Path(file_path).resolve() - except (OSError, ValueError): - match = str(editor_path) == str(file_path) - - if not match: - # Debug log if mismatch - log.debug("Path mismatch in preview. Editor: %s, File: %s", editor_path, file_path) - - # Return background-rendered preview if Editor is open OR Cropping is active - if match and self.image_editor.original_image: - if self._last_rendered_preview: - return self._last_rendered_preview - - _, _, display_gen = self.get_display_info() - image_path = self.image_files[index].path - path_str = image_path.as_posix() - cache_key = build_cache_key(image_path, display_gen) - - # Check cache - if cache_key in self.image_cache: - self.image_cache.hits += 1 # Increment hit counter - self._update_cache_stats() # Update UI with new stats - decoded = self.image_cache[cache_key] - with self._last_image_lock: - self.last_displayed_image = decoded - return decoded - - self.image_cache.misses += 1 # Increment miss counter - self._update_cache_stats() # Update UI with new stats - if self.debug_cache: - prefix = f"{path_str}::" - cached_gens = [ - key.split("::", 1)[1] - for key in self.image_cache.keys() - if key.startswith(prefix) - ] - cache_usage_gb = self.image_cache.currsize / (1024**3) - log.info( - "Cache miss for %s (index=%d gen=%d). Cached gens: %s. Cache usage=%.2fGB entries=%d", - image_path.name, - index, - display_gen, - cached_gens or "none", - cache_usage_gb, - len(self.image_cache), - ) - - # Cache miss: need to decode synchronously to ensure correct image displays - if _debug_mode: - decode_start = time.perf_counter() - log.info("Cache miss for index %d (gen: %d). Blocking decode.", index, display_gen) - - # Show decoding indicator if debug cache is enabled - if self.debug_cache: - self.ui_state.isDecoding = True - # Note: processEvents() caused crashes, so the indicator might not update immediately - # QCoreApplication.processEvents() - - try: - # Submit with priority=True to cancel pending prefetch tasks and free up workers - future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) - if not future: - with self._last_image_lock: - return self.last_displayed_image - - try: - # Wait for decode to complete (blocking but fast for JPEGs) - result = future.result(timeout=5.0) # 5 second timeout as safety - except concurrent.futures.TimeoutError: - log.warning("Timeout decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except concurrent.futures.CancelledError: - log.debug("Decode cancelled for index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except Exception: - log.exception("Error decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image - - if not result: - if _debug_mode: - log.debug("Decode returned no result for index %d", index) - with self._last_image_lock: - return self.last_displayed_image - - decoded_path, decoded_display_gen = result - cache_key = build_cache_key(decoded_path, decoded_display_gen) - if cache_key in self.image_cache: - decoded = self.image_cache[cache_key] - with self._last_image_lock: - self.last_displayed_image = decoded - if _debug_mode: - elapsed = time.perf_counter() - decode_start - log.info("Decoded image %d in %.3fs", index, elapsed) - return decoded - else: - if _debug_mode: - log.debug("Decode finished but cache_key missing (index=%d, key=%s)", index, cache_key) - with self._last_image_lock: - return self.last_displayed_image - finally: - # Hide decoding indicator - if self.debug_cache: - self.ui_state.isDecoding = False - - with self._last_image_lock: - return self.last_displayed_image - - def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: - """Thread-safe version of get_decoded_image for background workers. - - Does NOT update UI iteration or access QObjects. - """ - if not self.image_files or index < 0 or index >= len(self.image_files): - return None - - # Lock to ensure thread safety when reading shared state if necessary (though simple reads are usually safe) - # However, get_display_info reads 'self.is_zoomed' which is fine. - # Accessing self.image_files is safe as long as list isn't cleared concurrently, - # which only happens on directory change/refresh on main thread. - # Since we are in a worker, there's a small race risk if directory changes *while* we run, - # but the worker would likely just fail gracefully or get an old image. - - _, _, display_gen = self.get_display_info() - try: - image_path = self.image_files[index].path - except IndexError: - return None - - cache_key = build_cache_key(image_path, display_gen) - - # Check cache (thread-safe read) - if cache_key in self.image_cache: - # We don't update stats/hits here to avoid race conditions on those counters - return self.image_cache[cache_key] - - # Cache miss: decode synchronously (in this worker thread) - try: - # Submit with priority=True - # Note: prefetcher.submit_task logic needs to be thread-safe. - # Assuming futures dict access in submit_task handles strict GIL/thread safety or we might need locks there. - # But usually submitting to Executor is thread safe. - # The danger is 'self.futures' management in Prefetcher. - future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) - if future: - try: - result = future.result(timeout=5.0) - except concurrent.futures.TimeoutError: - log.warning(f"Timeout decoding image at index {index} (background)") - return None - except concurrent.futures.CancelledError: - log.debug(f"Decode cancelled for image at index {index} (background)") - return None - - if result: - decoded_path, decoded_display_gen = result - # Re-verify key - cache_key = build_cache_key(decoded_path, decoded_display_gen) - if cache_key in self.image_cache: - return self.image_cache[cache_key] - except Exception: - log.exception("_get_decoded_image_safe failed for index %d", index) - - return None - - def sync_ui_state(self): - """Forces the UI to update by emitting all state change signals.""" - self.ui_refresh_generation += 1 - self._metadata_cache_index = (-1, -1) # Invalidate cache - - # tell QML that index and image changed - self.ui_state.currentIndexChanged.emit() - self.ui_state.currentImageSourceChanged.emit() - self.ui_state.highlightStateChanged.emit() # Notify UI of new highlight stats - - # this is the one your footer needs - self.ui_state.metadataChanged.emit() - - log.debug( - "UI State Synced: Index=%d, Count=%d", - self.ui_state.currentIndex, - self.ui_state.imageCount - ) - log.debug( - "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", - self.ui_state.currentFilename, - self.ui_state.isUploaded, - self.ui_state.stackInfoText, - self.ui_state.batchInfoText - ) - - - - # --- Image Editor Integration --- - - - - - @Slot() - def save_edited_image(self): - """Saves functionality delegating to ImageEditor. - - Restores "Old" behavior: - - Save image - - Close Editor - - Clear Editor State - - Refresh List - - Re-select saved image - """ - if not self.image_editor.original_image: - return - - # Only write developed sidecar when editing from RAW source - write_sidecar = self.current_edit_source_mode == "raw" - dev_path = None - if write_sidecar and 0 <= self.current_index < len(self.image_files): - dev_path = self.image_files[self.current_index].developed_jpg_path - - try: - result = self.image_editor.save_image(write_developed_jpg=write_sidecar, developed_path=dev_path) - except RuntimeError as e: - self.update_status_message(str(e)) - return - except Exception as e: - log.exception(f"Unexpected error during save: {e}") - self.update_status_message("Failed to save image") - return - - if result: - saved_path, backup_path = result - - # --- Restore Old Behavior --- - - # 1. Close Editor UI - self.ui_state.isEditorOpen = False - - # 2. Clear Editor State (release memory) - self.image_editor.clear() - - # 3. Refresh List (to see new file or updated timestamp) - self.refresh_image_list() - - # 4. Find and Select the saved image - new_index = self.current_index # Default to keeping selection if not found - - # Try to find by exact path match - found_index = -1 - if saved_path: - try: - target_resolve = saved_path.resolve() - for i, img in enumerate(self.image_files): - try: - # Robust path comparison - if img.path.resolve() == target_resolve: - new_index = i - found_index = i - break - except (OSError, RuntimeError): - # Fallback to string compare - if str(img.path) == str(saved_path): - new_index = i - found_index = i - break - except (OSError, RuntimeError): - pass # Keep current selection if resolution fails - - self.current_index = new_index - - # 5. Force UI Sync / Prefetch - self.image_cache.clear() # Clear cache to ensure we reload valid image - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - self.update_status_message(f"Image saved") - else: - self.update_status_message("Failed to save image") - - - - - - # --- Actions --- - - def _set_current_index(self, index: int, direction: int = 0, is_navigation: bool = True): - """Centralized method to change current image index and reset state.""" - if index < 0 or index >= len(self.image_files): - return - - # Reset source mode to JPEG unless new image is strictly RAW-only - # (This implements the "Default state on navigation" requirement) - img = self.image_files[index] - is_raw_only = False - from faststack.io.indexer import RAW_EXTENSIONS, JPG_EXTENSIONS - # Robust RAW-only check: Main path is RAW and it's not a JPEG - is_jpeg_main = img.path.suffix.lower() in JPG_EXTENSIONS - is_raw_main = img.path.suffix.lower() in RAW_EXTENSIONS - is_raw_only = is_raw_main and not is_jpeg_main - - new_mode = "raw" if is_raw_only else "jpeg" - if self.current_edit_source_mode != new_mode: - self.current_edit_source_mode = new_mode - self.editSourceModeChanged.emit(new_mode) - - self.current_index = index # Set index first so signals pick up correct image - - self._reset_crop_settings() - self._do_prefetch(self.current_index, is_navigation=is_navigation, direction=direction) - self.sync_ui_state() - - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - - def next_image(self): - if self.current_index < len(self.image_files) - 1: - self._set_current_index(self.current_index + 1, direction=1) - - def prev_image(self): - if self.current_index > 0: - self._set_current_index(self.current_index - 1, direction=-1) - - @Slot(int) - def jump_to_image(self, index: int): - """Jump to a specific image by index (0-based).""" - if 0 <= index < len(self.image_files): - if index == self.current_index: - self.update_status_message(f"Already at image {index + 1}") - return - direction = 1 if index > self.current_index else -1 - self._set_current_index(index, direction=direction) - self.update_status_message(f"Jumped to image {index + 1}") - else: - log.warning("Invalid image index: %d", index) - self.update_status_message("Invalid image number") - - def show_jump_to_image_dialog(self): - """Shows the jump to image dialog (called from keybinder).""" - if self.main_window and hasattr(self.main_window, 'show_jump_to_image_dialog'): - self.main_window.show_jump_to_image_dialog() - else: - log.warning("Cannot open jump to image dialog: main_window or function not available") - - def show_exif_dialog(self): - """Shows the EXIF data dialog.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - path = self.image_files[self.current_index].path - data = get_exif_data(path) - - if self.main_window and hasattr(self.main_window, 'openExifDialog'): - # Pass data as QVariantMap (dict) - self.main_window.openExifDialog(data) - else: - log.warning("Cannot open EXIF dialog: main_window or openExifDialog not available") - - @Slot() - def dialog_opened(self): - """Called when any dialog opens to disable global keybindings.""" - self._dialog_open_count += 1 - if self._dialog_open_count == 1: - self._dialog_open = True - self.dialogStateChanged.emit(True) - log.debug("Dialog opened (count=1), disabling global keybindings") - - @Slot() - def dialog_closed(self): - """Called when any dialog closes to re-enable global keybindings.""" - prev = self._dialog_open_count - self._dialog_open_count = max(0, self._dialog_open_count - 1) - if prev > 0 and self._dialog_open_count == 0: - self._dialog_open = False - self.dialogStateChanged.emit(False) - log.debug("Dialog closed (count=0), re-enabling global keybindings") - - def toggle_grid_view(self): - log.warning("Grid view not implemented yet.") - - def toggle_uploaded(self): - """Toggle uploaded flag for current image.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - - meta.uploaded = not meta.uploaded - if meta.uploaded: - meta.uploaded_date = today - else: - meta.uploaded_date = None - - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - status = "uploaded" if meta.uploaded else "not uploaded" - self.update_status_message(f"Marked as {status}") - log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) - - def toggle_edited(self): - """Toggle edited flag for current image.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - - meta.edited = not meta.edited - if meta.edited: - meta.edited_date = today - else: - meta.edited_date = None - - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - status = "edited" if meta.edited else "not edited" - self.update_status_message(f"Marked as {status}") - log.info("Toggled edited flag to %s for %s", meta.edited, stem) - - def toggle_restacked(self): - """Toggle restacked flag for current image.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - - meta.restacked = not meta.restacked - if meta.restacked: - meta.restacked_date = today - else: - meta.restacked_date = None - - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - status = "restacked" if meta.restacked else "not restacked" - self.update_status_message(f"Marked as {status}") - log.info("Toggled restacked flag to %s for %s", meta.restacked, stem) - - def toggle_stacked(self): - """Toggle stacked flag for current image.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - - meta.stacked = not meta.stacked - if meta.stacked: - meta.stacked_date = today - else: - meta.stacked_date = None - - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - status = "stacked" if meta.stacked else "not stacked" - self.update_status_message(f"Marked as {status}") - log.info("Toggled stacked flag to %s for %s", meta.stacked, stem) - - def get_current_metadata(self) -> Dict: - if not self.image_files or self.current_index >= len(self.image_files): - if not self._logged_empty_metadata: - log.debug("get_current_metadata: image_files is empty or index out of bounds, returning {}.") - self._logged_empty_metadata = True - return {} - self._logged_empty_metadata = False - - # Cache hit check - cache_key = (self.current_index, self.ui_refresh_generation) - if cache_key == self._metadata_cache_index: - return self._metadata_cache - - # Compute and cache - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - stack_info = self._get_stack_info(self.current_index) - batch_info = self._get_batch_info(self.current_index) - - self._metadata_cache = { - "filename": self.image_files[self.current_index].path.name, - "stacked": meta.stacked, - "stacked_date": meta.stacked_date or "", - "uploaded": meta.uploaded, - "uploaded_date": meta.uploaded_date or "", - "edited": meta.edited, - "edited_date": meta.edited_date or "", - "restacked": meta.restacked, - "restacked_date": meta.restacked_date or "", - "stack_info_text": stack_info, - "batch_info_text": batch_info - } - self._metadata_cache_index = cache_key - return self._metadata_cache - - def begin_new_stack(self): - self.stack_start_index = self.current_index - log.info("Stack start marked at index %d", self.stack_start_index) - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() # Update UI to show start marker - self.sync_ui_state() - - def end_current_stack(self): - log.info("end_current_stack called. stack_start_index: %s", self.stack_start_index) - if self.stack_start_index is not None: - start = min(self.stack_start_index, self.current_index) - end = max(self.stack_start_index, self.current_index) - self.stacks.append([start, end]) - self.stacks.sort() # Keep stacks sorted by start index - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - log.info("Defined new stack: [%d, %d]", start, end) - self.stack_start_index = None - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() # Notify QML of data change - self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog - self.sync_ui_state() - else: - log.warning("No stack start marked. Press '[' first.") - - def begin_new_batch(self): - """Mark the start of a new batch for drag-and-drop.""" - self.batch_start_index = self.current_index - log.info("Batch start marked at index %d", self.batch_start_index) - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() - self.sync_ui_state() - self.update_status_message("Batch start marked") - - def end_current_batch(self): - """End the current batch and save the range.""" - log.info("end_current_batch called. batch_start_index: %s", self.batch_start_index) - if self.batch_start_index is not None: - start = min(self.batch_start_index, self.current_index) - end = max(self.batch_start_index, self.current_index) - self.batches.append([start, end]) - self.batches.sort() # Keep batches sorted by start index - log.info("Defined new batch: [%d, %d]", start, end) - self.batch_start_index = None - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() - self.sync_ui_state() - count = end - start + 1 - self.update_status_message(f"Batch defined: {count} images") - else: - log.warning("No batch start marked. Press '{' first.") - self.update_status_message("No batch start marked") - - - def remove_from_batch_or_stack(self): - """Remove current image from any batch or stack it's in.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - removed = False - - # Check and remove from batches - new_batches = [] - batch_modified = False - for start, end in self.batches: - if not batch_modified and start <= self.current_index <= end: - # This is the batch to modify. - - # Single image batch - remove entirely by not adding anything. - if start == end: - pass - # Remove from beginning - shift start forward - elif self.current_index == start: - new_batches.append([start + 1, end]) - # Remove from end - shift end backward - elif self.current_index == end: - new_batches.append([start, end - 1]) - # Remove from middle - split into two ranges - else: - new_batches.append([start, self.current_index - 1]) - new_batches.append([self.current_index + 1, end]) - - log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) - self.update_status_message(f"Removed from batch") - removed = True - batch_modified = True - else: - new_batches.append([start, end]) - - if batch_modified: - self.batches = new_batches - - # Check and remove from stacks - # Check and remove from stacks - if not removed: - new_stacks = [] - stack_modified = False - for start, end in self.stacks: - if not stack_modified and start <= self.current_index <= end: - # This is the stack to modify. - - # Single image stack - remove entirely. - if start == end: - pass - # Remove from beginning - elif self.current_index == start: - new_stacks.append([start + 1, end]) - # Remove from end - elif self.current_index == end: - new_stacks.append([start, end - 1]) - # Remove from middle - else: - new_stacks.append([start, self.current_index - 1]) - new_stacks.append([self.current_index + 1, end]) - - log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) - self.update_status_message(f"Removed from stack") - removed = True - stack_modified = True - else: - new_stacks.append([start, end]) - - if stack_modified: - self.stacks = new_stacks - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - if removed: - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.ui_state.stackSummaryChanged.emit() - self.sync_ui_state() - else: - self.update_status_message("Not in any batch or stack") - - def toggle_batch_membership(self): - """Toggles the current image's inclusion in a batch.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - index_to_toggle = self.current_index - - # Check if the image is already in a batch - in_batch = False - for start, end in self.batches: - if start <= index_to_toggle <= end: - in_batch = True - break - - new_batches = [] - if in_batch: - # Remove from batch - item_removed = False - for start, end in self.batches: - if not item_removed and start <= index_to_toggle <= end: - if start < index_to_toggle: - new_batches.append([start, index_to_toggle - 1]) - if index_to_toggle < end: - new_batches.append([index_to_toggle + 1, end]) - item_removed = True - else: - new_batches.append([start, end]) - self.batches = new_batches - self.update_status_message("Removed image from batch") - log.info("Removed index %d from a batch.", index_to_toggle) - else: - # Add to batch - merge with adjacent batches if possible - if not self.batches: - self.batches.append([index_to_toggle, index_to_toggle]) - self.update_status_message("Created new batch with current image.") - log.info("No existing batches. Created new batch for index %d.", index_to_toggle) - else: - # Check if adjacent to any existing batch - merged = False - for i, (start, end) in enumerate(self.batches): - # Adjacent to start of batch - if index_to_toggle == start - 1: - self.batches[i] = [index_to_toggle, end] - merged = True - break - # Adjacent to end of batch - elif index_to_toggle == end + 1: - self.batches[i] = [start, index_to_toggle] - merged = True - break - - if not merged: - # Not adjacent to any batch, create new one - self.batches.append([index_to_toggle, index_to_toggle]) - - # Sort and merge any overlapping batches - self.batches.sort() - merged_batches = [self.batches[0]] if self.batches else [] - for i in range(1, len(self.batches)): - last_start, last_end = merged_batches[-1] - current_start, current_end = self.batches[i] - if current_start <= last_end + 1: - merged_batches[-1] = [last_start, max(last_end, current_end)] - else: - merged_batches.append([current_start, current_end]) - self.batches = merged_batches - - self.update_status_message("Added image to batch") - log.info("Added index %d to batch.", index_to_toggle) - - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - - def toggle_stack_membership(self): - """Toggles the current image's inclusion in a stack.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - index_to_toggle = self.current_index - - # Check if the image is already in a stack - stack_to_modify_idx = -1 - for i, (start, end) in enumerate(self.stacks): - if start <= index_to_toggle <= end: - stack_to_modify_idx = i - break - - if stack_to_modify_idx != -1: - # --- Remove from existing stack --- - new_stacks = [] - item_removed = False - for i, (start, end) in enumerate(self.stacks): - if not item_removed and i == stack_to_modify_idx: - if start < index_to_toggle: - new_stacks.append([start, index_to_toggle - 1]) - if index_to_toggle < end: - new_stacks.append([index_to_toggle + 1, end]) - item_removed = True - else: - new_stacks.append([start, end]) - self.stacks = new_stacks - self.update_status_message("Removed image from stack") - log.info("Removed index %d from stack #%d.", index_to_toggle, stack_to_modify_idx + 1) - - else: - # --- Add to nearest stack --- - if not self.stacks: - self.stacks.append([index_to_toggle, index_to_toggle]) - self.update_status_message("Created new stack with current image.") - log.info("No existing stacks. Created new stack for index %d.", index_to_toggle) - else: - # Find closest stack - dist_backward = float('inf') - stack_idx_backward = -1 - for i in range(index_to_toggle - 1, -1, -1): - for j, (start, end) in enumerate(self.stacks): - if start <= i <= end: - dist_backward = index_to_toggle - i - stack_idx_backward = j - break - if stack_idx_backward != -1: - break - - dist_forward = float('inf') - stack_idx_forward = -1 - for i in range(index_to_toggle + 1, len(self.image_files)): - for j, (start, end) in enumerate(self.stacks): - if start <= i <= end: - dist_forward = i - index_to_toggle - stack_idx_forward = j - break - if stack_idx_forward != -1: - break - - if stack_idx_backward == -1 and stack_idx_forward == -1: - # This case should not be reached if `if not self.stacks` handles it. - self.stacks.append([index_to_toggle, index_to_toggle]) - self.update_status_message("Created new stack with current image.") - log.info("No stacks found nearby. Created new stack for index %d.", index_to_toggle) - else: - if dist_backward <= dist_forward: - stack_to_join_idx = stack_idx_backward - else: - stack_to_join_idx = stack_idx_forward - - start, end = self.stacks[stack_to_join_idx] - self.stacks[stack_to_join_idx] = [min(start, index_to_toggle), max(end, index_to_toggle)] - - # Merge overlapping stacks - self.stacks.sort() - merged_stacks = [self.stacks[0]] if self.stacks else [] - for i in range(1, len(self.stacks)): - last_start, last_end = merged_stacks[-1] - current_start, current_end = self.stacks[i] - if current_start <= last_end + 1: - merged_stacks[-1] = [last_start, max(last_end, current_end)] - else: - merged_stacks.append([current_start, current_end]) - self.stacks = merged_stacks - - # Find the new stack index for the status message - new_stack_idx = -1 - for i, (start, end) in enumerate(self.stacks): - if start <= index_to_toggle <= end: - new_stack_idx = i - break - - self.update_status_message(f"Added image to Stack #{new_stack_idx + 1}") - log.info("Added index %d to stack #%d.", index_to_toggle, new_stack_idx + 1) - - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.ui_state.stackSummaryChanged.emit() - self.sync_ui_state() - - def _reset_crop_settings(self): - """Resets crop settings to default (full image) and exits crop mode, and resets rotation.""" - if self.ui_state.isCropping: - self.ui_state.isCropping = False - self.update_status_message("Crop mode exited") - self.ui_state.currentCropBox = (0, 0, 1000, 1000) - # Also clear any editor-side crop box in case it's not fully synced yet - self.image_editor.set_crop_box((0, 0, 1000, 1000)) - # Reset rotation and straighten angle - self.image_editor.set_edit_param('rotation', 0) - self.image_editor.set_edit_param('straighten_angle', 0.0) - # Also update UI state for rotation values if they are exposed - if hasattr(self.ui_state, 'rotation'): - self.ui_state.rotation = 0 - if hasattr(self.ui_state, 'cropRotation'): # This is used by Components.qml for the overlay - self.ui_state.cropRotation = 0.0 - - # Also reset the straighten angle in current_edits since it affects rotation logic - if 'straighten_angle' in self.image_editor.current_edits: - self.image_editor.current_edits['straighten_angle'] = 0.0 - - def launch_helicon(self): - """Launches Helicon with selected files (RAW preferred, JPG fallback) or stacks.""" - if self.stacks: - log.info("Launching Helicon for %d defined stacks.", len(self.stacks)) - any_success = False - for start, end in self.stacks: - files_to_process = [] - for idx in range(start, end + 1): - if idx < len(self.image_files): - img_file = self.image_files[idx] - # Use RAW if available, otherwise use JPG - file_to_use = img_file.raw_pair if img_file.raw_pair else img_file.path - files_to_process.append(file_to_use) - - if files_to_process: - success = self._launch_helicon_with_files(files_to_process) - if success: - any_success = True - else: - log.warning("No valid files found for stack [%d, %d].", start, end) - - # Only clear stacks if at least one launch succeeded - if any_success: - self.clear_all_stacks() - - else: - log.warning("No selection or stacks defined to launch Helicon Focus.") - return - - self.sync_ui_state() - - def _launch_helicon_with_files(self, files: List[Path]) -> bool: - """Helper to launch Helicon with a specific list of files (RAW or JPG). - - Returns: - True if Helicon was successfully launched, False otherwise. - """ - log.info("Launching Helicon Focus with %d files.", len(files)) - unique_files = sorted(list(set(files))) - success, tmp_path = launch_helicon_focus(unique_files) - if success and tmp_path: - # Schedule delayed deletion of the temporary file - QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) - - # Record stacking metadata - today = date.today().isoformat() - for file_path in unique_files: - # Find the corresponding image file to get the stem - for img_file in self.image_files: - # Match by either RAW pair or JPG path - if img_file.raw_pair == file_path or img_file.path == file_path: - stem = img_file.path.stem - meta = self.sidecar.get_metadata(stem) - meta.stacked = True - meta.stacked_date = today - break - self.sidecar.save() - self._metadata_cache_index = (-1, -1) # Invalidate cache - - return success - - def _delete_temp_file(self, tmp_path: Path): - """Deletes the temporary file list passed to Helicon Focus.""" - if tmp_path.exists(): - try: - os.remove(tmp_path) - log.info("Deleted temporary file: %s", tmp_path) - except OSError as e: - log.error("Error deleting temporary file %s: %s", tmp_path, e) - - def clear_all_stacks(self): - log.info("Clearing all defined stacks.") - self.stacks = [] - self.stack_start_index = None - # Do NOT clear batches here - - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.ui_state.stackSummaryChanged.emit() - self.sync_ui_state() - self.update_status_message("All stacks cleared") - - def clear_all_batches(self): - """Clear all defined batches.""" - log.info("Clearing all defined batches.") - self.batches = [] - self.batch_start_index = None - - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - self.update_status_message("All batches cleared") - - def get_helicon_path(self): - return config.get('helicon', 'exe') - - def set_helicon_path(self, path): - config.set('helicon', 'exe', path) - config.save() - - def get_photoshop_path(self): - return config.get('photoshop', 'exe') - - def set_photoshop_path(self, path): - config.set('photoshop', 'exe', path) - config.save() - - def get_rawtherapee_path(self): - return config.get('rawtherapee', 'exe') - - def set_rawtherapee_path(self, path): - config.set('rawtherapee', 'exe', path) - config.save() - - def open_file_dialog(self): - dialog = QFileDialog() - dialog.setFileMode(QFileDialog.FileMode.ExistingFile) - dialog.setNameFilter("Executables (*.exe)") - if dialog.exec(): - return dialog.selectedFiles()[0] - return "" - - def check_path_exists(self, path): - return os.path.exists(path) - - def get_cache_size(self): - return config.getfloat('core', 'cache_size_gb') - - def get_cache_usage_gb(self): - """Returns current cache usage in GB.""" - return self.image_cache.currsize / (1024**3) - - def set_cache_size(self, size): - """Update cache size at runtime and persist to config.""" - size = max(0.5, min(size, 16.0)) # enforce sane bounds - config.set('core', 'cache_size_gb', size) - config.save() - - old_max_bytes = self.image_cache.max_bytes - new_max_bytes = int(size * 1024**3) - if old_max_bytes == new_max_bytes: - return - - log.info("Resizing decoded image cache from %.2f GB to %.2f GB", - old_max_bytes / (1024**3), size) - self.image_cache.max_bytes = new_max_bytes - - # If the new size is smaller than current usage, evict until under limit - while self.image_cache.currsize > new_max_bytes and len(self.image_cache) > 0: - try: - self.image_cache.popitem() - except KeyError: - break - - # Allow future warnings after expanding the cache - if new_max_bytes > old_max_bytes: - self._has_warned_cache_full = False - - def get_prefetch_radius(self): - return config.getint('core', 'prefetch_radius') - - def set_prefetch_radius(self, radius): - config.set('core', 'prefetch_radius', radius) - config.save() - self.prefetcher.prefetch_radius = radius - self.prefetcher.update_prefetch(self.current_index) - - def get_theme(self): - return 0 if config.get('core', 'theme') == 'dark' else 1 - - def set_theme(self, theme_index): - # update Python-side state - self.ui_state.theme = theme_index - - # persist it - theme = 'dark' if theme_index == 0 else 'light' - config.set('core', 'theme', theme) - config.save() - - # tell QML it changed (once is enough) - self.ui_state.themeChanged.emit() - - @Slot(result=str) - def get_color_mode(self): - """Returns current color management mode: 'none', 'saturation', or 'icc'.""" - return config.get('color', 'mode', fallback='none') - - @Slot(str) - def set_color_mode(self, mode: str): - """Sets color management mode and clears cache to force re-decode.""" - mode = mode.lower() - if mode not in ['none', 'saturation', 'icc']: - log.error("Invalid color mode: %s", mode) - return - - log.info("Setting color mode to: %s", mode) - config.set('color', 'mode', mode) - config.save() - - # Clear ICC caches when color mode changes - clear_icc_caches() - - # Clear cache and restart prefetcher to apply new color mode - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Notify QML that color mode changed - self.ui_state.colorModeChanged.emit() - - # Update status message - mode_names = { - 'none': 'Original Colors', - 'saturation': 'Saturation Compensation', - 'icc': 'Full ICC Profile' - } - self.update_status_message(f"Color mode: {mode_names.get(mode, mode)}") - - @Slot(result=float) - def get_saturation_factor(self): - """Returns current saturation factor (0.0-1.0).""" - return config.getfloat('color', 'saturation_factor', fallback=0.85) - - @Slot(float) - def set_saturation_factor(self, factor: float): - """Sets saturation factor and refreshes images.""" - factor = max(0.0, min(1.0, factor)) # Clamp to 0-1 - log.info("Setting saturation factor to: %.2f", factor) - config.set('color', 'saturation_factor', str(factor)) - config.save() - - # Only refresh if in saturation mode - if self.get_color_mode() == 'saturation': - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Notify QML - self.ui_state.saturationFactorChanged.emit() - - @Slot(result=str) - def get_awb_mode(self): - return config.get("awb", "mode") - - @Slot(str) - def set_awb_mode(self, mode): - config.set("awb", "mode", mode) - config.save() - - @Slot(result=float) - def get_awb_strength(self): - return config.getfloat("awb", "strength") - - @Slot(float) - def set_awb_strength(self, value): - config.set("awb", "strength", value) - config.save() - - # Refresh if AWB was recently applied - if self.get_color_mode() in ['saturation', 'icc']: - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - @Slot(float) - @Slot(float, float) - def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): - """Sets the straighten angle for the image editor and updates current view.""" - if not (self.ui_state.isEditorOpen or self.ui_state.isCropping): - return - - # Optimization: Assume image is loaded by toggle_crop_mode or open_editor. - # Avoid disk I/O here to prevent stutter during drag. - if not self.image_editor.original_image: - return - - # log.info(f"AppController.set_straighten_angle: {angle}, AR: {target_aspect_ratio}") - - # Update Aspect Ratio Compensation for Crop Box - # If we have a target aspect ratio, we need to adjust the normalized crop box - # because the underlying canvas aspect ratio changes with rotation (expand=True). - if target_aspect_ratio > 0 and self.ui_state.currentCropBox: - left, top, right, bottom = self.ui_state.currentCropBox - w_norm = right - left - h_norm = bottom - top - - if w_norm > 0 and h_norm > 0: - # Calculate new canvas dimensions - # PIL expand=True logic: - im_w, im_h = self.image_editor.original_image.size - # math imported at top level - rad = math.radians(abs(angle)) - # New dimensions - new_w = abs(im_w * math.cos(rad)) + abs(im_h * math.sin(rad)) - new_h = abs(im_w * math.sin(rad)) + abs(im_h * math.cos(rad)) - - if new_w > 0 and new_h > 0: - canvas_aspect = new_w / new_h - - # We want PixelAspect = (w_norm * new_w/1000) / (h_norm * new_h/1000) = target_aspect - # (w_norm / h_norm) * (new_w / new_h) = target_aspect - # w_norm / h_norm = target_aspect / canvas_aspect - - target_norm_ratio = target_aspect_ratio / canvas_aspect - - # Adjust dimensions to match target_norm_ratio - # Simple: Preserve Width, adjust Height. - - new_h_norm = w_norm / target_norm_ratio - - # If new height exceeds bounds (1000), constrain and adjust width instead - if new_h_norm > 1000: - new_h_norm = 1000 - w_norm = new_h_norm * target_norm_ratio - # Recenter height - cy = (top + bottom) / 2 - top = cy - new_h_norm / 2 - bottom = cy + new_h_norm / 2 - - # Clamp vertical - if top < 0: - bottom -= top # shift down - top = 0 - if bottom > 1000: - top -= (bottom - 1000) # shift up - bottom = 1000 - if top < 0: - top = 0 # double clamp - - # Recenter width (if changed) - cx = (left + right) / 2 - left = cx - w_norm / 2 - right = cx + w_norm / 2 - - # Clamp horizontal - if left < 0: - right -= left - left = 0 - if right > 1000: - left -= (right - 1000) - right = 1000 - if left < 0: - left = 0 - - self.ui_state.currentCropBox = (left, top, right, bottom) - self.image_editor.set_crop_box((left, top, right, bottom)) - - log.debug(f"AppController.set_straighten_angle: {angle}") - # Pass the angle as-is (degrees CW). - # QML rotation is CW-positive. - # ImageEditor expects CW-positive and handles the inversion for PIL internally. - self.image_editor.set_edit_param("straighten_angle", angle) - - # Trigger refresh. Since we are editing, we are viewing the preview. - # Incrementing display generation invalidates cache, but for preview it just ensures freshness if logic depends on it. - # Crucially, sync_ui_state emits currentImageSourceChanged, forcing QML to reload. - # self.display_generation += 1 - # self.sync_ui_state() # DISABLE TO PREVENT FLASHING - QML handles preview live - - @Slot(result=int) - def get_awb_warm_bias(self): - return config.getint("awb", "warm_bias") - - @Slot(int) - def set_awb_warm_bias(self, value): - config.set("awb", "warm_bias", value) - config.save() - - @Slot(result=int) - def get_awb_tint_bias(self): - return config.getint("awb", "tint_bias", fallback=0) - - @Slot(int) - def set_awb_tint_bias(self, value): - config.set("awb", "tint_bias", value) - config.save() - - @Slot(result=int) - def get_awb_luma_lower_bound(self): - return config.getint("awb", "luma_lower_bound") - - @Slot(int) - def set_awb_luma_lower_bound(self, value): - config.set("awb", "luma_lower_bound", value) - config.save() - - @Slot(result=int) - def get_awb_luma_upper_bound(self): - return config.getint("awb", "luma_upper_bound") - - @Slot(int) - def set_awb_luma_upper_bound(self, value): - config.set("awb", "luma_upper_bound", value) - config.save() - - @Slot(result=int) - def get_awb_rgb_lower_bound(self): - return config.getint("awb", "rgb_lower_bound") - - @Slot(int) - def set_awb_rgb_lower_bound(self, value): - config.set("awb", "rgb_lower_bound", value) - config.save() - - @Slot(result=int) - def get_awb_rgb_upper_bound(self): - return config.getint("awb", "rgb_upper_bound") - - @Slot(int) - def set_awb_rgb_upper_bound(self, value): - config.set("awb", "rgb_upper_bound", value) - config.save() - - def get_default_directory(self): - return config.get('core', 'default_directory') - - def set_default_directory(self, path): - config.set('core', 'default_directory', path) - config.save() - - def get_optimize_for(self): - return config.get('core', 'optimize_for', fallback='speed') - - def set_optimize_for(self, optimize_for): - old_value = config.get('core', 'optimize_for', fallback='speed') - config.set('core', 'optimize_for', optimize_for) - config.save() - - # If the setting changed, clear cache and redraw current image - if old_value != optimize_for: - log.info(f"Optimize for changed from {old_value} to {optimize_for}, clearing cache and redrawing") - self.image_cache.clear() - # Force redraw of current image - if self.current_index >= 0 and self.current_index < len(self.image_files): - self.ui_state.currentImageSourceChanged.emit() - - @Slot(result=float) - def get_auto_level_clipping_threshold(self): - return self.auto_level_threshold - - @Slot(float) - def set_auto_level_clipping_threshold(self, value): - # Clamp to 0-1 range for safety - value = max(0.0, min(1.0, value)) - self.auto_level_threshold = value - # Store as formatted string to avoid scientific notation weirdness or precision issues - config.set('core', 'auto_level_threshold', f"{value:.6g}") - config.save() - - @Slot(result=float) - def get_auto_level_strength(self): - return self.auto_level_strength - - @Slot(float) - def set_auto_level_strength(self, value): - # Clamp to 0-1 range - value = max(0.0, min(1.0, value)) - self.auto_level_strength = value - config.set('core', 'auto_level_strength', f"{value:.6g}") - config.save() - - @Slot(result=bool) - def get_auto_level_strength_auto(self): - return self.auto_level_strength_auto - - @Slot(bool) - def set_auto_level_strength_auto(self, value): - self.auto_level_strength_auto = value - # Store as canonical lowercase string - config.set('core', 'auto_level_strength_auto', "true" if value else "false") - config.save() - - def open_directory_dialog(self): - dialog = QFileDialog() - dialog.setFileMode(QFileDialog.FileMode.Directory) - if dialog.exec(): - return dialog.selectedFiles()[0] - return "" - - @Slot() - def open_folder(self): - """Opens a directory dialog and reloads the application with the selected folder.""" - path = self.open_directory_dialog() - if path: - # Stop the old watcher - if self.watcher: - self.watcher.stop() - - # Update the directory path - self.image_dir = Path(path) - - # Reinitialize directory-bound components - self.watcher = Watcher(self.image_dir, self.refresh_image_list) - self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) - self.recycle_bin_dir = self.image_dir / "image recycle bin" - - # Clear directory-specific state - self.delete_history = [] - self.undo_history = [] - self.stacks = [] - self.batches = [] - self.batch_start_index = None - self.stack_start_index = None - - # Clear caches since they reference old directory's images - with self._last_image_lock: - self.last_displayed_image = None - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self._metadata_cache = {} - self._metadata_cache_index = (-1, -1) - # Clear last displayed image since it references the old directory - with self._last_image_lock: - self.last_displayed_image = None - # Clear editor state if open - self.image_editor.clear() - - # Load images from new directory - self.load() - - - def preload_all_images(self): - if self.ui_state.isPreloading: - log.info("Preloading is already in progress.") - return - - log.info("Starting to preload all images, skipping cached.") - self.ui_state.isPreloading = True - self.ui_state.preloadProgress = 0 - - self.reporter = self.ProgressReporter() - self.reporter.progress_updated.connect(self._update_preload_progress) - self.reporter.finished.connect(self._finish_preloading) - - total_images = len(self.image_files) - if total_images == 0: - log.info("No images to preload.") - self.ui_state.isPreloading = False - self.ui_state.preloadProgress = 0 - return - - # --- Check for cached images --- - images_to_preload = [] - already_cached_count = 0 - _, _, display_gen = self.get_display_info() - - # We want to load images furthest from the current index FIRST, - # and images closest to the current index LAST. - # This ensures that the images the user is currently looking at (and their neighbors) - # are the most recently added to the LRU cache, so they won't be evicted. - - # Calculate distance for all images - # (index, distance_from_current) - all_images_with_dist = [] - for i in range(total_images): - dist = abs(i - self.current_index) - all_images_with_dist.append((i, dist)) - - # Sort by distance descending (furthest first) - all_images_with_dist.sort(key=lambda x: x[1], reverse=True) - - # Determine which images are "nearby" (e.g. within prefetch radius * 2) - # We will FORCE these to be re-cached even if they are already in cache, - # to ensure they are moved to the front of the LRU queue. - nearby_radius = self.prefetcher.prefetch_radius * 2 - - for i, dist in all_images_with_dist: - if i >= len(self.image_files): - continue - image_path = self.image_files[i].path - cache_key = build_cache_key(image_path, display_gen) - is_cached = cache_key in self.image_cache - is_nearby = dist <= nearby_radius - - if is_cached and not is_nearby: - already_cached_count += 1 - else: - # Add to preload list if it's not cached OR if it's nearby (to refresh LRU) - images_to_preload.append(i) - - log.info(f"Found {already_cached_count} cached images (skipping). Preloading {len(images_to_preload)} images (including nearby refreshes).") - - if not images_to_preload: - log.info("All images are already cached.") - self._update_preload_progress(100) - self._finish_preloading() - return - - # --- Setup progress tracking --- - # `completed` starts at the number of images already cached (that we are skipping). - completed = already_cached_count - - # Update initial progress - initial_progress = int((completed / total_images) * 100) - self._update_preload_progress(initial_progress) - - def _on_done(_future): - nonlocal completed - completed += 1 - progress = int((completed / total_images) * 100) - self.reporter.progress_updated.emit(progress) - # Check if all images (including cached ones) are accounted for - if completed == total_images: - self.reporter.finished.emit() - - # --- Submit tasks --- - # images_to_preload is already sorted furthest -> nearest - for i in images_to_preload: - # For nearby images that we are forcing to re-cache, we might need to remove them first - # to ensure the cache actually updates the LRU position (depending on cache implementation). - # ByteLRUCache (cachetools) updates LRU on access (get/set), so just overwriting is fine. - # But we need to make sure we don't skip the task in prefetcher if it thinks it's already done. - # The prefetcher checks self.futures, but we are submitting new ones. - - future = self.prefetcher.submit_task(i, self.prefetcher.generation) - if future: - future.add_done_callback(_on_done) - - def _update_preload_progress(self, progress: int): - log.debug("Updating preload progress in UI: %d%%", progress) - self.ui_state.preloadProgress = progress - - def _finish_preloading(self): - self.ui_state.isPreloading = False - self.ui_state.preloadProgress = 0 - log.info("Finished preloading all images.") - - @Slot(result=int) - def get_batch_count_for_current_image(self) -> int: - """Get the count of images in the batch that contains the current image.""" - if not self.image_files: - return 0 - - # Check if current image is in any batch - for start, end in self.batches: - if start <= self.current_index <= end: - # Calculate total count across all batches - total_count = sum(end - start + 1 for start, end in self.batches) - return total_count - - return 0 - - @Slot() - def delete_current_image(self): - """Moves current JPG and RAW to recycle bin. Shows dialog if multiple images in batch.""" - if not self.image_files: - self.update_status_message("No image to delete.") - return - - # Check if current image is in a batch with multiple images - batch_count = self.get_batch_count_for_current_image() - - if batch_count > 1: - # Show dialog asking what to delete - if hasattr(self, 'main_window') and self.main_window: - # Set batch count in dialog and open it - self.main_window.show_delete_batch_dialog(batch_count) - return - - # Single image deletion - proceed normally - self._delete_single_image(self.current_index) - - def _move_to_recycle(self, src: Path) -> Optional[Path]: - """Moves a file to the recycle bin safely, handling collisions and cross-device moves.""" - if not src.exists() or not src.is_file(): - return None - - # Ensure recycle bin exists - try: - self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: - log.error("Failed to create recycle bin: %s", e) - return None - - dest = self.recycle_bin_dir / src.name - - # Handle collisions with timestamp loop - if dest.exists(): - timestamp = int(time.time()) - base_name = f"{src.stem}.{timestamp}" - dest = self.recycle_bin_dir / f"{base_name}{src.suffix}" - counter = 1 - while dest.exists(): - dest = self.recycle_bin_dir / f"{base_name}_{counter}{src.suffix}" - counter += 1 - - try: - shutil.move(str(src), str(dest)) - log.info("Moved %s to recycle bin: %s", src.name, dest.name) - return dest - except OSError as e: - log.error("Failed to recycle %s: %s", src.name, e) - return None - - def _delete_single_image(self, index: int): - """Internal method to delete a single image by index.""" - if not self.image_files or index < 0 or index >= len(self.image_files): - self.update_status_message("No image to delete.") - return - - previous_index = self.current_index - image_file = self.image_files[index] - jpg_path = image_file.path - raw_path = image_file.raw_pair - - # Move files to recycle bin - recycled_jpg = self._move_to_recycle(jpg_path) - recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None - - # Add to delete history if anything was moved - if recycled_jpg or recycled_raw: - import time - timestamp = time.time() - # Store tuple of (src, bin_path) for each file - # Format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) - record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) - - self.delete_history.append(record) - self.undo_history.append(("delete", record, timestamp)) - - if not recycled_jpg and not recycled_raw: - self.update_status_message("Delete failed") - return - - # Refresh image list and move to next image - self.refresh_image_list() - if self.image_files: - self._reposition_after_delete(None, previous_index) - # Clear cache and invalidate display generation to force image reload - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() # Cancel stale tasks since image list changed - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - def _reposition_after_delete(self, preserved_path: Optional[Path], previous_index: int): - """Reposition current_index after the image list refreshed post-deletion.""" - if not self.image_files: - self.current_index = 0 - return - - if preserved_path: - for i, img_file in enumerate(self.image_files): - if img_file.path == preserved_path: - self.current_index = i - return - - self.current_index = min(previous_index, len(self.image_files) - 1) - - @Slot() - def delete_current_image_only(self): - """Delete only the current image, ignoring batch selection.""" - if not self.image_files: - self.update_status_message("No image to delete.") - return - self._delete_single_image(self.current_index) - - @Slot() - def delete_batch_images(self): - """Delete all images in the current batch.""" - if not self.image_files: - self.update_status_message("No images to delete.") - return - - # Collect all indices in batches - indices_to_delete = set() - for start, end in self.batches: - for i in range(start, end + 1): - if 0 <= i < len(self.image_files): - indices_to_delete.add(i) - - if not indices_to_delete: - self.update_status_message("No images in batch to delete.") - return - - # Sort indices in reverse order so we delete from end to start - # This way indices don't shift as we delete - sorted_indices = sorted(indices_to_delete, reverse=True) - - # Determine where to land after deletion - # We prefer to land on the image that was *conceptually* at the same position, - # which means following the last deleted index if we were deleting from right to left, - # or just staying at the start index of the batch. - - # If we just deleted a batch at the end of the list, we clamp to new length-1 - # If we deleted a batch in the middle, we want to be at the index that *was* - # immediately after the batch (which now shifts down by deleted_count). - - # Simpler logic: - # If we had a batch starting at index S with N items. - # After deleting N items, the item that was at S+N matches the new item at S. - # So we should generally effectively stay at 'start' (which finds the next image). - # We need to find the smallest index that was part of the deletion. - min_deleted_index = min(sorted_indices) - - # Create recycle bin if it doesn't exist - try: - self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: - self.update_status_message(f"Failed to create recycle bin: {e}") - log.error("Failed to create recycle bin directory: %s", e) - return - - deleted_count = 0 - import time - timestamp = time.time() - - # Delete all images in the batch - for index in sorted_indices: - if index >= len(self.image_files): - continue - - image_file = self.image_files[index] - jpg_path = image_file.path - raw_path = image_file.raw_pair - - try: - recycled_jpg = self._move_to_recycle(jpg_path) - recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None - - if recycled_jpg or recycled_raw: - record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) - self.delete_history.append(record) - self.undo_history.append(("delete", record, timestamp)) - deleted_count += 1 - - except OSError as e: - log.exception("Failed to delete image at index %d: %s", index, e) - - if deleted_count > 0: - # Clear all batches after deletion - self.batches = [] - self.batch_start_index = None - - # Refresh image list - self.refresh_image_list() - - if self.image_files: - # Calculate new index - # We essentially want to be at 'min_deleted_index' - # But clamped to boundaries. - new_index = min_deleted_index - new_index = max(0, min(new_index, len(self.image_files) - 1)) - - self.current_index = new_index - - # Clear cache and invalidate display generation to force image reload - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() # Cancel stale tasks since image list changed - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - self.update_status_message(f"Deleted {deleted_count} image(s)") - log.info("Deleted %d image(s) from batch", deleted_count) - else: - self.update_status_message("No images were deleted.") - - def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> bool: - """ - Robustly restores a backup file to its original location, handling - locking and permission errors using a unique temporary file strategy. - Verifies success. - """ - saved_path = Path(saved_path_str) - backup_path = Path(backup_path_str) - - if not backup_path.exists(): - if saved_path.exists(): - self.update_status_message("Already restored (backup missing)") - log.warning("Backup %s missing but original exists.", backup_path) - else: - self.update_status_message("Backup not found") - log.warning("Backup %s disappeared before it could be restored.", backup_path) - return False - - # Generate a unique temporary path to avoid collisions - temp_path = saved_path.with_suffix(f'.{uuid.uuid4().hex}.tmp_restore') - - try: - # 1. If the target exists, we need to move the backup to the temp location first, - # then try to swap. If target is locked, we can't delete it directly. - if saved_path.exists(): - try: - saved_path.unlink() # Try the easy way first - except PermissionError as pe: - log.warning("File %s locked, attempting safe restore strategy: %s", saved_path, pe) - - # Move backup to temp - try: - shutil.move(str(backup_path), str(temp_path)) - except OSError as e: - log.error("Failed to move backup to temp: %s", e) - raise - - if not temp_path.exists(): - log.error("Temp file %s not found after move!", temp_path) - raise OSError(f"Failed to create temp file {temp_path}") - - # Try to force-move the temp file over the target (replace) - try: - os.replace(str(temp_path), str(saved_path)) - except OSError: - # If replace fails, try to move back - log.error("Could not overwrite locked file %s", saved_path) - shutil.move(str(temp_path), str(backup_path)) - raise - - # 2. If target doesn't exist (successfully unlinked or didn't exist), move backup to target - if not saved_path.exists(): - # If we moved to temp, move temp -> target - source = temp_path if temp_path.exists() else backup_path - shutil.move(str(source), str(saved_path)) - - # Verify restoration - if not saved_path.exists(): - raise OSError(f"Restoration failed: {saved_path} does not exist after move.") - - if saved_path.stat().st_size == 0: - log.warning("Restored file %s is 0 bytes!", saved_path) - - log.info("Successfully restored %s from %s", saved_path, backup_path_str) - return True - - except Exception as e: - # Attempt cleanup - if temp_path.exists(): - try: - if backup_path.exists(): - temp_path.unlink() # Backup still there, just kill temp - else: - shutil.move(str(temp_path), str(backup_path)) # Put it back - except OSError: - pass - log.exception("Detailed error in _restore_backup_safe") - raise e - - @Slot() - def undo_delete(self): - """Unified undo that handles both delete and auto white balance operations.""" - if not self.undo_history: - self.update_status_message("Nothing to undo.") - return - - # Get the most recent action - action_type, action_data, timestamp = self.undo_history.pop() - - if action_type == "delete": - # New record format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) - (jpg_src, jpg_bin), (raw_src, raw_bin) = action_data - - # Remove from delete_history if it matches - if self.delete_history and self.delete_history[-1] == action_data: - self.delete_history.pop() - - restored_files = [] - try: - # Helper to move back safely - def restore_file(src_path: Optional[Path], bin_path: Optional[Path]): - if not src_path or not bin_path or not bin_path.exists(): - return False - if src_path.exists(): - log.warning("Cannot restore %s: User file already exists at %s", bin_path.name, src_path) - return False # Or maybe restore with new name? For now, skip to prevent overwrite - - shutil.move(str(bin_path), str(src_path)) - return True - - # Restore JPG - if restore_file(jpg_src, jpg_bin): - restored_files.append(jpg_src.name) - log.info("Restored %s from recycle bin", jpg_src.name) - - # Restore RAW - if restore_file(raw_src, raw_bin): - restored_files.append(raw_src.name) - log.info("Restored %s from recycle bin", raw_src.name) - - # Update status - if restored_files: - files_str = ", ".join(restored_files) - self.update_status_message(f"Restored: {files_str}") - else: - self.update_status_message("No files to restore") - - # Refresh image list - self.refresh_image_list() - - # Find and navigate to the restored image - for i, img_file in enumerate(self.image_files): - if img_file.path == jpg_src: - self.current_index = i - break - - # Clear cache and invalidate display generation to force image reload - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() # Cancel stale tasks since image list changed - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - except OSError as e: - self.update_status_message(f"Undo failed: {e}") - log.exception("Failed to restore image") - # Put it back in history if it failed - self.undo_history.append(("delete", action_data, timestamp)) - self.delete_history.append(action_data) - - elif action_type == "auto_white_balance": - saved_path, backup_path = action_data - try: - if self._restore_backup_safe(saved_path, backup_path): - # Refresh - self.refresh_image_list() - # Find - saved_path_obj = Path(saved_path) - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path_obj: - self.current_index = i - break - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - if self.ui_state.isHistogramVisible: - self.update_histogram() - - self.update_status_message("Undid auto white balance") - except Exception as e: - self.update_status_message(f"Undo failed: {e}") - if Path(backup_path).exists(): - self.undo_history.append(("auto_white_balance", action_data, timestamp)) - - elif action_type == "auto_levels": - saved_path, backup_path = action_data - try: - if self._restore_backup_safe(saved_path, backup_path): - # Refresh - self.refresh_image_list() - # Find - saved_path_obj = Path(saved_path) - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path_obj: - self.current_index = i - break - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - if self.ui_state.isHistogramVisible: - self.update_histogram() - - self.update_status_message("Undid auto levels") - except Exception as e: - self.update_status_message(f"Undo failed: {e}") - if Path(backup_path).exists(): - self.undo_history.append(("auto_levels", action_data, timestamp)) - - elif action_type == "crop": - saved_path, backup_path = action_data - try: - if self._restore_backup_safe(saved_path, backup_path): - # Refresh - self.refresh_image_list() - # Find - saved_path_obj = Path(saved_path) - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path_obj: - self.current_index = i - break - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - self.update_status_message("Undid crop") - except Exception as e: - self.update_status_message(f"Undo failed: {e}") - if Path(backup_path).exists(): - self.undo_history.append(("crop", action_data, timestamp)) - - def shutdown(self): - log.info("Application shutting down.") - - # Check if recycle bin has files and prompt to empty - if self.recycle_bin_dir.exists(): - files_in_bin = list(self.recycle_bin_dir.glob("*")) - if files_in_bin: - file_count = len(files_in_bin) - msg_box = QMessageBox() - msg_box.setWindowTitle("Recycle Bin") - msg_box.setText(f"There are {file_count} files in the recycle bin.") - msg_box.setInformativeText("What would you like to do?") - - # Add custom buttons - delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.YesRole) - restore_btn = msg_box.addButton(f"Restore {file_count} deleted files", QMessageBox.ActionRole) - keep_btn = msg_box.addButton("Keep in Recycle Bin", QMessageBox.NoRole) - - msg_box.setDefaultButton(keep_btn) - msg_box.exec() - - clicked_button = msg_box.clickedButton() - if clicked_button == delete_btn: - self.empty_recycle_bin() - elif clicked_button == restore_btn: - self.restore_all_from_recycle_bin() - - # Clear QML context property to prevent TypeErrors during shutdown - if self.engine: - log.info("Clearing uiState context property in QML.") - del self.engine # Explicitly delete the engine - - self.watcher.stop() - self.prefetcher.shutdown() - self.sidecar.set_last_index(self.current_index) - self.sidecar.save() - - def _shutdown_executors(self): - """Explicitly shuts down thread pools on app exit to prevent hanging.""" - self._shutting_down = True - log.info("Shutting down background executors...") - self._hist_executor.shutdown(wait=False, cancel_futures=True) - self._preview_executor.shutdown(wait=False, cancel_futures=True) - - def empty_recycle_bin(self): - """Permanently deletes all files in the recycle bin.""" - if not self.recycle_bin_dir.exists(): - return - - try: - import shutil - shutil.rmtree(self.recycle_bin_dir) - self.delete_history.clear() - log.info("Emptied recycle bin and cleared delete history") - except OSError: - log.exception("Failed to empty recycle bin") - - def _on_cache_evict(self): - """Callback for when the image cache evicts an item.""" - now = time.time() - - # 1. Record eviction timestamp - self._eviction_timestamps.append(now) - - # 2. Prune timestamps older than window - # Keep list short - cutoff = now - CACHE_THRASH_WINDOW_SECS - self._eviction_timestamps = [t for t in self._eviction_timestamps if t > cutoff] - - # 3. Check for thrashing (e.g., > threshold evictions in window) - if len(self._eviction_timestamps) > CACHE_THRASH_THRESHOLD: - # 4. Rate limit the warning - if now - self._last_cache_warning_time > CACHE_WARNING_COOLDOWN_SECS: - self._last_cache_warning_time = now - self._has_warned_cache_full = True - - # Format usage info - used_gb = self.image_cache.currsize / (1024**3) - max_gb = self.image_cache.max_bytes / (1024**3) - - msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in {CACHE_THRASH_WINDOW_SECS}s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB." - - # Use QTimer.singleShot to ensure this runs on the main thread - QTimer.singleShot(0, lambda: self.update_status_message(msg)) - log.warning(msg) - - def restore_all_from_recycle_bin(self): - """Restores all files from recycle bin to working directory.""" - if not self.recycle_bin_dir.exists(): - return - - try: - files_in_bin = list(self.recycle_bin_dir.glob("*")) - restored_count = 0 - - for file_in_bin in files_in_bin: - # Restore to original location (working directory) - dest_path = self.image_dir / file_in_bin.name - - # If file already exists, skip (don't overwrite) - if dest_path.exists(): - log.warning("File already exists, skipping: %s", dest_path) - continue - - try: - file_in_bin.rename(dest_path) - restored_count += 1 - log.info("Restored %s from recycle bin", file_in_bin.name) - except OSError as e: - log.error("Failed to restore %s: %s", file_in_bin.name, e) - - # Clear delete history since we restored everything - self.delete_history.clear() - - log.info("Restored %d files from recycle bin", restored_count) - - except OSError: - log.exception("Failed to restore files from recycle bin") - - @Slot() - def edit_in_photoshop(self): - if not self.image_files: - self.update_status_message("No image to edit.") - return - - # Prefer RAW file if it exists, otherwise use JPG - image_file = self.image_files[self.current_index] - jpg_path = image_file.path - - # Handle backup images: strip -backup, -backup2, -backup-1, etc. to find original RAW - import re - original_stem = jpg_path.stem - # Remove -backup with optional digits or -backup-digits (handles both formats) - original_stem = re.sub(r'-backup(-?\d+)?$', '', original_stem) - - # Look for RAW file with the original stem - raw_path = None - if image_file.raw_pair and image_file.raw_pair.exists(): - # Use the paired RAW if it exists - raw_path = image_file.raw_pair - else: - # Search for RAW file manually by original stem - from faststack.io.indexer import RAW_EXTENSIONS - for ext in RAW_EXTENSIONS: - potential_raw = jpg_path.parent / f"{original_stem}{ext}" - if potential_raw.exists(): - raw_path = potential_raw - break - - if raw_path and raw_path.exists(): - current_image_path = raw_path - log.info("Using RAW file for Photoshop: %s", raw_path) - else: - current_image_path = jpg_path - log.info("Using JPG file for Photoshop (no RAW found): %s", current_image_path) - - photoshop_exe = config.get('photoshop', 'exe') - photoshop_args = config.get('photoshop', 'args') - - # Validate executable path securely - is_valid, error_msg = validate_executable_path( - photoshop_exe, - app_type="photoshop", - allow_custom_paths=True - ) - - if not is_valid: - self.update_status_message(f"Photoshop validation failed: {error_msg}") - log.error("Photoshop executable validation failed: %s", error_msg) - return - - # Validate that the file path exists and is a file - if not current_image_path.exists() or not current_image_path.is_file(): - self.update_status_message(f"Image file not found: {current_image_path.name}") - log.error("Image file not found or not a file: %s", current_image_path) - return - - try: - # Build command list safely - command = [photoshop_exe] - - # Parse additional args safely using shlex (handles quotes and escapes properly) - if photoshop_args: - try: - # Use shlex to properly parse arguments with quotes/escapes - # On Windows, use posix=False to handle Windows-style paths - parsed_args = shlex.split(photoshop_args, posix=(os.name != 'nt')) - command.extend(parsed_args) - except ValueError as e: - log.error("Invalid photoshop_args format: %s", e) - self.update_status_message("Invalid Photoshop arguments configured") - return - - # Add the file path as the last argument - # Convert to string but keep it as a list element (not shell-interpolated) - command.append(str(current_image_path.resolve())) - - # SECURITY: Explicitly disable shell execution - subprocess.Popen( - command, - shell=False, # CRITICAL: Never use shell=True with user input - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - close_fds=True # Close unused file descriptors - ) - - # Mark as edited on successful launch - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = image_file.path.stem - meta = self.sidecar.get_metadata(stem) - meta.edited = True - meta.edited_date = today - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - - self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") - log.info("Launched Photoshop with: %s", command) - except FileNotFoundError as e: - self.update_status_message(f"Photoshop executable not found: {e}") - log.exception("Photoshop executable not found") - # Don't mark as edited if launch failed - return - except (OSError, subprocess.SubprocessError) as e: - self.update_status_message(f"Failed to open in Photoshop: {e}") - log.exception("Error launching Photoshop") - # Don't mark as edited if launch failed - return - - @Slot() - def copy_path_to_clipboard(self): - if not self.image_files: - self.update_status_message("No image path to copy.") - return - - current_image_path = str(self.image_files[self.current_index].path) - QApplication.clipboard().setText(current_image_path) - self.update_status_message(f"Copied: {current_image_path}") - log.info("Copied path to clipboard: %s", current_image_path) - - @Slot() - def reset_zoom_pan(self): - """Resets zoom and pan to fit the image in the window (like Ctrl+0 in Photoshop).""" - log.info("Resetting zoom and pan to fit window") - self.ui_state.resetZoomPan() - self.update_status_message("Reset zoom and pan") - - def update_status_message(self, message: str, timeout: int = 3000): - """ - Updates the UI status message and clears it after a timeout. - """ - def clear_message(): - if self.ui_state.statusMessage == message: - self.ui_state.statusMessage = "" - - self.ui_state.statusMessage = message - QTimer.singleShot(timeout, clear_message) - - - - @Slot() - def start_drag_current_image(self): - if not self.image_files or self.current_index >= len(self.image_files): - return - - # Collect all files: current + any in defined batches - files_to_drag = set() - files_to_drag.add(self.current_index) - - # Add all files from defined batches - for start, end in self.batches: - for idx in range(start, end + 1): - if 0 <= idx < len(self.image_files): - files_to_drag.add(idx) - - # Convert to sorted list and get only existing paths - file_indices = sorted(files_to_drag) - existing_indices = [idx for idx in file_indices if self.image_files[idx].path.exists()] - - # Prefer dragging the developed JPG if it exists (for external export), - # but only when RAW mode is active or we are dragging a developed file itself. - file_paths = [] - for idx in existing_indices: - img = self.image_files[idx] - - # Suggestion: only prefer -developed.jpg when RAW mode is active - # or when the current entry is itself the working/developed artifact. - is_developed_artifact = img.path.stem.lower().endswith("-developed") - in_raw_mode = (getattr(self, 'current_edit_source_mode', 'jpeg') == "raw") - - if (in_raw_mode or is_developed_artifact) and img.developed_jpg_path.exists(): - file_paths.append(img.developed_jpg_path) - else: - file_paths.append(img.path) - - if not file_paths: - log.error("No valid files to drag") - return - - if self.main_window is None: - return - - drag = QDrag(self.main_window) - mime_data = QMimeData() - - # Use Qt's standard setUrls - it handles both browser and native app compatibility - urls = [QUrl.fromLocalFile(str(p)) for p in file_paths] - mime_data.setUrls(urls) - - drag.setMimeData(mime_data) - - # --- thumbnail / drag preview --- - pix = QPixmap(str(file_paths[0])) - if not pix.isNull(): - # scale it down so it's not huge - scaled = pix.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) - drag.setPixmap(scaled) - # hotspot = center of image - drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) - - log.info("Starting drag for %d file(s): %s", len(file_paths), [str(p) for p in file_paths]) - # Support both Copy and Move actions for browser compatibility - result = drag.exec(Qt.CopyAction | Qt.MoveAction) - log.info("Drag completed with result: %s", result) - - # Reset zoom/pan after drag completes (drag can cause unwanted panning) - self.ui_state.resetZoomPan() - - # Mark all dragged files as uploaded if drag was successful - if result in (Qt.CopyAction, Qt.MoveAction): - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - - for idx in existing_indices: - stem = self.image_files[idx].path.stem - meta = self.sidecar.get_metadata(stem) - meta.uploaded = True - meta.uploaded_date = today - - self.sidecar.save() - - # Clear all batches after successful drag (like pressing \) - self.batches = [] - self.batch_start_index = None - - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) - - - - @Slot() - def enable_raw_editing(self): - """Switches the current image to RAW mode (using developed TIFF).""" - if not self.image_files: - return - - # 1. Update State - # 1. Update State - if self.current_edit_source_mode != "raw": - self.current_edit_source_mode = "raw" - self.editSourceModeChanged.emit("raw") - self.sync_ui_state() - - # 2. Check if we have a valid TIFF ready - path = self.get_active_edit_path(self.current_index) - - # If the path returned IS the working TIFF (and it exists), we can just load it. - # Check specific condition: - image_file = self.image_files[self.current_index] - if path == image_file.working_tif_path and self.is_valid_working_tif(path): - log.info("Valid working TIFF exists, switching to RAW mode immediately.") - self.load_image_for_editing() # This will now pick up the TIFF via get_active_edit_path - return - - # 3. If not ready, trigger development - # (Pass through to existing backend logic) - self._develop_raw_backend() - - def _develop_raw_backend(self): - """Internal: Triggers the actual RawTherapee process.""" - if not self.image_files: - return - - image_file = self.image_files[self.current_index] - if not image_file.has_raw: - self.update_status_message("No RAW file available.") - return - - raw_path = image_file.raw_path - tif_path = image_file.working_tif_path - - # Resolve RawTherapee Executable - from faststack.config import config - rt_exe = config.get("rawtherapee", "exe") - if not rt_exe or not os.path.exists(rt_exe): - self.update_status_message("RawTherapee not found. Check settings.") - log.error("RawTherapee executable not configured or missing: %s", rt_exe) - return - self.update_status_message("Developing RAW... please wait.") - log.info("Starting RAW development: %s -> %s", raw_path, tif_path) - - def worker(): - # Check for optional args in config - rt_args = config.get("rawtherapee", "args") - - # Build command: rawtherapee-cli -t -Y -o -c - # -t: TIFF output - # -b16: 16-bit depth (Critical! Default is often 8-bit) - # -Y: Overwrite existing - # -o: Output file - # -c: Input file (must be last) - cmd = [rt_exe, "-t", "-b16", "-Y", "-o", str(tif_path)] - - if rt_args: - try: - # Use shlex to properly parse arguments with quotes/escapes - # On Windows, use posix=False to handle Windows-style paths - parsed_args = shlex.split(rt_args, posix=(os.name != 'nt')) - cmd.extend(parsed_args) - except ValueError as e: - log.error("Invalid rawtherapee args format: %s", e) - - cmd.extend(["-c", str(raw_path)]) - cmd_str = " ".join(cmd) # For logging - - # Run process - run_kwargs = { - "capture_output": True, - "text": True, - "timeout": 60 # 60 second timeout - } - if sys.platform == "win32": - run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW - - try: - result = subprocess.run(cmd, **run_kwargs) - - if result.returncode == 0: - if tif_path.exists() and tif_path.stat().st_size > 0: - log.info("RAW development successful.") - # Use partial to bind variable deeply - QTimer.singleShot(0, functools.partial(self._on_develop_finished, True, None)) - return # Success path - else: - msg = f"RawTherapee exited successfully but output file is missing or empty.\nCommand: {cmd_str}" - log.error(msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, msg)) - else: - stderr = result.stderr.strip() if result.stderr else "(no stderr)" - stdout = result.stdout.strip() if result.stdout else "(no stdout)" - err_msg = f"RawTherapee failed (exit code {result.returncode}):\nCommand: {cmd_str}\nstderr: {stderr}\nstdout: {stdout}" - log.error(err_msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) - - except subprocess.TimeoutExpired: - err_msg = f"RawTherapee timed out after 60 seconds.\nCommand: {cmd_str}" - log.error(err_msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) - except Exception as e: - err_msg = f"Unexpected error running RawTherapee: {str(e)}" - log.exception(err_msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) - finally: - # Cleanup if we failed and left a bad file or 0-byte file (unless success logic already returned) - # Note: success logic returns early. If we are here, we likely failed or fell through (e.g. 0 byte file case did not return) - # Actually, the 0-byte case calls on_finished but doesn't return, so it falls here. - # Let's check specifically if we need to cleanup. - # If we succeeded, we returned. - if tif_path.exists() and 'result' in locals(): - # Only cleanup if result was assigned (subprocess ran) - # If it's 0 bytes or we are in an error state (which implies we didn't return early) - try: - if tif_path.stat().st_size == 0: - tif_path.unlink() - elif result.returncode != 0: - # If we crashed but left a file, delete it - tif_path.unlink() - except (OSError, AttributeError): - # AttributeError if result is None - pass - - threading.Thread(target=worker, daemon=True).start() - - # Preserving legacy slot name for compatibility if QML calls it directly, - # but QML should call enable_raw_editing now. - # Actually provider.py calls this. I will update provider.py to call enable_raw_editing. - # But I'll keep this as a proxy to the new method just in case. - @Slot() - def develop_raw_for_current_image(self): - self.enable_raw_editing() - - - @Slot() - def load_image_for_editing(self): - """ - Loads the currently viewed image into the editor using active path logic. - This provides a centralized entry point for loading the editor correctly. - """ - try: - active_path = self.get_active_edit_path(self.current_index) - filepath = str(active_path) - - # Fetch cached preview if available for faster initial display - cached_preview = self.get_decoded_image(self.current_index) - - # Determine if we should capture source EXIF (e.g., for RAW mode) - source_exif = None - if self.current_edit_source_mode == "raw": - # Capture EXIF from the original JPEG to preserve in developed JPG - image_file = self.image_files[self.current_index] - jpeg_path = image_file.path - # Only if the main path isn't itself a TIFF (avoid recursion) - if jpeg_path.suffix.lower() not in ('.tif', '.tiff') and jpeg_path.exists(): - try: - with Image.open(jpeg_path) as src_im: - source_exif = src_im.info.get('exif') - except Exception as e: - log.warning(f"Failed to capture source EXIF from {jpeg_path}: {e}") - - # Load into editor - if self.image_editor.load_image(filepath, cached_preview=cached_preview, source_exif=source_exif): - # Notify UIState to update bindings - # We do this via signals or by calling the update function on UIState if available - # But UIState listens to editor signals? - # Actually, the previous implementation in UIState pushed edits to itself. - # We need to preserve that behavior. - # For now, simpler to emit a signal that UIState listens to, - # OR just manually update UIState here if we have reference. - if self.ui_state: - self._sync_editor_state_to_ui() - - return True - except Exception as e: - log.exception("Failed to load image for editing: %s", e) - self.update_status_message(f"Error loading editor: {e}") - - return False - - def _sync_editor_state_to_ui(self): - """Helper to push editor state (initial edits) to UIState.""" - initial_edits = self.image_editor._initial_edits() - for key, value in initial_edits.items(): - if hasattr(self.ui_state, key): - setattr(self.ui_state, key, value) - - # Reset visual components - if hasattr(self.ui_state, 'aspectRatioNames'): - # This requires IMPORTs? No, just pass list. - from faststack.imaging.editor import ASPECT_RATIOS - self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] - self.ui_state.currentAspectRatioIndex = 0 - self.ui_state.currentCropBox = (0, 0, 1000, 1000) - - # Kick off background render - self._kick_preview_worker() - # Notify UI - self.ui_state.editorImageChanged.emit() - - def _on_develop_finished(self, success: bool, error_msg: Optional[str]): - """Callback on main thread after RAW development.""" - if success: - self.update_status_message("RAW Development complete.") - # Load active path (which should now be the developed TIFF) - self.load_image_for_editing() - else: - self.update_status_message(f"Development failed: {error_msg}") - # Ensure UI reflects failure (maybe revert mode? or just show error) - # Staying in RAW mode but failing to load allows user to try again or see error. - - @Slot(result=DecodedImage) - def get_preview_data(self) -> Optional[DecodedImage]: - """Gets the preview data of the currently edited image as a DecodedImage.""" - return self.image_editor.get_preview_data() - - @Slot(str, "QVariant") - def set_edit_parameter(self, key: str, value: Any): - """Sets an edit parameter and updates the UIState for the slider visual.""" - # Robust guard: only allow edits if the editor is actually holding an image. - if not self.image_editor: - return - if self.image_editor.current_filepath is None: - return - # Must have either a float image (working copy) or original loaded - if self.image_editor.float_image is None and self.image_editor.original_image is None: - return - - try: - # Update actual edit state (this bumps _edits_rev and invalidates preview cache) - changed = self.image_editor.set_edit_param(key, value) - - # Sync UI state with backend (e.g., rotation might be rounded) - final_value = value - if changed: - # Use thread-safe accessor to get the actual value applied - actual = self.image_editor.get_edit_value(key) - if actual is not None: - final_value = actual - - # Update UI state regardless (visual sliders need to match what user dragged, OR the clamped backend value) - if hasattr(self.ui_state, key): - setattr(self.ui_state, key, final_value) - - # Trigger a refresh of the image to show the edit, ONLY if something changed - # Uses gate pattern: runs immediately if not inflight, else queues for next - if changed: - self._kick_preview_worker() - except Exception as e: - log.error("Error setting edit parameter %s=%s: %s", key, value, e) - - @Slot(int, int, int, int) - def set_crop_box(self, left: int, top: int, right: int, bottom: int): - """Sets the normalized crop box (0-1000) in the editor.""" - from typing import Tuple - crop_box: Tuple[int, int, int, int] = (left, top, right, bottom) - self.image_editor.set_crop_box(crop_box) - self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) - - @Slot() - def reset_edit_parameters(self): - """Resets all editing parameters in the editor.""" - self.image_editor.reset_edits() - if hasattr(self.ui_state, 'reset_editor_state'): - self.ui_state.reset_editor_state() - - self.update_status_message("Edits reset") - - # Trigger a refresh to show the reset image - self.ui_refresh_generation += 1 - self._kick_preview_worker() - - if self.ui_state.isHistogramVisible: - self.update_histogram() - - - @Slot() - def rotate_image_cw(self): - """Rotate the edited image 90 degrees clockwise.""" - current = self.image_editor.current_edits.get('rotation', 0) - new_rotation = (current - 90) % 360 - self.set_edit_parameter('rotation', new_rotation) - if self.ui_state.isHistogramVisible: - self.update_histogram() - - @Slot() - def rotate_image_ccw(self): - """Rotate the edited image 90 degrees counter-clockwise.""" - current = self.image_editor.current_edits.get('rotation', 0) - new_rotation = (current + 90) % 360 - self.set_edit_parameter('rotation', new_rotation) - if self.ui_state.isHistogramVisible: - self.update_histogram() - - @Slot() - def toggle_histogram(self): - """Toggle histogram window visibility.""" - self.ui_state.isHistogramVisible = not self.ui_state.isHistogramVisible - if self.ui_state.isHistogramVisible: - self.update_histogram() - log.info("Histogram window opened") - else: - log.info("Histogram window closed") - - @Slot() - @Slot(float, float, float, float) # zoom, panX, panY, imageScale - def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = 0.0, image_scale: float = 1.0): - """Throttled request to update histogram. Updates continuously but capped at interval. - - Args: - zoom: Zoom scale factor (1.0 = no zoom) - pan_x: Pan offset in X direction (in image coordinates) - pan_y: Pan offset in Y direction (in image coordinates) - image_scale: Scale factor of displayed image vs original - """ - # Early guard: don't even schedule if nothing is showing the histogram - if not (self.ui_state.isHistogramVisible or self.ui_state.isEditorOpen): - with self._hist_lock: - self._hist_pending = None - return - - with self._hist_lock: - self._hist_pending = (zoom, pan_x, pan_y, image_scale) - inflight = self._hist_inflight - - if not self.histogram_timer.isActive() and not inflight: - self.histogram_timer.start() - - def _kick_histogram_worker(self): - if getattr(self, "_shutting_down", False): - return - - with self._hist_lock: - if self._hist_inflight: - return - if self._hist_pending is None: - return - - args = self._hist_pending - self._hist_pending = None - - self._hist_token += 1 - token = self._hist_token - # Mark as inflight while holding the lock to prevent others from entering - self._hist_inflight = True - - # Snap the currently known preview data to avoid racing with the editor - preview_data = self._last_rendered_preview - if not preview_data: - # Fallback for initial load if no edit preview yet (could use get_decoded_image?) - # But histogram is mostly for edits. If preview_data is None, we likely can't compute anyway. - # We can try to peek at the image editor if _last_rendered_preview is unset. - preview_data = self.image_editor.get_preview_data_cached(allow_compute=False) - - # Fallback: If still no preview data (e.g. editor not open), we need to fetch the main image. - # But doing get_decoded_image() here blocks the main thread. - # Instead, we pass the index to the worker and let it fetch/decode if needed. - target_index = -1 - if not preview_data and 0 <= self.current_index < len(self.image_files): - target_index = self.current_index - - # If no preview data AND no valid index, we can't compute. - if not preview_data and target_index == -1: - # We must clear inflight if we abort, otherwise we deadlock future updates - # Keep lock held while modifying shared state AND checking timer to prevent race - with self._hist_lock: - self._hist_inflight = False - # Restore pending args so the next timer tick (or preview completion) retries - if self._hist_pending is None: - self._hist_pending = args - # Make sure timer is running to retry (check under lock to avoid race) - should_start_timer = not self.histogram_timer.isActive() - - if should_start_timer: - self.histogram_timer.start() - return - - try: - # Pass simple data + controller reference + target_index - fut = self._hist_executor.submit(self._compute_histogram_worker, token, args, preview_data, self, target_index) - fut.add_done_callback(self._on_histogram_done) - except Exception as e: - log.error(f"Histogram executor failed to submit task: {e}") - with self._hist_lock: - self._hist_inflight = False - - @staticmethod - def _compute_histogram_worker(token, args, decoded, controller=None, target_index=-1): - # IMPORTANT: do not touch QObjects here except thread-safe plain data - zoom, pan_x, pan_y, image_scale = args - - # If data wasn't provided, try to fetch it safely using the controller - if not decoded and controller and target_index >= 0: - decoded = controller._get_decoded_image_safe(target_index) - - # Use explicitly passed or fetched decoded data - if not decoded: - return token, None - - import numpy as np - try: - arr = np.frombuffer(decoded.buffer, dtype=np.uint8).reshape((decoded.height, decoded.width, 3)) - - # If zoomed in, calculate visible region and only use that portion - if zoom > 1.1: - visible_width = decoded.width / zoom - visible_height = decoded.height / zoom - center_x = decoded.width / 2 - center_y = decoded.height / 2 - pan_x_image = pan_x / image_scale if image_scale > 0 else 0 - pan_y_image = pan_y / image_scale if image_scale > 0 else 0 - visible_center_x = center_x - (pan_x_image / zoom) - visible_center_y = center_y - (pan_y_image / zoom) - - visible_x_start = max(0, int(visible_center_x - visible_width / 2)) - visible_y_start = max(0, int(visible_center_y - visible_height / 2)) - visible_x_end = min(decoded.width, int(visible_center_x + visible_width / 2)) - visible_y_end = min(decoded.height, int(visible_center_y + visible_height / 2)) - - if visible_x_end > visible_x_start and visible_y_end > visible_y_start: - arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] - - bins = 256 - value_range = (0, 256) - - r_hist = np.histogram(arr[:, :, 0], bins=bins, range=value_range)[0] - g_hist = np.histogram(arr[:, :, 1], bins=bins, range=value_range)[0] - b_hist = np.histogram(arr[:, :, 2], bins=bins, range=value_range)[0] - - r_clip_count = int(r_hist[255]) - g_clip_count = int(g_hist[255]) - b_clip_count = int(b_hist[255]) - - r_preclip_count = int(np.sum(r_hist[250:255])) - g_preclip_count = int(np.sum(g_hist[250:255])) - b_preclip_count = int(np.sum(b_hist[250:255])) - - log_r_hist = [float(x) for x in np.log1p(r_hist)] - log_g_hist = [float(x) for x in np.log1p(g_hist)] - log_b_hist = [float(x) for x in np.log1p(b_hist)] - - hist = { - 'r': log_r_hist, - 'g': log_g_hist, - 'b': log_b_hist, - 'r_clip': r_clip_count, - 'g_clip': g_clip_count, - 'b_clip': b_clip_count, - 'r_preclip': r_preclip_count, - 'g_preclip': g_preclip_count, - 'b_preclip': b_preclip_count, - } - return token, hist - except Exception: - return token, None - - def _on_histogram_done(self, fut): - if getattr(self, "_shutting_down", False): - return - - try: - token, hist = fut.result() - except Exception: - token, hist = None, None - - # bounce back to UI thread via signal - self.histogramReady.emit((token, hist)) - - @Slot(object) - def _apply_histogram_result(self, payload): - if getattr(self, "_shutting_down", False): - return - - token, hist = payload - - with self._hist_lock: - self._hist_inflight = False - - if hist is not None: - if token == self._hist_token: - self.ui_state.histogramData = hist - self.ui_state.highlightStateChanged.emit() - - # If more updates arrived while we computed, run again soon - pending = self._hist_pending is not None - - if pending: - self.histogram_timer.start() - - def _kick_preview_worker(self): - """Kicks off a background preview render task.""" - if getattr(self, "_shutting_down", False): - return - - with self._preview_lock: - if self._preview_inflight: - self._preview_pending = True - return - - self._preview_inflight = True - self._preview_pending = False - self._preview_token += 1 - token = self._preview_token - - # Submit task to dedicated preview executor - try: - fut = self._preview_executor.submit(self._render_preview_worker, token, self.image_editor) - fut.add_done_callback(self._on_preview_done) - except RuntimeError: - log.warning("Preview executor failed (shutting down?)") - with self._preview_lock: - self._preview_inflight = False - - @staticmethod - def _render_preview_worker(token, image_editor): - # Heavy work (PIL apply_edits) happens here off-thread - try: - # allow_compute=True ensures we actually do the work - decoded = image_editor.get_preview_data_cached(allow_compute=True) - return token, decoded - except Exception: - log.exception("Preview render failed") - return token, None - - def _on_preview_done(self, fut): - if getattr(self, "_shutting_down", False): - return - - try: - token, decoded = fut.result() - except Exception: - token, decoded = None, None - - # Emit from worker thread; Qt will queue to UI thread - self.previewReady.emit((token, decoded)) - - @Slot(object) - def _apply_preview_result(self, payload): - if getattr(self, "_shutting_down", False): - return - - token, decoded = payload - should_kick = False - should_accept = False - - with self._preview_lock: - self._preview_inflight = False - - # Accept result only if: - # 1. We got valid decoded data - # 2. Token matches (not stale from an old request) - # 3. No pending request waiting (avoid "snap back" stale frame flash) - if decoded is not None and token == self._preview_token and not self._preview_pending: - self._last_rendered_preview = decoded - self.ui_refresh_generation += 1 - self._last_rendered_preview_index = self.current_index - self._last_rendered_preview_gen = self.ui_refresh_generation - should_accept = True - - # Consume pending flag atomically before scheduling - if self._preview_pending: - self._preview_pending = False - should_kick = True - - # Emit outside lock to avoid holding lock during UI work - if should_accept: - self.ui_state.currentImageSourceChanged.emit() - self.ui_state.highlightStateChanged.emit() - self.update_histogram() - - # Call directly (not via singleShot) since we're on the UI thread. - # This prevents race where a new slider event could interleave between - # scheduling and execution, causing a spurious extra render. - if should_kick: - self._kick_preview_worker() - - - - @Slot() - def cancel_crop_mode(self): - """Cancel crop mode without applying changes.""" - if self.ui_state.isCropping: - self.ui_state.isCropping = False - self.ui_state.currentCropBox = [0, 0, 1000, 1000] - # Ensure preview rotation is cleared - self.image_editor.set_edit_param("straighten_angle", 0.0) - # Force QML to refresh if it's showing provider preview frames - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.update_status_message("Crop cancelled") - log.info("Crop mode cancelled") - - @Slot() - def toggle_crop_mode(self): - """Toggle crop mode on/off.""" - self.ui_state.isCropping = not self.ui_state.isCropping - if self.ui_state.isCropping: - # Reset crop box when entering crop mode - self.ui_state.currentCropBox = (0, 0, 1000, 1000) - # Set aspect ratios for QML dropdown - self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] - self.ui_state.currentAspectRatioIndex = 0 - - # Pre-load image into editor to ensure smooth rotation - if self.image_files and self.current_index < len(self.image_files): - image_file = self.image_files[self.current_index] - filepath = image_file.path - editor_path = self.image_editor.current_filepath - - # Robust comparison - match = False - if editor_path: - try: - match = Path(editor_path).resolve() == Path(filepath).resolve() - except (OSError, ValueError): - match = str(editor_path) == str(filepath) - - if not match: - log.debug(f"toggle_crop_mode: Loading {filepath} into editor") - # Use cached preview if available to speed up using get_decoded_image(self.current_index) - # note: get_decoded_image verifies index bounds - cached_preview = self.get_decoded_image(self.current_index) - self.image_editor.load_image(str(filepath), cached_preview=cached_preview) - - # Reset rotation to 0 when starting fresh crop mode - self.image_editor.set_edit_param("straighten_angle", 0.0) - - self.update_status_message("Crop mode: Drag to select area, Enter to crop") - log.info("Crop mode enabled") - else: # Exiting crop mode - self.ui_state.isCropping = False - self.ui_state.currentCropBox = (0, 0, 1000, 1000) - self.update_status_message("Crop cancelled") - log.info("Crop mode disabled") - - @Slot() - def stack_source_raws(self): - """ - Finds the source RAW files for the current stacked JPG and launches Helicon Focus. - """ - if not self.image_files or self.current_index >= len(self.image_files): - self.update_status_message("No image selected.") - return - - current_image_path = self.image_files[self.current_index].path - filename = current_image_path.name - - # Ensure it's a stacked JPG - if not filename.lower().endswith(" stacked.jpg"): - self.update_status_message("Current image is not a stacked JPG.") - return - - # Extract base name and number, e.g., "PB210633" from "20251121-PB210633 stacked.JPG" - match = re.search(r'([A-Z]+)(\d+)\s+stacked\.JPG', filename, re.IGNORECASE) - if not match: - self.update_status_message("Could not parse stacked JPG filename format.") - log.error("Could not parse stacked JPG filename: %s", filename) - return - - base_prefix = match.group(1) # e.g., "PB" - base_number_str = match.group(2) # e.g., "210633" - base_number = int(base_number_str) - - # Determine the RAW source directory - raw_source_dir_str = config.get('raw', 'source_dir') - if not raw_source_dir_str: - self.update_status_message("RAW source directory not configured in settings.") - log.warning("RAW source directory (raw.source_dir) is not set in config.") - return - - raw_base_dir = Path(raw_source_dir_str) - if not raw_base_dir.is_dir(): - self.update_status_message(f"RAW source directory not found: {raw_base_dir}") - log.warning("Configured RAW source directory does not exist: %s", raw_base_dir) - return - - # Get the mirror base from config - mirror_base_str = config.get('raw', 'mirror_base') - if not mirror_base_str: - self.update_status_message("RAW mirror base directory not configured in settings.") - log.warning("RAW mirror base (raw.mirror_base) is not set in config.") - return - - mirror_base_dir = Path(mirror_base_str) - if not mirror_base_dir.is_dir(): - self.update_status_message(f"RAW mirror base directory not found: {mirror_base_dir}") - log.warning("Configured RAW mirror base directory does not exist: %s", mirror_base_dir) - return - - # The date structure in the RAW directory mirrors the structure relative to the mirror_base - try: - relative_part = current_image_path.parent.relative_to(mirror_base_dir) - except ValueError: - self.update_status_message("Current image is not in the configured mirror base directory.") - log.error( - "Could not find relative path for '%s' from base '%s'. Check 'mirror_base' config.", - current_image_path.parent, - mirror_base_dir - ) - return - - raw_search_dir = raw_base_dir / relative_part - - if not raw_search_dir.is_dir(): - self.update_status_message(f"RAW directory for this date not found: {raw_search_dir}") - log.warning("RAW search directory does not exist: %s", raw_search_dir) - return - - # Find RAW files by decrementing the number - found_raw_files: List[Path] = [] - # Start one number less than the stacked image number - current_raw_number = base_number - 1 - - # Limit to reasonable number of RAWs to avoid infinite loop or too many files - max_raw_search = 15 # As per user request, typically between 3 and 15 - search_count = 0 - - while current_raw_number >= 0 and search_count < max_raw_search: - raw_filename_stem = f"{base_prefix}{current_raw_number:06d}" # e.g., PB210632 - - # Look for any of the common RAW extensions - potential_raw_paths = [] - for ext in RAW_EXTENSIONS: - potential_raw_paths.append(raw_search_dir / f"{raw_filename_stem}{ext}") - - found_this_number = False - for p in potential_raw_paths: - if p.is_file(): - found_raw_files.append(p) - found_this_number = True - break - - if not found_this_number: - # User specified "continue until there is a gap in the numbers" - # If we don't find any RAW for a number, assume it's a gap and stop - if found_raw_files: # Only break if we've found at least one file before this gap - break - - current_raw_number -= 1 - search_count += 1 - - if not found_raw_files: - self.update_status_message(f"No source RAW files found in {raw_search_dir} for {filename}.") - log.info("No source RAWs found for %s in %s", filename, raw_search_dir) - return - - # Sort the files by name to ensure Helicon Focus receives them in sequence - found_raw_files.sort() - - self.update_status_message(f"Launching Helicon Focus with {len(found_raw_files)} RAWs...") - log.info("Launching Helicon Focus for %s with RAWs: %s", filename, [str(p) for p in found_raw_files]) - success = self._launch_helicon_with_files(found_raw_files) - - if success: - # Mark as restacked on success - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - meta.restacked = True - meta.restacked_date = today - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - - self.update_status_message("Helicon Focus launched successfully.") - else: - self.update_status_message("Failed to launch Helicon Focus.") - - - - - @Slot() - def execute_crop(self): - """Execute the crop operation: crop image, save, backup, and refresh.""" - if not self.image_files or self.current_index >= len(self.image_files): - self.update_status_message("No image to crop") - return - - if not self.ui_state.isCropping: - return - - # Capture current rotation (straighten_angle) from editor state BEFORE any reload - # This is the single source of truth since set_straighten_angle updates it live. - current_rotation = float(self.image_editor.current_edits.get("straighten_angle", 0.0)) - - crop_box_raw = self.ui_state.currentCropBox - - # Normalize crop_box_raw to a tuple of 4 ints - try: - # Handle QJSValue/QVariant wrapper if present - if hasattr(crop_box_raw, "toVariant"): - crop_box_raw = crop_box_raw.toVariant() - - # Convert list to tuple if needed - if isinstance(crop_box_raw, list): - crop_box_raw = tuple(crop_box_raw) - - if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: - raise ValueError(f"Expected 4-item tuple, got {type(crop_box_raw)}: {crop_box_raw}") - - # Coerce elements to int and clamp to [0, 1000] - l, t, r, b = [max(0, min(1000, int(x))) for x in crop_box_raw] - - # Ensure correct order (left <= right, top <= bottom) - crop_box_raw = (min(l, r), min(t, b), max(l, r), max(t, b)) - - except (ValueError, TypeError, AttributeError) as e: - log.warning("Invalid crop box format: %s", e) - self.update_status_message("Invalid crop selection") - return - - if crop_box_raw == (0, 0, 1000, 1000): - self.update_status_message("No crop area selected") - return - - # Ensure image is loaded in editor - image_file = self.image_files[self.current_index] - filepath = image_file.path - - # Robust path comparison - editor_path = self.image_editor.current_filepath - paths_match = False - if editor_path: - try: - paths_match = Path(editor_path).resolve() == Path(filepath).resolve() - except (OSError, ValueError): - paths_match = str(editor_path) == str(filepath) - - if not paths_match: - log.debug(f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}") - cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image(str(filepath), cached_preview=cached_preview): - self.update_status_message("Failed to load image for cropping") - return - - self.image_editor.set_crop_box(crop_box_raw) - - # Re-apply the captured rotation. - # This handles cases where we reloaded the image (resetting edits) or where UI state sync was flaky. - self.image_editor.set_edit_param('straighten_angle', current_rotation) - - # Save via ImageEditor (handles rotation + crop correctly) - try: - save_result = self.image_editor.save_image() - except RuntimeError as e: - log.warning(f"execute_crop: Save failed: {e}") - self.update_status_message(f"Failed to save cropped image: {e}") - return - except Exception as e: - log.exception(f"execute_crop: Unexpected error during save: {e}") - self.update_status_message("Failed to save cropped image") - return - - if save_result: - saved_path, backup_path = save_result - - # Track for undo - import time - timestamp = time.time() - self.undo_history.append(("crop", (str(saved_path), str(backup_path)), timestamp)) - - # Exit crop mode - self.ui_state.isCropping = False - self.ui_state.currentCropBox = (0, 0, 1000, 1000) - - # Refresh the view - self.refresh_image_list() - - # Find the edited image - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path: - self.current_index = i - break - - # Invalidate cache and refresh display - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Reset zoom/pan - self.ui_state.resetZoomPan() - - if self.ui_state.isHistogramVisible: - self.update_histogram() - - self.update_status_message("Image cropped and saved") - log.info("Crop operation completed for %s", saved_path) - - # Force reload of editor to ensure subsequent edits operate on the cropped image - self.image_editor.clear() - self.reset_edit_parameters() - - else: - self.update_status_message("Failed to save cropped image") - - @Slot() - def auto_levels(self): - """Calculates and applies auto levels (preview only). Returns False if skipped.""" - if not self.image_files: - self.update_status_message("No image to adjust") - return False - - image_file = self.image_files[self.current_index] - filepath = str(image_file.path) - - # Ensure image is loaded in editor - if not self.image_editor.current_filepath or str(self.image_editor.current_filepath) != filepath: - cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image(filepath, cached_preview=cached_preview): - self.update_status_message("Failed to load image") - return False - - # Calculate auto levels - # Calculate auto levels - now returns (blacks, whites, p_low, p_high) - blacks, whites, p_low, p_high = self.image_editor.auto_levels(self.auto_level_threshold) - - # Auto-strength computation using stretch-factor capping - # - # Philosophy: threshold_percent defines acceptable clipping (e.g., 0.1% at each end). - # Auto-strength should NOT prevent that clipping - it's intentional. - # Instead, auto-strength prevents INSANE levels on low-dynamic-range images. - # - # Approach: Cap the stretch factor to a reasonable maximum (e.g., 3-4x). - # - Full strength: stretch = 255 / (p_high - p_low) - # - If stretch is reasonable (<= cap), use full strength - # - If stretch is extreme (> cap), blend to limit effective stretch to cap - # - if self.auto_level_strength_auto: - # Calculate full-strength stretch factor - dynamic_range = p_high - p_low - if dynamic_range < 1.0: - # Degenerate case: nearly flat image - strength = 0.0 - log.debug(f"Auto levels: degenerate dynamic range ({dynamic_range:.2f}), strength=0") - else: - stretch_full = 255.0 / dynamic_range - - # Cap stretch to prevent insane levels - # E.g., if image spans only 50-200 (range=150), full stretch would be 255/150 = 1.7x (fine) - # But if image spans 100-110 (range=10), full stretch would be 255/10 = 25.5x (insane!) - STRETCH_CAP = 4.0 # Maximum allowed stretch factor - - if stretch_full <= STRETCH_CAP: - # Reasonable stretch, use full strength - strength = 1.0 - else: - # Excessive stretch - blend to cap it - # effective_stretch = 1 + strength * (stretch_full - 1) = STRETCH_CAP - # solving for strength: strength = (STRETCH_CAP - 1) / (stretch_full - 1) - strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) - strength = max(0.0, min(1.0, strength)) - - log.debug(f"Auto levels: p_low={p_low:.1f}, p_high={p_high:.1f}, " - f"range={dynamic_range:.1f}, stretch_full={stretch_full:.2f}, strength={strength:.3f}") - else: - strength = self.auto_level_strength - - # Apply strength scaling to blacks and whites parameters - blacks *= strength - whites *= strength - - # Apply scaled values - self.image_editor.set_edit_param('blacks', blacks) - self.image_editor.set_edit_param('whites', whites) - - # Update UI state - self.ui_state.blacks = blacks - self.ui_state.whites = whites - - # Trigger preview update - self.ui_state.currentImageSourceChanged.emit() - - if self.ui_state.isHistogramVisible: - self.update_histogram() - - # Determine status message based on whether endpoints were pinned (clipping detected) - # We check p_high/p_low directly because whites/blacks might be small due to strength scaling - # even if not pinned. - msg = "Auto levels applied" - - # Check for essentially no-op (degenerate or already full range) - # Degenerate: dynamic range is tiny (< 1.0) - # Full range: p_low is near 0 and p_high near 255 - if abs(p_high - p_low) < 1.0: - msg = "Auto levels: no changes (degenerate range)" - elif p_low <= 0 and p_high >= 255: - # We already cover the full range - msg = "Auto levels: no changes (image already covers full range)" - # Check for pinning - elif p_high >= 255.0: - msg = "Auto levels: highlights already clipped; only adjusting shadows" - elif p_low <= 0.0: - msg = "Auto levels: shadows already clipped; only adjusting highlights" - - self._kick_preview_worker() - - self.update_status_message(f"{msg} (preview only)") - log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s", - filepath, self.auto_level_threshold, strength, msg) - return True - - @Slot() - def quick_auto_levels(self): - """Applies auto levels and immediately saves (with undo).""" - if not self.image_files: - self.update_status_message("No image to adjust") - return - - # Apply the preview first (loads image + sets params) - applied = self.auto_levels() - - # If in auto mode and no changes were made (skipped), don't save - if self.auto_level_strength_auto and not applied: - # Status message already set by auto_levels ("No changes made...") - return - - # Save - import time - try: - save_result = self.image_editor.save_image() - except RuntimeError as e: - log.warning(f"quick_auto_levels: Save failed: {e}") - self.update_status_message(f"Failed to save image: {e}") - return - except Exception as e: - log.exception(f"quick_auto_levels: Unexpected error during save: {e}") - self.update_status_message("Failed to save image") - return - - if save_result: - saved_path, backup_path = save_result - timestamp = time.time() - self.undo_history.append(("auto_levels", (saved_path, backup_path), timestamp)) - - # Force reload to ensure disk consistency - self.image_editor.clear() - - # Refresh list/cache/UI (standard save pattern) - # Note: We must locate the saved_path again because the list order - # might have changed (e.g., if a backup file was inserted before it). - self.refresh_image_list() - - # Find image again using robust path matching - new_index = -1 - target_name = Path(saved_path).name - - for i, img_file in enumerate(self.image_files): - # Match by filename alone - safest for flat directory structures - # avoiding drive letter/symlink/casing issues with full paths - if img_file.path.name == target_name: - new_index = i - break - - if new_index != -1: - self.current_index = new_index - else: - log.warning("Auto levels: Could not find saved image %s (name: %s) in refreshed list", saved_path, target_name) - - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - if self.ui_state.isHistogramVisible: - self.update_histogram() - - self.update_status_message("Auto levels applied and saved") - log.info("Quick auto levels saved for %s. New index: %d", saved_path, self.current_index) - else: - self.update_status_message("Failed to save image") - - - - @Slot() - def quick_auto_white_balance(self): - """Quickly apply auto white balance, save the image, and track for undo.""" - if not self.image_files: - self.update_status_message("No image to adjust") - return - - import time - image_file = self.image_files[self.current_index] - filepath = str(image_file.path) - - # Load the image into the editor if not already loaded - cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image(filepath, cached_preview=cached_preview): - self.update_status_message("Failed to load image") - return - - # Calculate and apply auto white balance - self.auto_white_balance() - - # Save the edited image (this creates a backup automatically) - try: - save_result = self.image_editor.save_image() - except RuntimeError as e: - log.warning(f"quick_auto_white_balance: Save failed: {e}") - self.update_status_message(f"Failed to save image: {e}") - return - except Exception as e: - log.exception(f"quick_auto_white_balance: Unexpected error during save: {e}") - self.update_status_message("Failed to save image") - return - - if save_result: - saved_path, backup_path = save_result - # Track this action for undo - timestamp = time.time() - self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) - - # Force the image editor to clear its current state so it reloads fresh - self.image_editor.clear() - - # Refresh the view - need to refresh image list since backup file was created - original_path = Path(filepath) - self.refresh_image_list() - - # Find the edited image (not the backup) in the refreshed list - for i, img_file in enumerate(self.image_files): - if img_file.path == original_path: - self.current_index = i - break - - # Invalidate cache for the edited image so it's reloaded from disk - # This ensures the Image Editor will see the updated version - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - - self.update_status_message("Auto white balance applied and saved") - log.info("Quick auto white balance applied to %s", filepath) - else: - self.update_status_message("Failed to save image") - - @Slot() - def auto_white_balance(self): - """ - Dispatcher for auto white balance. Calls the appropriate method based on - the mode set in the config ('lab' or 'rgb'). - """ - mode = config.get('awb', 'mode', fallback='lab') - if mode == 'lab': - self.auto_white_balance_lab() - elif mode == 'rgb': - self.auto_white_balance_legacy() - else: - log.error(f"Unknown AWB mode: {mode}") - self.update_status_message(f"Error: Unknown AWB mode '{mode}'") - - def auto_white_balance_legacy(self): - """ - Calculates and applies auto white balance using the legacy grey world - assumption on the entire RGB image. - """ - if not self.image_editor.original_image: - log.warning("No image loaded in editor for auto white balance") - return - - try: - import numpy as np - except ImportError: - log.error("NumPy not found. Please install with: pip install numpy") - self.update_status_message("Error: NumPy not installed") - return - - log.info("Applying legacy (RGB Grey World) Auto White Balance") - - img = self.image_editor.original_image - arr = np.array(img, dtype=np.float32) - - r_mean = arr[:, :, 0].mean() - g_mean = arr[:, :, 1].mean() - b_mean = arr[:, :, 2].mean() - - grey_target = (r_mean + g_mean + b_mean) / 3.0 - - r_diff = r_mean - grey_target - g_diff = g_mean - grey_target - - by_shift = -(r_diff + g_diff) / 2.0 - mg_shift = -(r_diff - g_diff) / 2.0 - - by_value = by_shift / 63.75 - mg_value = mg_shift / 63.75 - - by_value = float(np.clip(by_value, -1.0, 1.0)) - mg_value = float(np.clip(mg_value, -1.0, 1.0)) - - self.image_editor.set_edit_param('white_balance_by', by_value) - self.image_editor.set_edit_param('white_balance_mg', mg_value) - - self.ui_state.white_balance_by = by_value - self.ui_state.white_balance_mg = mg_value - - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.update_status_message("Auto white balance applied (Legacy)") - - - def auto_white_balance_lab(self): - """ - Calculates and applies auto white balance using the Lab color space, - filtering out clipped and saturated pixels for a more robust result. - """ - if not self.image_editor.original_image: - log.warning("No image loaded in editor for auto white balance") - return - - try: - import cv2 - import numpy as np - except ImportError: - log.error("OpenCV or NumPy not found. Please install with: pip install opencv-python numpy") - self.update_status_message("Error: OpenCV or NumPy not installed") - return - - img = self.image_editor.original_image - # Ensure image is RGB before processing - if img.mode != 'RGB': - img = img.convert('RGB') - - arr = np.array(img, dtype=np.uint8) - - # --- Tunable Constants for Auto White Balance (from config) --- - _LOWER_BOUND_RGB = config.getint('awb', 'rgb_lower_bound', 5) - _UPPER_BOUND_RGB = config.getint('awb', 'rgb_upper_bound', 250) - _LUMA_LOWER_BOUND = config.getint('awb', 'luma_lower_bound', 30) - _LUMA_UPPER_BOUND = config.getint('awb', 'luma_upper_bound', 220) - warm_bias = config.getint('awb', 'warm_bias', 6) - tint_bias = config.getint('awb', 'tint_bias', 0) - _TARGET_A_LAB = 128.0 + tint_bias - _TARGET_B_LAB = 128.0 + warm_bias - _SCALING_FACTOR_LAB_TO_SLIDER = 128.0 - _CORRECTION_STRENGTH = config.getfloat('awb', 'strength', 0.7) - - # --- 1. Reject clipped channels and use a luma midtone mask --- - mask = ( - (arr[:, :, 0] > _LOWER_BOUND_RGB) & (arr[:, :, 0] < _UPPER_BOUND_RGB) & - (arr[:, :, 1] > _LOWER_BOUND_RGB) & (arr[:, :, 1] < _UPPER_BOUND_RGB) & - (arr[:, :, 2] > _LOWER_BOUND_RGB) & (arr[:, :, 2] < _UPPER_BOUND_RGB) - ) - - luma = (0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2]) - mask &= (luma > _LUMA_LOWER_BOUND) & (luma < _LUMA_UPPER_BOUND) - - if not np.any(mask): - log.warning("Auto white balance: No pixels found after clipping and luma filter. Aborting.") - self.update_status_message("AWB failed: no valid pixels found") - return - - # --- 2. Work in Lab color space --- - lab_image = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB) - - a_channel = lab_image[:, :, 1] - b_channel = lab_image[:, :, 2] - - masked_a = a_channel[mask] - masked_b = b_channel[mask] - - a_mean = masked_a.mean() - b_mean = masked_b.mean() - - a_shift = _TARGET_A_LAB - a_mean - b_shift = _TARGET_B_LAB - b_mean - - log.info( - "Auto WB (Lab) - means: a*=%.1f, b*=%.1f; targets: a*=%.1f, b*=%.1f; shifts: a*=%.1f, b*=%.1f", - a_mean, b_mean, _TARGET_A_LAB, _TARGET_B_LAB, a_shift, b_shift - ) - - # --- 3. Convert Lab shift to our slider values with strength factor --- - by_value = (b_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH - mg_value = (a_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH - - by_value = float(np.clip(by_value, -1.0, 1.0)) - mg_value = float(np.clip(mg_value, -1.0, 1.0)) - - log.info(f"Auto white balance values: B/Y={by_value:.3f}, M/G={mg_value:.3f}") - - self.image_editor.set_edit_param('white_balance_by', by_value) - self.image_editor.set_edit_param('white_balance_mg', mg_value) - - self.ui_state.white_balance_by = by_value - self.ui_state.white_balance_mg = mg_value - - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.update_status_message("Auto white balance applied") - - def _get_stack_info(self, index: int) -> str: - info = "" - for i, (start, end) in enumerate(self.stacks): - if start <= index <= end: - count_in_stack = end - start + 1 - pos_in_stack = index - start + 1 - info = f"Stack {i+1} ({pos_in_stack}/{count_in_stack})" - break - if not info and self.stack_start_index is not None and self.stack_start_index == index: - info = "Stack Start Marked" - log.debug("_get_stack_info for index %d: %s", index, info) - return info - - def _get_batch_info(self, index: int) -> str: - """Get batch info for the given index.""" - info = "" - # Check if current image is in any batch - in_batch = False - for start, end in self.batches: - if start <= index <= end: - in_batch = True - break - - if in_batch: - # Calculate total count across all batches - total_count = sum(end - start + 1 for start, end in self.batches) - info = f"{total_count} in Batch" - elif self.batch_start_index is not None and self.batch_start_index == index: - info = "Batch Start Marked" - - log.debug("_get_batch_info for index %d: %s", index, info) - return info - - def get_stack_summary(self) -> str: - if not self.stacks: - return "No stacks defined." - summary = [] - for i, (start, end) in enumerate(self.stacks): - summary.append(f"Stack {i+1}: {start}-{end}") - return "; ".join(summary) - - def is_stacked(self) -> bool: - if not self.image_files or self.current_index >= len(self.image_files): - return False - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - return meta.stacked - - def _update_cache_stats(self): - if self.debug_cache: - hits = self.image_cache.hits - misses = self.image_cache.misses - total = hits + misses - hit_rate = (hits / total * 100) if total > 0 else 0 - size_mb = self.image_cache.currsize / (1024 * 1024) - self.ui_state.cacheStats = f"Cache: {hits} hits, {misses} misses ({hit_rate:.1f}%), {size_mb:.1f} MB" - -def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): - """FastStack Application Entry Point""" - global _debug_mode - _debug_mode = debug - - t0 = time.perf_counter() - setup_logging(debug) - if debug: - log.info("Startup: after setup_logging: %.3fs", time.perf_counter() - t0) - log.info("Starting FastStack") - - os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" - os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") - - app = QApplication(sys.argv) # QApplication is correct for desktop apps with widgets - if debug: - log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) - - if not image_dir: - image_dir_str = config.get('core', 'default_directory') - if not image_dir_str: - log.warning("No image directory provided and no default directory set. Opening directory selection dialog.") - selected_dir = QFileDialog.getExistingDirectory(None, "Select Image Directory") - if not selected_dir: - log.error("No image directory selected. Exiting.") - sys.exit(1) - image_dir_str = selected_dir - image_dir_path = Path(image_dir_str) - else: - image_dir_path = Path(image_dir) - - if not image_dir_path.is_dir(): - log.error("Image directory not found: %s", image_dir_path) - sys.exit(1) - app.setOrganizationName("FastStack") - app.setOrganizationDomain("faststack.dev") - app.setApplicationName("FastStack") - - engine = QQmlApplicationEngine() - engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml")) - engine.addImportPath("qrc:/qt-project.org/imports") - engine.addImportPath(os.path.join(os.path.dirname(__file__), "qml")) - # Add the path to Qt5Compat.GraphicalEffects to QML import paths - engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat")) - - controller = AppController(image_dir_path, engine, debug_cache=debug_cache) - if debug: - log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) - image_provider = ImageProvider(controller) - engine.addImageProvider("provider", image_provider) - - # Expose controller and UI state to QML - context = engine.rootContext() - context.setContextProperty("uiState", controller.ui_state) - context.setContextProperty("controller", controller) - - qml_file = Path(__file__).parent / "qml" / "Main.qml" - engine.load(QUrl.fromLocalFile(str(qml_file))) - if debug: - log.info("Startup: after engine.load(QML): %.3fs", time.perf_counter() - t0) - - if not engine.rootObjects(): - log.error("Failed to load QML.") - sys.exit(-1) - - # Connect key events from the main window - main_window = engine.rootObjects()[0] - controller.main_window = main_window - main_window.installEventFilter(controller) - - # Load data and start services - controller.load() - if debug: - log.info("Startup: after controller.load(): %.3fs", time.perf_counter() - t0) - - # Graceful shutdown - app.aboutToQuit.connect(controller.shutdown) - - sys.exit(app.exec()) - -def cli(): - """CLI entry point.""" - parser = argparse.ArgumentParser(description="FastStack - Ultra-fast JPG Viewer for Focus Stacking Selection") - parser.add_argument("image_dir", nargs="?", default="", help="Directory of images to view") - parser.add_argument("--debug", action="store_true", help="Enable debug logging and timing information") - parser.add_argument("--debugcache", action="store_true", help="Enable debug cache features") - args = parser.parse_args() - main(image_dir=args.image_dir, debug=args.debug, debug_cache=args.debugcache) - -if __name__ == "__main__": - cli() +"""Main application entry point for FastStack.""" + +import logging +import sys +import math +import struct +import shlex +import time +import argparse +from pathlib import Path +from typing import Optional, List, Dict, Any, Tuple +from datetime import date +import os +import shutil +import uuid +import functools +# Must set before importing PySide6 +os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" + +# Type Aliases for readability +DeletePair = Tuple[Optional[Path], Optional[Path]] # (src_path, recycle_bin_path) +DeleteRecord = Tuple[DeletePair, DeletePair] # (jpg_pair, raw_pair) + +import concurrent.futures +import threading +import subprocess +from faststack.ui.provider import ImageProvider, UIState +import PySide6 +from PySide6.QtGui import QDrag, QPixmap +from PySide6.QtCore import ( + QUrl, + QTimer, + QObject, + QEvent, + Signal, + Slot, + QMimeData, + Qt, + QPoint, + QCoreApplication +) +from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox +from PySide6.QtQml import QQmlApplicationEngine +from PIL import Image +Image.MAX_IMAGE_PIXELS = 200_000_000 # 200 megapixels, enough for most photos +# ⬇️ these are the ones that went missing +from faststack.config import config +from faststack.logging_setup import setup_logging +from faststack.models import ImageFile, DecodedImage, EntryMetadata +from faststack.io.indexer import find_images +from faststack.io.sidecar import SidecarManager +from faststack.io.watcher import Watcher +from faststack.io.helicon import launch_helicon_focus +from faststack.io.executable_validator import validate_executable_path +from faststack.imaging.cache import ByteLRUCache, get_decoded_image_size, build_cache_key +from faststack.imaging.prefetch import Prefetcher, clear_icc_caches +from faststack.ui.provider import ImageProvider +from faststack.ui.keystrokes import Keybinder +from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS, create_backup_file +from faststack.imaging.metadata import get_exif_data +import re +import numpy as np +from faststack.io.indexer import RAW_EXTENSIONS + +def make_hdrop(paths): + """ + Build a real CF_HDROP (DROPFILES) payload for Windows drag-and-drop. + paths: list[str] + """ + files_part = ("\0".join(paths) + "\0\0").encode("utf-16le") + + # DROPFILES header (20 bytes): bool: + """Checks if a working TIFF path is valid for editing.""" + try: + return path.exists() and path.stat().st_size > 0 + except OSError: + return False + + def get_active_edit_path(self, index: int) -> Path: + """ + Determines the correct file path to use for editing/exporting based on current mode. + + Rules: + 1. If index invalid, raise IndexError or return None (caller handles). + 2. If image is RAW-only (no paired JPEG and path is RAW ext), force "raw" mode functionality. + (Note: ImageFile.path is usually the JPEG if it exists. If it's a RAW file, it means orphaned RAW). + 3. If mode is "jpeg": return jpg_path (visual/original). + 4. If mode is "raw": + - Check for valid developed TIFF. If yes, return it. + - If no TIFF, return the RAW path itself (RawTherapee will need to develop it, + or we load it if we support direct RAW - here we likely return raw_path so + load_image_for_editing can decide to develop it). + """ + if index < 0 or index >= len(self.image_files): + raise IndexError("Invalid image index") + + img = self.image_files[index] + + # Check if we are strictly RAW-only (orphaned RAW or just RAW opened) + # ImageFile.path is the main file. ImageFile.raw_pair is the sidecar RAW. + # If raw_pair is None but path is a RAW extension, it's RAW-only. + is_raw_only = False + from faststack.io.indexer import RAW_EXTENSIONS + if img.raw_pair is None and img.path.suffix.lower() in RAW_EXTENSIONS: + is_raw_only = True + + mode = self.current_edit_source_mode + if is_raw_only: + mode = "raw" + + if mode == "jpeg": + return img.path + + # Mode is RAW + if img.has_working_tif and self.is_valid_working_tif(img.working_tif_path): + return img.working_tif_path + + if img.raw_pair: + return img.raw_pair + + # Fallback for RAW-only case where path is the RAW + return img.path + + @Slot(str) + def apply_filter(self, filter_string: str): + filter_string = filter_string.strip() + + if not filter_string: + self.clear_filter() + return + + self._filter_string = filter_string + self._filter_enabled = True + self._apply_filter_to_cached_list() # Fast in-memory filtering + self.dataChanged.emit() + self.ui_state.filterStringChanged.emit() # Notify UI of filter change + + # reset to start of filtered list + self.current_index = 0 + self.sync_ui_state() + self._do_prefetch(self.current_index) + + @Slot(result=str) + def get_filter_string(self): + # return current string, or "" if filter off + return self._filter_string + + @Slot() + def clear_filter(self): + if not self._filter_enabled and not self._filter_string: + return + self._filter_enabled = False + self._filter_string = "" + self._apply_filter_to_cached_list() # Fast in-memory filtering + self.dataChanged.emit() + self.ui_state.filterStringChanged.emit() # Notify UI of filter change + self.current_index = min(self.current_index, max(0, len(self.image_files) - 1)) + self.sync_ui_state() + self._do_prefetch(self.current_index) + + + + def get_display_info(self): + if self.is_zoomed: + return 0, 0, self.display_generation + + return self.display_width, self.display_height, self.display_generation + + def on_display_size_changed(self, width: int, height: int): + """Debounces display size change events to prevent spamming resizes.""" + log.debug(f"on_display_size_changed called with {width}x{height}. Current: {self.display_width}x{self.display_height}") + if width <= 0 or height <= 0: + log.debug("Ignoring invalid resize event") + return + + # Debounce resize events + self.pending_width = width + self.pending_height = height + self.resize_timer.start(150) # 150ms debounce + + def _handle_resize(self): + """Actual resize handler, called after debounce period.""" + log.info("Display size changed to: %dx%d (physical pixels)", self.pending_width, self.pending_height) + self.display_width = self.pending_width + self.display_height = self.pending_height + self.display_generation += 1 # Invalidates old entries via cache key + + # Mark display as ready after first size report + is_first_resize = not self.display_ready + if is_first_resize: + self.display_ready = True + log.info("Display size now stable, enabling prefetch") + + self.prefetcher.cancel_all() # Cancel stale tasks to avoid wasted work + + # On first resize, execute deferred prefetch; on subsequent resizes, do normal prefetch + if is_first_resize and self.pending_prefetch_index is not None: + self.prefetcher.update_prefetch(self.pending_prefetch_index) + self.pending_prefetch_index = None + else: + self.prefetcher.update_prefetch(self.current_index) + + self.sync_ui_state() # To refresh the image + + @Slot(bool) + def set_zoomed(self, zoomed: bool): + if self.is_zoomed != zoomed: + if _debug_mode: + log.info(f"AppController.set_zoomed: {self.is_zoomed} -> {zoomed}") + self.is_zoomed = zoomed + self.is_zoomed_changed.emit(zoomed) + log.info("Zoom state changed to: %s", zoomed) + self.display_generation += 1 # Invalidates old entries via cache key + + # Invalidate current image to force reload with new resolution logic + if self.image_files and self.main_window: + # Force QML to reload the image by updating the URL generation + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.main_window.update() # Force repaint + + # -- Zoom Shortcuts -- + def zoom_100(self): + log.info("Zoom 100% requested") + self.ui_state.request_absolute_zoom(1.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic + + def zoom_200(self): + log.info("Zoom 200% requested") + self.ui_state.request_absolute_zoom(2.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic + + def zoom_300(self): + log.info("Zoom 300% requested") + self.ui_state.request_absolute_zoom(3.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic + + def zoom_400(self): + log.info("Zoom 400% requested") + self.ui_state.request_absolute_zoom(4.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic + # NOTE: We don't clear the cache here. The generation increment is enough. + # Cache keys include display_generation, so zoomed/unzoomed images become + # naturally unreachable and LRU will evict them. This lets us instantly + # reuse cached images if user toggles zoom on/off repeatedly. + self.prefetcher.cancel_all() # Cancel stale tasks to avoid wasted work + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + self.ui_state.isZoomedChanged.emit() + + def eventFilter(self, watched, event) -> bool: + # Don't handle key events when a dialog is open + if self._dialog_open: + return False + + if watched == self.main_window and event.type() == QEvent.Type.KeyPress: + # QML handles Crop Enter/Esc keys now. + # We defer to QML to avoid double-triggering or focus conflicts. + # handled = self.keybinder.handle_key_press(event) ... + + # When cropping (or editing), let QML handle Enter/Esc and related keys. + # Otherwise keybinder can swallow them before QML sees them. + if getattr(self.ui_state, "isCropping", False) or getattr(self.ui_state, "isEditorOpen", False): + return False + + handled = self.keybinder.handle_key_press(event) + if handled: + return True + return super().eventFilter(watched, event) + + def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optional[int] = None): + """Helper to defer prefetch until display size is stable. + + Args: + index: The index to prefetch around + is_navigation: True if called from user navigation (arrow keys, etc.) + direction: 1 for forward, -1 for backward, None to use last direction + """ + # If navigation occurs during resize debounce, cancel timer and apply resize immediately + # to ensure prefetch uses correct dimensions + if is_navigation and self.resize_timer.isActive(): + self.resize_timer.stop() + self._handle_resize() + + if not self.display_ready: + log.debug("Display not ready, deferring prefetch for index %d", index) + self.pending_prefetch_index = index + return + self.prefetcher.update_prefetch(index, is_navigation=is_navigation, direction=direction) + + def load(self): + """Loads images, sidecar data, and starts services.""" + self.refresh_image_list() # Initial scan from disk + if not self.image_files: + self.current_index = 0 + else: + self.current_index = max(0, min(self.sidecar.data.last_index, len(self.image_files) - 1)) + self.stacks = self.sidecar.data.stacks # Load stacks from sidecar + self.dataChanged.emit() # Emit after stacks are loaded + self.watcher.start() + self._do_prefetch(self.current_index) + + # Defer initial UI sync until after images are loaded + self.sync_ui_state() + + + def refresh_image_list(self): + """Rescans the directory for images from disk and updates cache. + + This does a full disk scan and should only be called when: + - Application starts (load()) + - Directory watcher detects file changes + - User explicitly refreshes + + For filtering, use _apply_filter_to_cached_list() instead. + """ + self._all_images = find_images(self.image_dir) + self._apply_filter_to_cached_list() + + def _apply_filter_to_cached_list(self): + """Applies current filter to cached image list without disk I/O.""" + if self._filter_enabled and self._filter_string: + needle = self._filter_string.lower() + self.image_files = [ + img for img in self._all_images + if needle in img.path.stem.lower() + ] + else: + self.image_files = self._all_images + + self.prefetcher.set_image_files(self.image_files) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.ui_state.imageCountChanged.emit() + + def get_decoded_image(self, index: int) -> Optional[DecodedImage]: + """Retrieves a decoded image, blocking until ready to ensure correct display. + + This blocks the UI thread on cache miss, but that's acceptable for an image viewer + where users expect to see the correct image immediately. The prefetcher minimizes + cache misses by decoding adjacent images in advance. + """ + if not self.image_files or index < 0 or index >= len(self.image_files): + log.warning("get_decoded_image called with empty image_files or out of bounds index.") + return None + + # Debug preview condition + if self.ui_state.isEditorOpen or self.ui_state.isCropping: + # Robust path comparison + editor_path = self.image_editor.current_filepath + file_path = self.image_files[index].path + + match = False + if editor_path and file_path: + try: + match = Path(editor_path).resolve() == Path(file_path).resolve() + except (OSError, ValueError): + match = str(editor_path) == str(file_path) + + if not match: + # Debug log if mismatch + log.debug("Path mismatch in preview. Editor: %s, File: %s", editor_path, file_path) + + # Return background-rendered preview if Editor is open OR Cropping is active + if match and self.image_editor.original_image: + if self._last_rendered_preview: + return self._last_rendered_preview + + _, _, display_gen = self.get_display_info() + image_path = self.image_files[index].path + path_str = image_path.as_posix() + cache_key = build_cache_key(image_path, display_gen) + + # Check cache + if cache_key in self.image_cache: + self.image_cache.hits += 1 # Increment hit counter + self._update_cache_stats() # Update UI with new stats + decoded = self.image_cache[cache_key] + with self._last_image_lock: + self.last_displayed_image = decoded + return decoded + + self.image_cache.misses += 1 # Increment miss counter + self._update_cache_stats() # Update UI with new stats + if self.debug_cache: + prefix = f"{path_str}::" + cached_gens = [ + key.split("::", 1)[1] + for key in self.image_cache.keys() + if key.startswith(prefix) + ] + cache_usage_gb = self.image_cache.currsize / (1024**3) + log.info( + "Cache miss for %s (index=%d gen=%d). Cached gens: %s. Cache usage=%.2fGB entries=%d", + image_path.name, + index, + display_gen, + cached_gens or "none", + cache_usage_gb, + len(self.image_cache), + ) + + # Cache miss: need to decode synchronously to ensure correct image displays + if _debug_mode: + decode_start = time.perf_counter() + log.info("Cache miss for index %d (gen: %d). Blocking decode.", index, display_gen) + + # Show decoding indicator if debug cache is enabled + if self.debug_cache: + self.ui_state.isDecoding = True + # Note: processEvents() caused crashes, so the indicator might not update immediately + # QCoreApplication.processEvents() + + try: + # Submit with priority=True to cancel pending prefetch tasks and free up workers + future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) + if not future: + with self._last_image_lock: + return self.last_displayed_image + + try: + # Wait for decode to complete (blocking but fast for JPEGs) + result = future.result(timeout=5.0) # 5 second timeout as safety + except concurrent.futures.TimeoutError: + log.warning("Timeout decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except concurrent.futures.CancelledError: + log.debug("Decode cancelled for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except Exception: + log.exception("Error decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + + if not result: + if _debug_mode: + log.debug("Decode returned no result for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + + decoded_path, decoded_display_gen = result + cache_key = build_cache_key(decoded_path, decoded_display_gen) + if cache_key in self.image_cache: + decoded = self.image_cache[cache_key] + with self._last_image_lock: + self.last_displayed_image = decoded + if _debug_mode: + elapsed = time.perf_counter() - decode_start + log.info("Decoded image %d in %.3fs", index, elapsed) + return decoded + else: + if _debug_mode: + log.debug("Decode finished but cache_key missing (index=%d, key=%s)", index, cache_key) + with self._last_image_lock: + return self.last_displayed_image + finally: + # Hide decoding indicator + if self.debug_cache: + self.ui_state.isDecoding = False + + with self._last_image_lock: + return self.last_displayed_image + + def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: + """Thread-safe version of get_decoded_image for background workers. + + Does NOT update UI iteration or access QObjects. + """ + if not self.image_files or index < 0 or index >= len(self.image_files): + return None + + # Lock to ensure thread safety when reading shared state if necessary (though simple reads are usually safe) + # However, get_display_info reads 'self.is_zoomed' which is fine. + # Accessing self.image_files is safe as long as list isn't cleared concurrently, + # which only happens on directory change/refresh on main thread. + # Since we are in a worker, there's a small race risk if directory changes *while* we run, + # but the worker would likely just fail gracefully or get an old image. + + _, _, display_gen = self.get_display_info() + try: + image_path = self.image_files[index].path + except IndexError: + return None + + cache_key = build_cache_key(image_path, display_gen) + + # Check cache (thread-safe read) + if cache_key in self.image_cache: + # We don't update stats/hits here to avoid race conditions on those counters + return self.image_cache[cache_key] + + # Cache miss: decode synchronously (in this worker thread) + try: + # Submit with priority=True + # Note: prefetcher.submit_task logic needs to be thread-safe. + # Assuming futures dict access in submit_task handles strict GIL/thread safety or we might need locks there. + # But usually submitting to Executor is thread safe. + # The danger is 'self.futures' management in Prefetcher. + future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) + if future: + try: + result = future.result(timeout=5.0) + except concurrent.futures.TimeoutError: + log.warning(f"Timeout decoding image at index {index} (background)") + return None + except concurrent.futures.CancelledError: + log.debug(f"Decode cancelled for image at index {index} (background)") + return None + + if result: + decoded_path, decoded_display_gen = result + # Re-verify key + cache_key = build_cache_key(decoded_path, decoded_display_gen) + if cache_key in self.image_cache: + return self.image_cache[cache_key] + except Exception: + log.exception("_get_decoded_image_safe failed for index %d", index) + + return None + + def sync_ui_state(self): + """Forces the UI to update by emitting all state change signals.""" + self.ui_refresh_generation += 1 + self._metadata_cache_index = (-1, -1) # Invalidate cache + + # tell QML that index and image changed + self.ui_state.currentIndexChanged.emit() + self.ui_state.currentImageSourceChanged.emit() + self.ui_state.highlightStateChanged.emit() # Notify UI of new highlight stats + + # this is the one your footer needs + self.ui_state.metadataChanged.emit() + + log.debug( + "UI State Synced: Index=%d, Count=%d", + self.ui_state.currentIndex, + self.ui_state.imageCount + ) + log.debug( + "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", + self.ui_state.currentFilename, + self.ui_state.isUploaded, + self.ui_state.stackInfoText, + self.ui_state.batchInfoText + ) + + + + # --- Image Editor Integration --- + + + + + @Slot() + def save_edited_image(self): + """Saves functionality delegating to ImageEditor. + + Restores "Old" behavior: + - Save image + - Close Editor + - Clear Editor State + - Refresh List + - Re-select saved image + """ + if not self.image_editor.original_image: + return + + # Only write developed sidecar when editing from RAW source + write_sidecar = self.current_edit_source_mode == "raw" + dev_path = None + if write_sidecar and 0 <= self.current_index < len(self.image_files): + dev_path = self.image_files[self.current_index].developed_jpg_path + + try: + result = self.image_editor.save_image(write_developed_jpg=write_sidecar, developed_path=dev_path) + except RuntimeError as e: + self.update_status_message(str(e)) + return + except Exception as e: + log.exception(f"Unexpected error during save: {e}") + self.update_status_message("Failed to save image") + return + + if result: + saved_path, _ = result # backup_path unused + + # --- Restore Old Behavior --- + + # 1. Close Editor UI + self.ui_state.isEditorOpen = False + + # 2. Clear Editor State (release memory) + self.image_editor.clear() + + # 3. Refresh List (to see new file or updated timestamp) + self.refresh_image_list() + + # 4. Find and Select the saved image + new_index = self.current_index # Default to keeping selection if not found + + # Try to find by exact path match + if saved_path: + try: + target_resolve = saved_path.resolve() + for i, img in enumerate(self.image_files): + try: + # Robust path comparison + if img.path.resolve() == target_resolve: + new_index = i + break + except (OSError, RuntimeError): + # Fallback to string compare + if str(img.path) == str(saved_path): + new_index = i + break + except (OSError, RuntimeError): + pass # Keep current selection if resolution fails + + self.current_index = new_index + + # 5. Force UI Sync / Prefetch + self.image_cache.clear() # Clear cache to ensure we reload valid image + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message(f"Image saved") + else: + self.update_status_message("Failed to save image") + + + + + + # --- Actions --- + + def _set_current_index(self, index: int, direction: int = 0, is_navigation: bool = True): + """Centralized method to change current image index and reset state.""" + if index < 0 or index >= len(self.image_files): + return + + # Reset source mode to JPEG unless new image is strictly RAW-only + # (This implements the "Default state on navigation" requirement) + img = self.image_files[index] + is_raw_only = False + from faststack.io.indexer import RAW_EXTENSIONS, JPG_EXTENSIONS + # Robust RAW-only check: Main path is RAW and it's not a JPEG + is_jpeg_main = img.path.suffix.lower() in JPG_EXTENSIONS + is_raw_main = img.path.suffix.lower() in RAW_EXTENSIONS + is_raw_only = is_raw_main and not is_jpeg_main + + new_mode = "raw" if is_raw_only else "jpeg" + if self.current_edit_source_mode != new_mode: + self.current_edit_source_mode = new_mode + self.editSourceModeChanged.emit(new_mode) + + self.current_index = index # Set index first so signals pick up correct image + + self._reset_crop_settings() + self._do_prefetch(self.current_index, is_navigation=is_navigation, direction=direction) + self.sync_ui_state() + + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + + def next_image(self): + if self.current_index < len(self.image_files) - 1: + self._set_current_index(self.current_index + 1, direction=1) + + def prev_image(self): + if self.current_index > 0: + self._set_current_index(self.current_index - 1, direction=-1) + + @Slot(int) + def jump_to_image(self, index: int): + """Jump to a specific image by index (0-based).""" + if 0 <= index < len(self.image_files): + if index == self.current_index: + self.update_status_message(f"Already at image {index + 1}") + return + direction = 1 if index > self.current_index else -1 + self._set_current_index(index, direction=direction) + self.update_status_message(f"Jumped to image {index + 1}") + else: + log.warning("Invalid image index: %d", index) + self.update_status_message("Invalid image number") + + def show_jump_to_image_dialog(self): + """Shows the jump to image dialog (called from keybinder).""" + if self.main_window and hasattr(self.main_window, 'show_jump_to_image_dialog'): + self.main_window.show_jump_to_image_dialog() + else: + log.warning("Cannot open jump to image dialog: main_window or function not available") + + def show_exif_dialog(self): + """Shows the EXIF data dialog.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + path = self.image_files[self.current_index].path + data = get_exif_data(path) + + if self.main_window and hasattr(self.main_window, 'openExifDialog'): + # Pass data as QVariantMap (dict) + self.main_window.openExifDialog(data) + else: + log.warning("Cannot open EXIF dialog: main_window or openExifDialog not available") + + @Slot() + def dialog_opened(self): + """Called when any dialog opens to disable global keybindings.""" + self._dialog_open_count += 1 + if self._dialog_open_count == 1: + self._dialog_open = True + self.dialogStateChanged.emit(True) + log.debug("Dialog opened (count=1), disabling global keybindings") + + @Slot() + def dialog_closed(self): + """Called when any dialog closes to re-enable global keybindings.""" + prev = self._dialog_open_count + self._dialog_open_count = max(0, self._dialog_open_count - 1) + if prev > 0 and self._dialog_open_count == 0: + self._dialog_open = False + self.dialogStateChanged.emit(False) + log.debug("Dialog closed (count=0), re-enabling global keybindings") + + def toggle_grid_view(self): + log.warning("Grid view not implemented yet.") + + def toggle_uploaded(self): + """Toggle uploaded flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.uploaded = not meta.uploaded + if meta.uploaded: + meta.uploaded_date = today + else: + meta.uploaded_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "uploaded" if meta.uploaded else "not uploaded" + self.update_status_message(f"Marked as {status}") + log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) + + def toggle_edited(self): + """Toggle edited flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.edited = not meta.edited + if meta.edited: + meta.edited_date = today + else: + meta.edited_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "edited" if meta.edited else "not edited" + self.update_status_message(f"Marked as {status}") + log.info("Toggled edited flag to %s for %s", meta.edited, stem) + + def toggle_restacked(self): + """Toggle restacked flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.restacked = not meta.restacked + if meta.restacked: + meta.restacked_date = today + else: + meta.restacked_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "restacked" if meta.restacked else "not restacked" + self.update_status_message(f"Marked as {status}") + log.info("Toggled restacked flag to %s for %s", meta.restacked, stem) + + def toggle_stacked(self): + """Toggle stacked flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.stacked = not meta.stacked + if meta.stacked: + meta.stacked_date = today + else: + meta.stacked_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "stacked" if meta.stacked else "not stacked" + self.update_status_message(f"Marked as {status}") + log.info("Toggled stacked flag to %s for %s", meta.stacked, stem) + + def get_current_metadata(self) -> Dict: + if not self.image_files or self.current_index >= len(self.image_files): + if not self._logged_empty_metadata: + log.debug("get_current_metadata: image_files is empty or index out of bounds, returning {}.") + self._logged_empty_metadata = True + return {} + self._logged_empty_metadata = False + + # Cache hit check + cache_key = (self.current_index, self.ui_refresh_generation) + if cache_key == self._metadata_cache_index: + return self._metadata_cache + + # Compute and cache + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + stack_info = self._get_stack_info(self.current_index) + batch_info = self._get_batch_info(self.current_index) + + self._metadata_cache = { + "filename": self.image_files[self.current_index].path.name, + "stacked": meta.stacked, + "stacked_date": meta.stacked_date or "", + "uploaded": meta.uploaded, + "uploaded_date": meta.uploaded_date or "", + "edited": meta.edited, + "edited_date": meta.edited_date or "", + "restacked": meta.restacked, + "restacked_date": meta.restacked_date or "", + "stack_info_text": stack_info, + "batch_info_text": batch_info + } + self._metadata_cache_index = cache_key + return self._metadata_cache + + def begin_new_stack(self): + self.stack_start_index = self.current_index + log.info("Stack start marked at index %d", self.stack_start_index) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Update UI to show start marker + self.sync_ui_state() + + def end_current_stack(self): + log.info("end_current_stack called. stack_start_index: %s", self.stack_start_index) + if self.stack_start_index is not None: + start = min(self.stack_start_index, self.current_index) + end = max(self.stack_start_index, self.current_index) + self.stacks.append([start, end]) + self.stacks.sort() # Keep stacks sorted by start index + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + log.info("Defined new stack: [%d, %d]", start, end) + self.stack_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Notify QML of data change + self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog + self.sync_ui_state() + else: + log.warning("No stack start marked. Press '[' first.") + + def begin_new_batch(self): + """Mark the start of a new batch for drag-and-drop.""" + self.batch_start_index = self.current_index + log.info("Batch start marked at index %d", self.batch_start_index) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("Batch start marked") + + def end_current_batch(self): + """End the current batch and save the range.""" + log.info("end_current_batch called. batch_start_index: %s", self.batch_start_index) + if self.batch_start_index is not None: + start = min(self.batch_start_index, self.current_index) + end = max(self.batch_start_index, self.current_index) + self.batches.append([start, end]) + self.batches.sort() # Keep batches sorted by start index + log.info("Defined new batch: [%d, %d]", start, end) + self.batch_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + count = end - start + 1 + self.update_status_message(f"Batch defined: {count} images") + else: + log.warning("No batch start marked. Press '{' first.") + self.update_status_message("No batch start marked") + + + def remove_from_batch_or_stack(self): + """Remove current image from any batch or stack it's in.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + removed = False + + # Check and remove from batches + new_batches = [] + batch_modified = False + for start, end in self.batches: + if not batch_modified and start <= self.current_index <= end: + # This is the batch to modify. + + # Single image batch - remove entirely by not adding anything. + if start == end: + pass + # Remove from beginning - shift start forward + elif self.current_index == start: + new_batches.append([start + 1, end]) + # Remove from end - shift end backward + elif self.current_index == end: + new_batches.append([start, end - 1]) + # Remove from middle - split into two ranges + else: + new_batches.append([start, self.current_index - 1]) + new_batches.append([self.current_index + 1, end]) + + log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from batch") + removed = True + batch_modified = True + else: + new_batches.append([start, end]) + + if batch_modified: + self.batches = new_batches + + # Check and remove from stacks + # Check and remove from stacks + if not removed: + new_stacks = [] + stack_modified = False + for start, end in self.stacks: + if not stack_modified and start <= self.current_index <= end: + # This is the stack to modify. + + # Single image stack - remove entirely. + if start == end: + pass + # Remove from beginning + elif self.current_index == start: + new_stacks.append([start + 1, end]) + # Remove from end + elif self.current_index == end: + new_stacks.append([start, end - 1]) + # Remove from middle + else: + new_stacks.append([start, self.current_index - 1]) + new_stacks.append([self.current_index + 1, end]) + + log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from stack") + removed = True + stack_modified = True + else: + new_stacks.append([start, end]) + + if stack_modified: + self.stacks = new_stacks + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + if removed: + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + else: + self.update_status_message("Not in any batch or stack") + + def toggle_batch_membership(self): + """Toggles the current image's inclusion in a batch.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + index_to_toggle = self.current_index + + # Check if the image is already in a batch + in_batch = False + for start, end in self.batches: + if start <= index_to_toggle <= end: + in_batch = True + break + + new_batches = [] + if in_batch: + # Remove from batch + item_removed = False + for start, end in self.batches: + if not item_removed and start <= index_to_toggle <= end: + if start < index_to_toggle: + new_batches.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_batches.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_batches.append([start, end]) + self.batches = new_batches + self.update_status_message("Removed image from batch") + log.info("Removed index %d from a batch.", index_to_toggle) + else: + # Add to batch - merge with adjacent batches if possible + if not self.batches: + self.batches.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new batch with current image.") + log.info("No existing batches. Created new batch for index %d.", index_to_toggle) + else: + # Check if adjacent to any existing batch + merged = False + for i, (start, end) in enumerate(self.batches): + # Adjacent to start of batch + if index_to_toggle == start - 1: + self.batches[i] = [index_to_toggle, end] + merged = True + break + # Adjacent to end of batch + elif index_to_toggle == end + 1: + self.batches[i] = [start, index_to_toggle] + merged = True + break + + if not merged: + # Not adjacent to any batch, create new one + self.batches.append([index_to_toggle, index_to_toggle]) + + # Sort and merge any overlapping batches + self.batches.sort() + merged_batches = [self.batches[0]] if self.batches else [] + for i in range(1, len(self.batches)): + last_start, last_end = merged_batches[-1] + current_start, current_end = self.batches[i] + if current_start <= last_end + 1: + merged_batches[-1] = [last_start, max(last_end, current_end)] + else: + merged_batches.append([current_start, current_end]) + self.batches = merged_batches + + self.update_status_message("Added image to batch") + log.info("Added index %d to batch.", index_to_toggle) + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + def toggle_stack_membership(self): + """Toggles the current image's inclusion in a stack.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + index_to_toggle = self.current_index + + # Check if the image is already in a stack + stack_to_modify_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + stack_to_modify_idx = i + break + + if stack_to_modify_idx != -1: + # --- Remove from existing stack --- + new_stacks = [] + item_removed = False + for i, (start, end) in enumerate(self.stacks): + if not item_removed and i == stack_to_modify_idx: + if start < index_to_toggle: + new_stacks.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_stacks.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_stacks.append([start, end]) + self.stacks = new_stacks + self.update_status_message("Removed image from stack") + log.info("Removed index %d from stack #%d.", index_to_toggle, stack_to_modify_idx + 1) + + else: + # --- Add to nearest stack --- + if not self.stacks: + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No existing stacks. Created new stack for index %d.", index_to_toggle) + else: + # Find closest stack + dist_backward = float('inf') + stack_idx_backward = -1 + for i in range(index_to_toggle - 1, -1, -1): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_backward = index_to_toggle - i + stack_idx_backward = j + break + if stack_idx_backward != -1: + break + + dist_forward = float('inf') + stack_idx_forward = -1 + for i in range(index_to_toggle + 1, len(self.image_files)): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_forward = i - index_to_toggle + stack_idx_forward = j + break + if stack_idx_forward != -1: + break + + if stack_idx_backward == -1 and stack_idx_forward == -1: + # This case should not be reached if `if not self.stacks` handles it. + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No stacks found nearby. Created new stack for index %d.", index_to_toggle) + else: + if dist_backward <= dist_forward: + stack_to_join_idx = stack_idx_backward + else: + stack_to_join_idx = stack_idx_forward + + start, end = self.stacks[stack_to_join_idx] + self.stacks[stack_to_join_idx] = [min(start, index_to_toggle), max(end, index_to_toggle)] + + # Merge overlapping stacks + self.stacks.sort() + merged_stacks = [self.stacks[0]] if self.stacks else [] + for i in range(1, len(self.stacks)): + last_start, last_end = merged_stacks[-1] + current_start, current_end = self.stacks[i] + if current_start <= last_end + 1: + merged_stacks[-1] = [last_start, max(last_end, current_end)] + else: + merged_stacks.append([current_start, current_end]) + self.stacks = merged_stacks + + # Find the new stack index for the status message + new_stack_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + new_stack_idx = i + break + + self.update_status_message(f"Added image to Stack #{new_stack_idx + 1}") + log.info("Added index %d to stack #%d.", index_to_toggle, new_stack_idx + 1) + + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + + def _reset_crop_settings(self): + """Resets crop settings to default (full image) and exits crop mode, and resets rotation.""" + if self.ui_state.isCropping: + self.ui_state.isCropping = False + self.update_status_message("Crop mode exited") + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + # Also clear any editor-side crop box in case it's not fully synced yet + self.image_editor.set_crop_box((0, 0, 1000, 1000)) + # Reset rotation and straighten angle + self.image_editor.set_edit_param('rotation', 0) + self.image_editor.set_edit_param('straighten_angle', 0.0) + # Also update UI state for rotation values if they are exposed + if hasattr(self.ui_state, 'rotation'): + self.ui_state.rotation = 0 + if hasattr(self.ui_state, 'cropRotation'): # This is used by Components.qml for the overlay + self.ui_state.cropRotation = 0.0 + + # Also reset the straighten angle in current_edits since it affects rotation logic + if 'straighten_angle' in self.image_editor.current_edits: + self.image_editor.current_edits['straighten_angle'] = 0.0 + + def launch_helicon(self): + """Launches Helicon with selected files (RAW preferred, JPG fallback) or stacks.""" + if self.stacks: + log.info("Launching Helicon for %d defined stacks.", len(self.stacks)) + any_success = False + for start, end in self.stacks: + files_to_process = [] + for idx in range(start, end + 1): + if idx < len(self.image_files): + img_file = self.image_files[idx] + # Use RAW if available, otherwise use JPG + file_to_use = img_file.raw_pair if img_file.raw_pair else img_file.path + files_to_process.append(file_to_use) + + if files_to_process: + success = self._launch_helicon_with_files(files_to_process) + if success: + any_success = True + else: + log.warning("No valid files found for stack [%d, %d].", start, end) + + # Only clear stacks if at least one launch succeeded + if any_success: + self.clear_all_stacks() + + else: + log.warning("No selection or stacks defined to launch Helicon Focus.") + return + + self.sync_ui_state() + + def _launch_helicon_with_files(self, files: List[Path]) -> bool: + """Helper to launch Helicon with a specific list of files (RAW or JPG). + + Returns: + True if Helicon was successfully launched, False otherwise. + """ + log.info("Launching Helicon Focus with %d files.", len(files)) + unique_files = sorted(list(set(files))) + success, tmp_path = launch_helicon_focus(unique_files) + if success and tmp_path: + # Schedule delayed deletion of the temporary file + QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) + + # Record stacking metadata + today = date.today().isoformat() + for file_path in unique_files: + # Find the corresponding image file to get the stem + for img_file in self.image_files: + # Match by either RAW pair or JPG path + if img_file.raw_pair == file_path or img_file.path == file_path: + stem = img_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.stacked = True + meta.stacked_date = today + break + self.sidecar.save() + self._metadata_cache_index = (-1, -1) # Invalidate cache + + return success + + def _delete_temp_file(self, tmp_path: Path): + """Deletes the temporary file list passed to Helicon Focus.""" + if tmp_path.exists(): + try: + os.remove(tmp_path) + log.info("Deleted temporary file: %s", tmp_path) + except OSError as e: + log.error("Error deleting temporary file %s: %s", tmp_path, e) + + def clear_all_stacks(self): + log.info("Clearing all defined stacks.") + self.stacks = [] + self.stack_start_index = None + # Do NOT clear batches here + + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + self.update_status_message("All stacks cleared") + + def clear_all_batches(self): + """Clear all defined batches.""" + log.info("Clearing all defined batches.") + self.batches = [] + self.batch_start_index = None + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("All batches cleared") + + def get_helicon_path(self): + return config.get('helicon', 'exe') + + def set_helicon_path(self, path): + config.set('helicon', 'exe', path) + config.save() + + def get_photoshop_path(self): + return config.get('photoshop', 'exe') + + def set_photoshop_path(self, path): + config.set('photoshop', 'exe', path) + config.save() + + def get_rawtherapee_path(self): + return config.get('rawtherapee', 'exe') + + def set_rawtherapee_path(self, path): + config.set('rawtherapee', 'exe', path) + config.save() + + def open_file_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.ExistingFile) + dialog.setNameFilter("Executables (*.exe)") + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + + def check_path_exists(self, path): + return os.path.exists(path) + + def get_cache_size(self): + return config.getfloat('core', 'cache_size_gb') + + def get_cache_usage_gb(self): + """Returns current cache usage in GB.""" + return self.image_cache.currsize / (1024**3) + + def set_cache_size(self, size): + """Update cache size at runtime and persist to config.""" + size = max(0.5, min(size, 16.0)) # enforce sane bounds + config.set('core', 'cache_size_gb', size) + config.save() + + old_max_bytes = self.image_cache.max_bytes + new_max_bytes = int(size * 1024**3) + if old_max_bytes == new_max_bytes: + return + + log.info("Resizing decoded image cache from %.2f GB to %.2f GB", + old_max_bytes / (1024**3), size) + self.image_cache.max_bytes = new_max_bytes + + # If the new size is smaller than current usage, evict until under limit + while self.image_cache.currsize > new_max_bytes and len(self.image_cache) > 0: + try: + self.image_cache.popitem() + except KeyError: + break + + # Allow future warnings after expanding the cache + if new_max_bytes > old_max_bytes: + self._has_warned_cache_full = False + + def get_prefetch_radius(self): + return config.getint('core', 'prefetch_radius') + + def set_prefetch_radius(self, radius): + config.set('core', 'prefetch_radius', radius) + config.save() + self.prefetcher.prefetch_radius = radius + self.prefetcher.update_prefetch(self.current_index) + + def get_theme(self): + return 0 if config.get('core', 'theme') == 'dark' else 1 + + def set_theme(self, theme_index): + # update Python-side state + self.ui_state.theme = theme_index + + # persist it + theme = 'dark' if theme_index == 0 else 'light' + config.set('core', 'theme', theme) + config.save() + + # tell QML it changed (once is enough) + self.ui_state.themeChanged.emit() + + @Slot(result=str) + def get_color_mode(self): + """Returns current color management mode: 'none', 'saturation', or 'icc'.""" + return config.get('color', 'mode', fallback='none') + + @Slot(str) + def set_color_mode(self, mode: str): + """Sets color management mode and clears cache to force re-decode.""" + mode = mode.lower() + if mode not in ['none', 'saturation', 'icc']: + log.error("Invalid color mode: %s", mode) + return + + log.info("Setting color mode to: %s", mode) + config.set('color', 'mode', mode) + config.save() + + # Clear ICC caches when color mode changes + clear_icc_caches() + + # Clear cache and restart prefetcher to apply new color mode + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Notify QML that color mode changed + self.ui_state.colorModeChanged.emit() + + # Update status message + mode_names = { + 'none': 'Original Colors', + 'saturation': 'Saturation Compensation', + 'icc': 'Full ICC Profile' + } + self.update_status_message(f"Color mode: {mode_names.get(mode, mode)}") + + @Slot(result=float) + def get_saturation_factor(self): + """Returns current saturation factor (0.0-1.0).""" + return config.getfloat('color', 'saturation_factor', fallback=0.85) + + @Slot(float) + def set_saturation_factor(self, factor: float): + """Sets saturation factor and refreshes images.""" + factor = max(0.0, min(1.0, factor)) # Clamp to 0-1 + log.info("Setting saturation factor to: %.2f", factor) + config.set('color', 'saturation_factor', str(factor)) + config.save() + + # Only refresh if in saturation mode + if self.get_color_mode() == 'saturation': + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Notify QML + self.ui_state.saturationFactorChanged.emit() + + @Slot(result=str) + def get_awb_mode(self): + return config.get("awb", "mode") + + @Slot(str) + def set_awb_mode(self, mode): + config.set("awb", "mode", mode) + config.save() + + @Slot(result=float) + def get_awb_strength(self): + return config.getfloat("awb", "strength") + + @Slot(float) + def set_awb_strength(self, value): + config.set("awb", "strength", value) + config.save() + + # Refresh if AWB was recently applied + if self.get_color_mode() in ['saturation', 'icc']: + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + @Slot(float) + @Slot(float, float) + def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): + """Sets the straighten angle for the image editor and updates current view.""" + if not (self.ui_state.isEditorOpen or self.ui_state.isCropping): + return + + # Optimization: Assume image is loaded by toggle_crop_mode or open_editor. + # Avoid disk I/O here to prevent stutter during drag. + if not self.image_editor.original_image: + return + + # log.info(f"AppController.set_straighten_angle: {angle}, AR: {target_aspect_ratio}") + + # Update Aspect Ratio Compensation for Crop Box + # If we have a target aspect ratio, we need to adjust the normalized crop box + # because the underlying canvas aspect ratio changes with rotation (expand=True). + if target_aspect_ratio > 0 and self.ui_state.currentCropBox: + left, top, right, bottom = self.ui_state.currentCropBox + w_norm = right - left + h_norm = bottom - top + + if w_norm > 0 and h_norm > 0: + # Calculate new canvas dimensions + # PIL expand=True logic: + im_w, im_h = self.image_editor.original_image.size + # math imported at top level + rad = math.radians(abs(angle)) + # New dimensions + new_w = abs(im_w * math.cos(rad)) + abs(im_h * math.sin(rad)) + new_h = abs(im_w * math.sin(rad)) + abs(im_h * math.cos(rad)) + + if new_w > 0 and new_h > 0: + canvas_aspect = new_w / new_h + + # We want PixelAspect = (w_norm * new_w/1000) / (h_norm * new_h/1000) = target_aspect + # (w_norm / h_norm) * (new_w / new_h) = target_aspect + # w_norm / h_norm = target_aspect / canvas_aspect + + target_norm_ratio = target_aspect_ratio / canvas_aspect + + # Adjust dimensions to match target_norm_ratio + # Simple: Preserve Width, adjust Height. + + new_h_norm = w_norm / target_norm_ratio + + # If new height exceeds bounds (1000), constrain and adjust width instead + if new_h_norm > 1000: + new_h_norm = 1000 + w_norm = new_h_norm * target_norm_ratio + # Recenter height + cy = (top + bottom) / 2 + top = cy - new_h_norm / 2 + bottom = cy + new_h_norm / 2 + + # Clamp vertical + if top < 0: + bottom -= top # shift down + top = 0 + if bottom > 1000: + top -= (bottom - 1000) # shift up + bottom = 1000 + if top < 0: + top = 0 # double clamp + + # Recenter width (if changed) + cx = (left + right) / 2 + left = cx - w_norm / 2 + right = cx + w_norm / 2 + + # Clamp horizontal + if left < 0: + right -= left + left = 0 + if right > 1000: + left -= (right - 1000) + right = 1000 + if left < 0: + left = 0 + + self.ui_state.currentCropBox = (left, top, right, bottom) + self.image_editor.set_crop_box((left, top, right, bottom)) + + log.debug(f"AppController.set_straighten_angle: {angle}") + # Pass the angle as-is (degrees CW). + # QML rotation is CW-positive. + # ImageEditor expects CW-positive and handles the inversion for PIL internally. + self.image_editor.set_edit_param("straighten_angle", angle) + + # Trigger refresh. Since we are editing, we are viewing the preview. + # Incrementing display generation invalidates cache, but for preview it just ensures freshness if logic depends on it. + # Crucially, sync_ui_state emits currentImageSourceChanged, forcing QML to reload. + # self.display_generation += 1 + # self.sync_ui_state() # DISABLE TO PREVENT FLASHING - QML handles preview live + + @Slot(result=int) + def get_awb_warm_bias(self): + return config.getint("awb", "warm_bias") + + @Slot(int) + def set_awb_warm_bias(self, value): + config.set("awb", "warm_bias", value) + config.save() + + @Slot(result=int) + def get_awb_tint_bias(self): + return config.getint("awb", "tint_bias", fallback=0) + + @Slot(int) + def set_awb_tint_bias(self, value): + config.set("awb", "tint_bias", value) + config.save() + + @Slot(result=int) + def get_awb_luma_lower_bound(self): + return config.getint("awb", "luma_lower_bound") + + @Slot(int) + def set_awb_luma_lower_bound(self, value): + config.set("awb", "luma_lower_bound", value) + config.save() + + @Slot(result=int) + def get_awb_luma_upper_bound(self): + return config.getint("awb", "luma_upper_bound") + + @Slot(int) + def set_awb_luma_upper_bound(self, value): + config.set("awb", "luma_upper_bound", value) + config.save() + + @Slot(result=int) + def get_awb_rgb_lower_bound(self): + return config.getint("awb", "rgb_lower_bound") + + @Slot(int) + def set_awb_rgb_lower_bound(self, value): + config.set("awb", "rgb_lower_bound", value) + config.save() + + @Slot(result=int) + def get_awb_rgb_upper_bound(self): + return config.getint("awb", "rgb_upper_bound") + + @Slot(int) + def set_awb_rgb_upper_bound(self, value): + config.set("awb", "rgb_upper_bound", value) + config.save() + + def get_default_directory(self): + return config.get('core', 'default_directory') + + def set_default_directory(self, path): + config.set('core', 'default_directory', path) + config.save() + + def get_optimize_for(self): + return config.get('core', 'optimize_for', fallback='speed') + + def set_optimize_for(self, optimize_for): + old_value = config.get('core', 'optimize_for', fallback='speed') + config.set('core', 'optimize_for', optimize_for) + config.save() + + # If the setting changed, clear cache and redraw current image + if old_value != optimize_for: + log.info(f"Optimize for changed from {old_value} to {optimize_for}, clearing cache and redrawing") + self.image_cache.clear() + # Force redraw of current image + if self.current_index >= 0 and self.current_index < len(self.image_files): + self.ui_state.currentImageSourceChanged.emit() + + @Slot(result=float) + def get_auto_level_clipping_threshold(self): + return self.auto_level_threshold + + @Slot(float) + def set_auto_level_clipping_threshold(self, value): + # Clamp to 0-1 range for safety + value = max(0.0, min(1.0, value)) + self.auto_level_threshold = value + # Store as formatted string to avoid scientific notation weirdness or precision issues + config.set('core', 'auto_level_threshold', f"{value:.6g}") + config.save() + + @Slot(result=float) + def get_auto_level_strength(self): + return self.auto_level_strength + + @Slot(float) + def set_auto_level_strength(self, value): + # Clamp to 0-1 range + value = max(0.0, min(1.0, value)) + self.auto_level_strength = value + config.set('core', 'auto_level_strength', f"{value:.6g}") + config.save() + + @Slot(result=bool) + def get_auto_level_strength_auto(self): + return self.auto_level_strength_auto + + @Slot(bool) + def set_auto_level_strength_auto(self, value): + self.auto_level_strength_auto = value + # Store as canonical lowercase string + config.set('core', 'auto_level_strength_auto', "true" if value else "false") + config.save() + + def open_directory_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.Directory) + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + + @Slot() + def open_folder(self): + """Opens a directory dialog and reloads the application with the selected folder.""" + path = self.open_directory_dialog() + if path: + # Stop the old watcher + if self.watcher: + self.watcher.stop() + + # Update the directory path + self.image_dir = Path(path) + + # Reinitialize directory-bound components + self.watcher = Watcher(self.image_dir, self.refresh_image_list) + self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) + self.recycle_bin_dir = self.image_dir / "image recycle bin" + + # Clear directory-specific state + self.delete_history = [] + self.undo_history = [] + self.stacks = [] + self.batches = [] + self.batch_start_index = None + self.stack_start_index = None + + # Clear caches since they reference old directory's images + with self._last_image_lock: + self.last_displayed_image = None + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self._metadata_cache = {} + self._metadata_cache_index = (-1, -1) + # Clear last displayed image since it references the old directory + with self._last_image_lock: + self.last_displayed_image = None + # Clear editor state if open + self.image_editor.clear() + + # Load images from new directory + self.load() + + + def preload_all_images(self): + if self.ui_state.isPreloading: + log.info("Preloading is already in progress.") + return + + log.info("Starting to preload all images, skipping cached.") + self.ui_state.isPreloading = True + self.ui_state.preloadProgress = 0 + + self.reporter = self.ProgressReporter() + self.reporter.progress_updated.connect(self._update_preload_progress) + self.reporter.finished.connect(self._finish_preloading) + + total_images = len(self.image_files) + if total_images == 0: + log.info("No images to preload.") + self.ui_state.isPreloading = False + self.ui_state.preloadProgress = 0 + return + + # --- Check for cached images --- + images_to_preload = [] + already_cached_count = 0 + _, _, display_gen = self.get_display_info() + + # We want to load images furthest from the current index FIRST, + # and images closest to the current index LAST. + # This ensures that the images the user is currently looking at (and their neighbors) + # are the most recently added to the LRU cache, so they won't be evicted. + + # Calculate distance for all images + # (index, distance_from_current) + all_images_with_dist = [] + for i in range(total_images): + dist = abs(i - self.current_index) + all_images_with_dist.append((i, dist)) + + # Sort by distance descending (furthest first) + all_images_with_dist.sort(key=lambda x: x[1], reverse=True) + + # Determine which images are "nearby" (e.g. within prefetch radius * 2) + # We will FORCE these to be re-cached even if they are already in cache, + # to ensure they are moved to the front of the LRU queue. + nearby_radius = self.prefetcher.prefetch_radius * 2 + + for i, dist in all_images_with_dist: + if i >= len(self.image_files): + continue + image_path = self.image_files[i].path + cache_key = build_cache_key(image_path, display_gen) + is_cached = cache_key in self.image_cache + is_nearby = dist <= nearby_radius + + if is_cached and not is_nearby: + already_cached_count += 1 + else: + # Add to preload list if it's not cached OR if it's nearby (to refresh LRU) + images_to_preload.append(i) + + log.info(f"Found {already_cached_count} cached images (skipping). Preloading {len(images_to_preload)} images (including nearby refreshes).") + + if not images_to_preload: + log.info("All images are already cached.") + self._update_preload_progress(100) + self._finish_preloading() + return + + # --- Setup progress tracking --- + # `completed` starts at the number of images already cached (that we are skipping). + completed = already_cached_count + + # Update initial progress + initial_progress = int((completed / total_images) * 100) + self._update_preload_progress(initial_progress) + + def _on_done(_future): + nonlocal completed + completed += 1 + progress = int((completed / total_images) * 100) + self.reporter.progress_updated.emit(progress) + # Check if all images (including cached ones) are accounted for + if completed == total_images: + self.reporter.finished.emit() + + # --- Submit tasks --- + # images_to_preload is already sorted furthest -> nearest + for i in images_to_preload: + # For nearby images that we are forcing to re-cache, we might need to remove them first + # to ensure the cache actually updates the LRU position (depending on cache implementation). + # ByteLRUCache (cachetools) updates LRU on access (get/set), so just overwriting is fine. + # But we need to make sure we don't skip the task in prefetcher if it thinks it's already done. + # The prefetcher checks self.futures, but we are submitting new ones. + + future = self.prefetcher.submit_task(i, self.prefetcher.generation) + if future: + future.add_done_callback(_on_done) + + def _update_preload_progress(self, progress: int): + log.debug("Updating preload progress in UI: %d%%", progress) + self.ui_state.preloadProgress = progress + + def _finish_preloading(self): + self.ui_state.isPreloading = False + self.ui_state.preloadProgress = 0 + log.info("Finished preloading all images.") + + @Slot(result=int) + def get_batch_count_for_current_image(self) -> int: + """Get the count of images in the batch that contains the current image.""" + if not self.image_files: + return 0 + + # Check if current image is in any batch + for start, end in self.batches: + if start <= self.current_index <= end: + # Calculate total count across all batches + total_count = sum(end - start + 1 for start, end in self.batches) + return total_count + + return 0 + + @Slot() + def delete_current_image(self): + """Moves current JPG and RAW to recycle bin. Shows dialog if multiple images in batch.""" + if not self.image_files: + self.update_status_message("No image to delete.") + return + + # Check if current image is in a batch with multiple images + batch_count = self.get_batch_count_for_current_image() + + if batch_count > 1: + # Show dialog asking what to delete + if hasattr(self, 'main_window') and self.main_window: + # Set batch count in dialog and open it + self.main_window.show_delete_batch_dialog(batch_count) + return + + # Single image deletion - proceed normally + self._delete_single_image(self.current_index) + + def _move_to_recycle(self, src: Path) -> Optional[Path]: + """Moves a file to the recycle bin safely, handling collisions and cross-device moves.""" + if not src.exists() or not src.is_file(): + return None + + # Ensure recycle bin exists + try: + self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + log.error("Failed to create recycle bin: %s", e) + return None + + dest = self.recycle_bin_dir / src.name + + # Handle collisions with timestamp loop + if dest.exists(): + timestamp = int(time.time()) + base_name = f"{src.stem}.{timestamp}" + dest = self.recycle_bin_dir / f"{base_name}{src.suffix}" + counter = 1 + while dest.exists(): + dest = self.recycle_bin_dir / f"{base_name}_{counter}{src.suffix}" + counter += 1 + + try: + shutil.move(str(src), str(dest)) + log.info("Moved %s to recycle bin: %s", src.name, dest.name) + return dest + except OSError as e: + log.error("Failed to recycle %s: %s", src.name, e) + return None + + def _delete_single_image(self, index: int): + """Internal method to delete a single image by index.""" + if not self.image_files or index < 0 or index >= len(self.image_files): + self.update_status_message("No image to delete.") + return + + previous_index = self.current_index + image_file = self.image_files[index] + jpg_path = image_file.path + raw_path = image_file.raw_pair + + # Move files to recycle bin + recycled_jpg = self._move_to_recycle(jpg_path) + recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None + + # Add to delete history if anything was moved + if recycled_jpg or recycled_raw: + import time + timestamp = time.time() + # Store tuple of (src, bin_path) for each file + # Format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) + record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) + + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + + if not recycled_jpg and not recycled_raw: + self.update_status_message("Delete failed") + return + + # Refresh image list and move to next image + self.refresh_image_list() + if self.image_files: + self._reposition_after_delete(None, previous_index) + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + def _reposition_after_delete(self, preserved_path: Optional[Path], previous_index: int): + """Reposition current_index after the image list refreshed post-deletion.""" + if not self.image_files: + self.current_index = 0 + return + + if preserved_path: + for i, img_file in enumerate(self.image_files): + if img_file.path == preserved_path: + self.current_index = i + return + + self.current_index = min(previous_index, len(self.image_files) - 1) + + @Slot() + def delete_current_image_only(self): + """Delete only the current image, ignoring batch selection.""" + if not self.image_files: + self.update_status_message("No image to delete.") + return + self._delete_single_image(self.current_index) + + @Slot() + def delete_batch_images(self): + """Delete all images in the current batch.""" + if not self.image_files: + self.update_status_message("No images to delete.") + return + + # Collect all indices in batches + indices_to_delete = set() + for start, end in self.batches: + for i in range(start, end + 1): + if 0 <= i < len(self.image_files): + indices_to_delete.add(i) + + if not indices_to_delete: + self.update_status_message("No images in batch to delete.") + return + + # Sort indices in reverse order so we delete from end to start + # This way indices don't shift as we delete + sorted_indices = sorted(indices_to_delete, reverse=True) + + # Determine where to land after deletion + # We prefer to land on the image that was *conceptually* at the same position, + # which means following the last deleted index if we were deleting from right to left, + # or just staying at the start index of the batch. + + # If we just deleted a batch at the end of the list, we clamp to new length-1 + # If we deleted a batch in the middle, we want to be at the index that *was* + # immediately after the batch (which now shifts down by deleted_count). + + # Simpler logic: + # If we had a batch starting at index S with N items. + # After deleting N items, the item that was at S+N matches the new item at S. + # So we should generally effectively stay at 'start' (which finds the next image). + # We need to find the smallest index that was part of the deletion. + min_deleted_index = min(sorted_indices) + + # Create recycle bin if it doesn't exist + try: + self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + self.update_status_message(f"Failed to create recycle bin: {e}") + log.error("Failed to create recycle bin directory: %s", e) + return + + deleted_count = 0 + import time + timestamp = time.time() + + # Delete all images in the batch + for index in sorted_indices: + if index >= len(self.image_files): + continue + + image_file = self.image_files[index] + jpg_path = image_file.path + raw_path = image_file.raw_pair + + try: + recycled_jpg = self._move_to_recycle(jpg_path) + recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None + + if recycled_jpg or recycled_raw: + record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + deleted_count += 1 + + except OSError as e: + log.exception("Failed to delete image at index %d: %s", index, e) + + if deleted_count > 0: + # Clear all batches after deletion + self.batches = [] + self.batch_start_index = None + + # Refresh image list + self.refresh_image_list() + + if self.image_files: + # Calculate new index + # We essentially want to be at 'min_deleted_index' + # But clamped to boundaries. + new_index = min_deleted_index + new_index = max(0, min(new_index, len(self.image_files) - 1)) + + self.current_index = new_index + + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message(f"Deleted {deleted_count} image(s)") + log.info("Deleted %d image(s) from batch", deleted_count) + else: + self.update_status_message("No images were deleted.") + + def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> bool: + """ + Robustly restores a backup file to its original location, handling + locking and permission errors using a unique temporary file strategy. + Verifies success. + """ + saved_path = Path(saved_path_str) + backup_path = Path(backup_path_str) + + if not backup_path.exists(): + if saved_path.exists(): + self.update_status_message("Already restored (backup missing)") + log.warning("Backup %s missing but original exists.", backup_path) + else: + self.update_status_message("Backup not found") + log.warning("Backup %s disappeared before it could be restored.", backup_path) + return False + + # Generate a unique temporary path to avoid collisions + temp_path = saved_path.with_suffix(f'.{uuid.uuid4().hex}.tmp_restore') + + try: + # 1. If the target exists, we need to move the backup to the temp location first, + # then try to swap. If target is locked, we can't delete it directly. + if saved_path.exists(): + try: + saved_path.unlink() # Try the easy way first + except PermissionError as pe: + log.warning("File %s locked, attempting safe restore strategy: %s", saved_path, pe) + + # Move backup to temp + try: + shutil.move(str(backup_path), str(temp_path)) + except OSError as e: + log.error("Failed to move backup to temp: %s", e) + raise + + if not temp_path.exists(): + log.error("Temp file %s not found after move!", temp_path) + raise OSError(f"Failed to create temp file {temp_path}") + + # Try to force-move the temp file over the target (replace) + try: + os.replace(str(temp_path), str(saved_path)) + except OSError: + # If replace fails, try to move back + log.error("Could not overwrite locked file %s", saved_path) + shutil.move(str(temp_path), str(backup_path)) + raise + + # 2. If target doesn't exist (successfully unlinked or didn't exist), move backup to target + if not saved_path.exists(): + # If we moved to temp, move temp -> target + source = temp_path if temp_path.exists() else backup_path + shutil.move(str(source), str(saved_path)) + + # Verify restoration + if not saved_path.exists(): + raise OSError(f"Restoration failed: {saved_path} does not exist after move.") + + if saved_path.stat().st_size == 0: + log.warning("Restored file %s is 0 bytes!", saved_path) + + log.info("Successfully restored %s from %s", saved_path, backup_path_str) + return True + + except Exception as e: + # Attempt cleanup + if temp_path.exists(): + try: + if backup_path.exists(): + temp_path.unlink() # Backup still there, just kill temp + else: + shutil.move(str(temp_path), str(backup_path)) # Put it back + except OSError: + pass + log.exception("Detailed error in _restore_backup_safe") + raise e + + @Slot() + def undo_delete(self): + """Unified undo that handles both delete and auto white balance operations.""" + if not self.undo_history: + self.update_status_message("Nothing to undo.") + return + + # Get the most recent action + action_type, action_data, timestamp = self.undo_history.pop() + + if action_type == "delete": + # New record format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) + (jpg_src, jpg_bin), (raw_src, raw_bin) = action_data + + # Remove from delete_history if it matches + if self.delete_history and self.delete_history[-1] == action_data: + self.delete_history.pop() + + restored_files = [] + try: + # Helper to move back safely + def restore_file(src_path: Optional[Path], bin_path: Optional[Path]): + if not src_path or not bin_path or not bin_path.exists(): + return False + if src_path.exists(): + log.warning("Cannot restore %s: User file already exists at %s", bin_path.name, src_path) + return False # Or maybe restore with new name? For now, skip to prevent overwrite + + shutil.move(str(bin_path), str(src_path)) + return True + + # Restore JPG + if restore_file(jpg_src, jpg_bin): + restored_files.append(jpg_src.name) + log.info("Restored %s from recycle bin", jpg_src.name) + + # Restore RAW + if restore_file(raw_src, raw_bin): + restored_files.append(raw_src.name) + log.info("Restored %s from recycle bin", raw_src.name) + + # Update status + if restored_files: + files_str = ", ".join(restored_files) + self.update_status_message(f"Restored: {files_str}") + else: + self.update_status_message("No files to restore") + + # Refresh image list + self.refresh_image_list() + + # Find and navigate to the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == jpg_src: + self.current_index = i + break + + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to restore image") + # Put it back in history if it failed + self.undo_history.append(("delete", action_data, timestamp)) + self.delete_history.append(action_data) + + elif action_type == "auto_white_balance": + saved_path, backup_path = action_data + try: + if self._restore_backup_safe(saved_path, backup_path): + # Refresh + self.refresh_image_list() + # Find + saved_path_obj = Path(saved_path) + for i, img_file in enumerate(self.image_files): + if img_file.path == saved_path_obj: + self.current_index = i + break + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + if self.ui_state.isHistogramVisible: + self.update_histogram() + + self.update_status_message("Undid auto white balance") + except Exception as e: + self.update_status_message(f"Undo failed: {e}") + if Path(backup_path).exists(): + self.undo_history.append(("auto_white_balance", action_data, timestamp)) + + elif action_type == "auto_levels": + saved_path, backup_path = action_data + try: + if self._restore_backup_safe(saved_path, backup_path): + # Refresh + self.refresh_image_list() + # Find + saved_path_obj = Path(saved_path) + for i, img_file in enumerate(self.image_files): + if img_file.path == saved_path_obj: + self.current_index = i + break + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + if self.ui_state.isHistogramVisible: + self.update_histogram() + + self.update_status_message("Undid auto levels") + except Exception as e: + self.update_status_message(f"Undo failed: {e}") + if Path(backup_path).exists(): + self.undo_history.append(("auto_levels", action_data, timestamp)) + + elif action_type == "crop": + saved_path, backup_path = action_data + try: + if self._restore_backup_safe(saved_path, backup_path): + # Refresh + self.refresh_image_list() + # Find + saved_path_obj = Path(saved_path) + for i, img_file in enumerate(self.image_files): + if img_file.path == saved_path_obj: + self.current_index = i + break + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message("Undid crop") + except Exception as e: + self.update_status_message(f"Undo failed: {e}") + if Path(backup_path).exists(): + self.undo_history.append(("crop", action_data, timestamp)) + + def shutdown(self): + log.info("Application shutting down.") + + # Check if recycle bin has files and prompt to empty + if self.recycle_bin_dir.exists(): + files_in_bin = list(self.recycle_bin_dir.glob("*")) + if files_in_bin: + file_count = len(files_in_bin) + msg_box = QMessageBox() + msg_box.setWindowTitle("Recycle Bin") + msg_box.setText(f"There are {file_count} files in the recycle bin.") + msg_box.setInformativeText("What would you like to do?") + + # Add custom buttons + delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.YesRole) + restore_btn = msg_box.addButton(f"Restore {file_count} deleted files", QMessageBox.ActionRole) + keep_btn = msg_box.addButton("Keep in Recycle Bin", QMessageBox.NoRole) + + msg_box.setDefaultButton(keep_btn) + msg_box.exec() + + clicked_button = msg_box.clickedButton() + if clicked_button == delete_btn: + self.empty_recycle_bin() + elif clicked_button == restore_btn: + self.restore_all_from_recycle_bin() + + # Clear QML context property to prevent TypeErrors during shutdown + if self.engine: + log.info("Clearing uiState context property in QML.") + del self.engine # Explicitly delete the engine + + self.watcher.stop() + self.prefetcher.shutdown() + self.sidecar.set_last_index(self.current_index) + self.sidecar.save() + + def _shutdown_executors(self): + """Explicitly shuts down thread pools on app exit to prevent hanging.""" + self._shutting_down = True + log.info("Shutting down background executors...") + self._hist_executor.shutdown(wait=False, cancel_futures=True) + self._preview_executor.shutdown(wait=False, cancel_futures=True) + + def empty_recycle_bin(self): + """Permanently deletes all files in the recycle bin.""" + if not self.recycle_bin_dir.exists(): + return + + try: + import shutil + shutil.rmtree(self.recycle_bin_dir) + self.delete_history.clear() + log.info("Emptied recycle bin and cleared delete history") + except OSError: + log.exception("Failed to empty recycle bin") + + def _on_cache_evict(self): + """Callback for when the image cache evicts an item.""" + now = time.time() + + # 1. Record eviction timestamp + self._eviction_timestamps.append(now) + + # 2. Prune timestamps older than window + # Keep list short + cutoff = now - CACHE_THRASH_WINDOW_SECS + self._eviction_timestamps = [t for t in self._eviction_timestamps if t > cutoff] + + # 3. Check for thrashing (e.g., > threshold evictions in window) + if len(self._eviction_timestamps) > CACHE_THRASH_THRESHOLD: + # 4. Rate limit the warning + if now - self._last_cache_warning_time > CACHE_WARNING_COOLDOWN_SECS: + self._last_cache_warning_time = now + self._has_warned_cache_full = True + + # Format usage info + used_gb = self.image_cache.currsize / (1024**3) + max_gb = self.image_cache.max_bytes / (1024**3) + + msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in {CACHE_THRASH_WINDOW_SECS}s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB." + + # Use QTimer.singleShot to ensure this runs on the main thread + QTimer.singleShot(0, lambda: self.update_status_message(msg)) + log.warning(msg) + + def restore_all_from_recycle_bin(self): + """Restores all files from recycle bin to working directory.""" + if not self.recycle_bin_dir.exists(): + return + + try: + files_in_bin = list(self.recycle_bin_dir.glob("*")) + restored_count = 0 + + for file_in_bin in files_in_bin: + # Restore to original location (working directory) + dest_path = self.image_dir / file_in_bin.name + + # If file already exists, skip (don't overwrite) + if dest_path.exists(): + log.warning("File already exists, skipping: %s", dest_path) + continue + + try: + file_in_bin.rename(dest_path) + restored_count += 1 + log.info("Restored %s from recycle bin", file_in_bin.name) + except OSError as e: + log.error("Failed to restore %s: %s", file_in_bin.name, e) + + # Clear delete history since we restored everything + self.delete_history.clear() + + log.info("Restored %d files from recycle bin", restored_count) + + except OSError: + log.exception("Failed to restore files from recycle bin") + + @Slot() + def edit_in_photoshop(self): + if not self.image_files: + self.update_status_message("No image to edit.") + return + + # Prefer RAW file if it exists, otherwise use JPG + image_file = self.image_files[self.current_index] + jpg_path = image_file.path + + # Handle backup images: strip -backup, -backup2, -backup-1, etc. to find original RAW + import re + original_stem = jpg_path.stem + # Remove -backup with optional digits or -backup-digits (handles both formats) + original_stem = re.sub(r'-backup(-?\d+)?$', '', original_stem) + + # Look for RAW file with the original stem + raw_path = None + if image_file.raw_pair and image_file.raw_pair.exists(): + # Use the paired RAW if it exists + raw_path = image_file.raw_pair + else: + # Search for RAW file manually by original stem + from faststack.io.indexer import RAW_EXTENSIONS + for ext in RAW_EXTENSIONS: + potential_raw = jpg_path.parent / f"{original_stem}{ext}" + if potential_raw.exists(): + raw_path = potential_raw + break + + if raw_path and raw_path.exists(): + current_image_path = raw_path + log.info("Using RAW file for Photoshop: %s", raw_path) + else: + current_image_path = jpg_path + log.info("Using JPG file for Photoshop (no RAW found): %s", current_image_path) + + photoshop_exe = config.get('photoshop', 'exe') + photoshop_args = config.get('photoshop', 'args') + + # Validate executable path securely + is_valid, error_msg = validate_executable_path( + photoshop_exe, + app_type="photoshop", + allow_custom_paths=True + ) + + if not is_valid: + self.update_status_message(f"Photoshop validation failed: {error_msg}") + log.error("Photoshop executable validation failed: %s", error_msg) + return + + # Validate that the file path exists and is a file + if not current_image_path.exists() or not current_image_path.is_file(): + self.update_status_message(f"Image file not found: {current_image_path.name}") + log.error("Image file not found or not a file: %s", current_image_path) + return + + try: + # Build command list safely + command = [photoshop_exe] + + # Parse additional args safely using shlex (handles quotes and escapes properly) + if photoshop_args: + try: + # Use shlex to properly parse arguments with quotes/escapes + # On Windows, use posix=False to handle Windows-style paths + parsed_args = shlex.split(photoshop_args, posix=(os.name != 'nt')) + command.extend(parsed_args) + except ValueError as e: + log.error("Invalid photoshop_args format: %s", e) + self.update_status_message("Invalid Photoshop arguments configured") + return + + # Add the file path as the last argument + # Convert to string but keep it as a list element (not shell-interpolated) + command.append(str(current_image_path.resolve())) + + # SECURITY: Explicitly disable shell execution + subprocess.Popen( + command, + shell=False, # CRITICAL: Never use shell=True with user input + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True # Close unused file descriptors + ) + + # Mark as edited on successful launch + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = image_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.edited = True + meta.edited_date = today + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") + log.info("Launched Photoshop with: %s", command) + except FileNotFoundError as e: + self.update_status_message(f"Photoshop executable not found: {e}") + log.exception("Photoshop executable not found") + # Don't mark as edited if launch failed + return + except (OSError, subprocess.SubprocessError) as e: + self.update_status_message(f"Failed to open in Photoshop: {e}") + log.exception("Error launching Photoshop") + # Don't mark as edited if launch failed + return + + @Slot() + def copy_path_to_clipboard(self): + if not self.image_files: + self.update_status_message("No image path to copy.") + return + + current_image_path = str(self.image_files[self.current_index].path) + QApplication.clipboard().setText(current_image_path) + self.update_status_message(f"Copied: {current_image_path}") + log.info("Copied path to clipboard: %s", current_image_path) + + @Slot() + def reset_zoom_pan(self): + """Resets zoom and pan to fit the image in the window (like Ctrl+0 in Photoshop).""" + log.info("Resetting zoom and pan to fit window") + self.ui_state.resetZoomPan() + self.update_status_message("Reset zoom and pan") + + def update_status_message(self, message: str, timeout: int = 3000): + """ + Updates the UI status message and clears it after a timeout. + """ + def clear_message(): + if self.ui_state.statusMessage == message: + self.ui_state.statusMessage = "" + + self.ui_state.statusMessage = message + QTimer.singleShot(timeout, clear_message) + + + + @Slot() + def start_drag_current_image(self): + if not self.image_files or self.current_index >= len(self.image_files): + return + + # Collect all files: current + any in defined batches + files_to_drag = set() + files_to_drag.add(self.current_index) + + # Add all files from defined batches + for start, end in self.batches: + for idx in range(start, end + 1): + if 0 <= idx < len(self.image_files): + files_to_drag.add(idx) + + # Convert to sorted list and get only existing paths + file_indices = sorted(files_to_drag) + existing_indices = [idx for idx in file_indices if self.image_files[idx].path.exists()] + + # Prefer dragging the developed JPG if it exists (for external export), + # but only when RAW mode is active or we are dragging a developed file itself. + file_paths = [] + for idx in existing_indices: + img = self.image_files[idx] + + # Suggestion: only prefer -developed.jpg when RAW mode is active + # or when the current entry is itself the working/developed artifact. + is_developed_artifact = img.path.stem.lower().endswith("-developed") + in_raw_mode = (getattr(self, 'current_edit_source_mode', 'jpeg') == "raw") + + if (in_raw_mode or is_developed_artifact) and img.developed_jpg_path.exists(): + file_paths.append(img.developed_jpg_path) + else: + file_paths.append(img.path) + + if not file_paths: + log.error("No valid files to drag") + return + + if self.main_window is None: + return + + drag = QDrag(self.main_window) + mime_data = QMimeData() + + # Use Qt's standard setUrls - it handles both browser and native app compatibility + urls = [QUrl.fromLocalFile(str(p)) for p in file_paths] + mime_data.setUrls(urls) + + drag.setMimeData(mime_data) + + # --- thumbnail / drag preview --- + pix = QPixmap(str(file_paths[0])) + if not pix.isNull(): + # scale it down so it's not huge + scaled = pix.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) + drag.setPixmap(scaled) + # hotspot = center of image + drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) + + log.info("Starting drag for %d file(s): %s", len(file_paths), [str(p) for p in file_paths]) + # Support both Copy and Move actions for browser compatibility + result = drag.exec(Qt.CopyAction | Qt.MoveAction) + log.info("Drag completed with result: %s", result) + + # Reset zoom/pan after drag completes (drag can cause unwanted panning) + self.ui_state.resetZoomPan() + + # Mark all dragged files as uploaded if drag was successful + if result in (Qt.CopyAction, Qt.MoveAction): + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + + for idx in existing_indices: + stem = self.image_files[idx].path.stem + meta = self.sidecar.get_metadata(stem) + meta.uploaded = True + meta.uploaded_date = today + + self.sidecar.save() + + # Clear all batches after successful drag (like pressing \) + self.batches = [] + self.batch_start_index = None + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) + + + + @Slot() + def enable_raw_editing(self): + """Switches the current image to RAW mode (using developed TIFF).""" + if not self.image_files: + return + + # 1. Update State + # 1. Update State + if self.current_edit_source_mode != "raw": + self.current_edit_source_mode = "raw" + self.editSourceModeChanged.emit("raw") + self.sync_ui_state() + + # 2. Check if we have a valid TIFF ready + path = self.get_active_edit_path(self.current_index) + + # If the path returned IS the working TIFF (and it exists), we can just load it. + # Check specific condition: + image_file = self.image_files[self.current_index] + if path == image_file.working_tif_path and self.is_valid_working_tif(path): + log.info("Valid working TIFF exists, switching to RAW mode immediately.") + self.load_image_for_editing() # This will now pick up the TIFF via get_active_edit_path + return + + # 3. If not ready, trigger development + # (Pass through to existing backend logic) + self._develop_raw_backend() + + def _develop_raw_backend(self): + """Internal: Triggers the actual RawTherapee process.""" + if not self.image_files: + return + + image_file = self.image_files[self.current_index] + if not image_file.has_raw: + self.update_status_message("No RAW file available.") + return + + raw_path = image_file.raw_path + tif_path = image_file.working_tif_path + + # Resolve RawTherapee Executable + from faststack.config import config + rt_exe = config.get("rawtherapee", "exe") + if not rt_exe or not os.path.exists(rt_exe): + self.update_status_message("RawTherapee not found. Check settings.") + log.error("RawTherapee executable not configured or missing: %s", rt_exe) + return + self.update_status_message("Developing RAW... please wait.") + log.info("Starting RAW development: %s -> %s", raw_path, tif_path) + + def worker(): + # Check for optional args in config + rt_args = config.get("rawtherapee", "args") + + # Build command: rawtherapee-cli -t -Y -o -c + # -t: TIFF output + # -b16: 16-bit depth (Critical! Default is often 8-bit) + # -Y: Overwrite existing + # -o: Output file + # -c: Input file (must be last) + cmd = [rt_exe, "-t", "-b16", "-Y", "-o", str(tif_path)] + + if rt_args: + try: + # Use shlex to properly parse arguments with quotes/escapes + # On Windows, use posix=False to handle Windows-style paths + parsed_args = shlex.split(rt_args, posix=(os.name != 'nt')) + cmd.extend(parsed_args) + except ValueError as e: + log.error("Invalid rawtherapee args format: %s", e) + + cmd.extend(["-c", str(raw_path)]) + cmd_str = " ".join(cmd) # For logging + + # Run process + run_kwargs = { + "capture_output": True, + "text": True, + "timeout": 60 # 60 second timeout + } + if sys.platform == "win32": + run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + + try: + result = subprocess.run(cmd, **run_kwargs) + + if result.returncode == 0: + if tif_path.exists() and tif_path.stat().st_size > 0: + log.info("RAW development successful.") + # Use partial to bind variable deeply + QTimer.singleShot(0, functools.partial(self._on_develop_finished, True, None)) + return # Success path + else: + msg = f"RawTherapee exited successfully but output file is missing or empty.\nCommand: {cmd_str}" + log.error(msg) + QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, msg)) + else: + stderr = result.stderr.strip() if result.stderr else "(no stderr)" + stdout = result.stdout.strip() if result.stdout else "(no stdout)" + err_msg = f"RawTherapee failed (exit code {result.returncode}):\nCommand: {cmd_str}\nstderr: {stderr}\nstdout: {stdout}" + log.error(err_msg) + QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) + + except subprocess.TimeoutExpired: + err_msg = f"RawTherapee timed out after 60 seconds.\nCommand: {cmd_str}" + log.error(err_msg) + QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) + except Exception as e: + err_msg = f"Unexpected error running RawTherapee: {str(e)}" + log.exception(err_msg) + QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) + finally: + # Cleanup if we failed and left a bad file or 0-byte file (unless success logic already returned) + # Note: success logic returns early. If we are here, we likely failed or fell through (e.g. 0 byte file case did not return) + # Actually, the 0-byte case calls on_finished but doesn't return, so it falls here. + # Let's check specifically if we need to cleanup. + # If we succeeded, we returned. + if tif_path.exists() and 'result' in locals(): + # Only cleanup if result was assigned (subprocess ran) + # If it's 0 bytes or we are in an error state (which implies we didn't return early) + try: + if tif_path.stat().st_size == 0: + tif_path.unlink() + elif result.returncode != 0: + # If we crashed but left a file, delete it + tif_path.unlink() + except (OSError, AttributeError): + # AttributeError if result is None + pass + + threading.Thread(target=worker, daemon=True).start() + + # Preserving legacy slot name for compatibility if QML calls it directly, + # but QML should call enable_raw_editing now. + # Actually provider.py calls this. I will update provider.py to call enable_raw_editing. + # But I'll keep this as a proxy to the new method just in case. + @Slot() + def develop_raw_for_current_image(self): + self.enable_raw_editing() + + + @Slot() + def load_image_for_editing(self): + """ + Loads the currently viewed image into the editor using active path logic. + This provides a centralized entry point for loading the editor correctly. + """ + try: + active_path = self.get_active_edit_path(self.current_index) + filepath = str(active_path) + + # Fetch cached preview if available for faster initial display + cached_preview = self.get_decoded_image(self.current_index) + + # Determine if we should capture source EXIF (e.g., for RAW mode) + source_exif = None + if self.current_edit_source_mode == "raw": + # Capture EXIF from the original JPEG to preserve in developed JPG + image_file = self.image_files[self.current_index] + jpeg_path = image_file.path + # Only if the main path isn't itself a TIFF (avoid recursion) + if jpeg_path.suffix.lower() not in ('.tif', '.tiff') and jpeg_path.exists(): + try: + with Image.open(jpeg_path) as src_im: + source_exif = src_im.info.get('exif') + except Exception as e: + log.warning(f"Failed to capture source EXIF from {jpeg_path}: {e}") + + # Load into editor + if self.image_editor.load_image(filepath, cached_preview=cached_preview, source_exif=source_exif): + # Notify UIState to update bindings + # We do this via signals or by calling the update function on UIState if available + # But UIState listens to editor signals? + # Actually, the previous implementation in UIState pushed edits to itself. + # We need to preserve that behavior. + # For now, simpler to emit a signal that UIState listens to, + # OR just manually update UIState here if we have reference. + if self.ui_state: + self._sync_editor_state_to_ui() + + return True + except Exception as e: + log.exception("Failed to load image for editing: %s", e) + self.update_status_message(f"Error loading editor: {e}") + + return False + + def _sync_editor_state_to_ui(self): + """Helper to push editor state (initial edits) to UIState.""" + initial_edits = self.image_editor._initial_edits() + for key, value in initial_edits.items(): + if hasattr(self.ui_state, key): + setattr(self.ui_state, key, value) + + # Reset visual components + if hasattr(self.ui_state, 'aspectRatioNames'): + # This requires IMPORTs? No, just pass list. + from faststack.imaging.editor import ASPECT_RATIOS + self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + self.ui_state.currentAspectRatioIndex = 0 + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + + # Kick off background render + self._kick_preview_worker() + # Notify UI + self.ui_state.editorImageChanged.emit() + + def _on_develop_finished(self, success: bool, error_msg: Optional[str]): + """Callback on main thread after RAW development.""" + if success: + self.update_status_message("RAW Development complete.") + # Load active path (which should now be the developed TIFF) + self.load_image_for_editing() + else: + self.update_status_message(f"Development failed: {error_msg}") + # Ensure UI reflects failure (maybe revert mode? or just show error) + # Staying in RAW mode but failing to load allows user to try again or see error. + + @Slot(result=DecodedImage) + def get_preview_data(self) -> Optional[DecodedImage]: + """Gets the preview data of the currently edited image as a DecodedImage.""" + return self.image_editor.get_preview_data() + + @Slot(str, "QVariant") + def set_edit_parameter(self, key: str, value: Any): + """Sets an edit parameter and updates the UIState for the slider visual.""" + # Robust guard: only allow edits if the editor is actually holding an image. + if not self.image_editor: + return + if self.image_editor.current_filepath is None: + return + # Must have either a float image (working copy) or original loaded + if self.image_editor.float_image is None and self.image_editor.original_image is None: + return + + try: + # Update actual edit state (this bumps _edits_rev and invalidates preview cache) + changed = self.image_editor.set_edit_param(key, value) + + # Sync UI state with backend (e.g., rotation might be rounded) + final_value = value + if changed: + # Use thread-safe accessor to get the actual value applied + actual = self.image_editor.get_edit_value(key) + if actual is not None: + final_value = actual + + # Update UI state regardless (visual sliders need to match what user dragged, OR the clamped backend value) + if hasattr(self.ui_state, key): + setattr(self.ui_state, key, final_value) + + # Trigger a refresh of the image to show the edit, ONLY if something changed + # Uses gate pattern: runs immediately if not inflight, else queues for next + if changed: + self._kick_preview_worker() + except Exception as e: + log.error("Error setting edit parameter %s=%s: %s", key, value, e) + + @Slot(int, int, int, int) + def set_crop_box(self, left: int, top: int, right: int, bottom: int): + """Sets the normalized crop box (0-1000) in the editor.""" + from typing import Tuple + crop_box: Tuple[int, int, int, int] = (left, top, right, bottom) + self.image_editor.set_crop_box(crop_box) + self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) + + @Slot() + def reset_edit_parameters(self): + """Resets all editing parameters in the editor.""" + self.image_editor.reset_edits() + if hasattr(self.ui_state, 'reset_editor_state'): + self.ui_state.reset_editor_state() + + self.update_status_message("Edits reset") + + # Trigger a refresh to show the reset image + self.ui_refresh_generation += 1 + self._kick_preview_worker() + + if self.ui_state.isHistogramVisible: + self.update_histogram() + + + @Slot() + def rotate_image_cw(self): + """Rotate the edited image 90 degrees clockwise.""" + current = self.image_editor.current_edits.get('rotation', 0) + new_rotation = (current - 90) % 360 + self.set_edit_parameter('rotation', new_rotation) + if self.ui_state.isHistogramVisible: + self.update_histogram() + + @Slot() + def rotate_image_ccw(self): + """Rotate the edited image 90 degrees counter-clockwise.""" + current = self.image_editor.current_edits.get('rotation', 0) + new_rotation = (current + 90) % 360 + self.set_edit_parameter('rotation', new_rotation) + if self.ui_state.isHistogramVisible: + self.update_histogram() + + @Slot() + def toggle_histogram(self): + """Toggle histogram window visibility.""" + self.ui_state.isHistogramVisible = not self.ui_state.isHistogramVisible + if self.ui_state.isHistogramVisible: + self.update_histogram() + log.info("Histogram window opened") + else: + log.info("Histogram window closed") + + @Slot() + @Slot(float, float, float, float) # zoom, panX, panY, imageScale + def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = 0.0, image_scale: float = 1.0): + """Throttled request to update histogram. Updates continuously but capped at interval. + + Args: + zoom: Zoom scale factor (1.0 = no zoom) + pan_x: Pan offset in X direction (in image coordinates) + pan_y: Pan offset in Y direction (in image coordinates) + image_scale: Scale factor of displayed image vs original + """ + # Early guard: don't even schedule if nothing is showing the histogram + if not (self.ui_state.isHistogramVisible or self.ui_state.isEditorOpen): + with self._hist_lock: + self._hist_pending = None + return + + with self._hist_lock: + self._hist_pending = (zoom, pan_x, pan_y, image_scale) + inflight = self._hist_inflight + + if not self.histogram_timer.isActive() and not inflight: + self.histogram_timer.start() + + def _kick_histogram_worker(self): + if getattr(self, "_shutting_down", False): + return + + with self._hist_lock: + if self._hist_inflight: + return + if self._hist_pending is None: + return + + args = self._hist_pending + self._hist_pending = None + + self._hist_token += 1 + token = self._hist_token + # Mark as inflight while holding the lock to prevent others from entering + self._hist_inflight = True + + # Snap the currently known preview data to avoid racing with the editor + preview_data = self._last_rendered_preview + if not preview_data: + # Fallback for initial load if no edit preview yet (could use get_decoded_image?) + # But histogram is mostly for edits. If preview_data is None, we likely can't compute anyway. + # We can try to peek at the image editor if _last_rendered_preview is unset. + preview_data = self.image_editor.get_preview_data_cached(allow_compute=False) + + # Fallback: If still no preview data (e.g. editor not open), we need to fetch the main image. + # But doing get_decoded_image() here blocks the main thread. + # Instead, we pass the index to the worker and let it fetch/decode if needed. + target_index = -1 + if not preview_data and 0 <= self.current_index < len(self.image_files): + target_index = self.current_index + + # If no preview data AND no valid index, we can't compute. + if not preview_data and target_index == -1: + # We must clear inflight if we abort, otherwise we deadlock future updates + # Keep lock held while modifying shared state AND checking timer to prevent race + with self._hist_lock: + self._hist_inflight = False + # Restore pending args so the next timer tick (or preview completion) retries + if self._hist_pending is None: + self._hist_pending = args + # Make sure timer is running to retry (check under lock to avoid race) + should_start_timer = not self.histogram_timer.isActive() + + if should_start_timer: + self.histogram_timer.start() + return + + try: + # Pass simple data + controller reference + target_index + fut = self._hist_executor.submit(self._compute_histogram_worker, token, args, preview_data, self, target_index) + fut.add_done_callback(self._on_histogram_done) + except Exception as e: + log.error(f"Histogram executor failed to submit task: {e}") + with self._hist_lock: + self._hist_inflight = False + + @staticmethod + def _compute_histogram_worker(token, args, decoded, controller=None, target_index=-1): + # IMPORTANT: do not touch QObjects here except thread-safe plain data + zoom, pan_x, pan_y, image_scale = args + + # If data wasn't provided, try to fetch it safely using the controller + if not decoded and controller and target_index >= 0: + decoded = controller._get_decoded_image_safe(target_index) + + # Use explicitly passed or fetched decoded data + if not decoded: + return token, None + + import numpy as np + try: + arr = np.frombuffer(decoded.buffer, dtype=np.uint8).reshape((decoded.height, decoded.width, 3)) + + # If zoomed in, calculate visible region and only use that portion + if zoom > 1.1: + visible_width = decoded.width / zoom + visible_height = decoded.height / zoom + center_x = decoded.width / 2 + center_y = decoded.height / 2 + pan_x_image = pan_x / image_scale if image_scale > 0 else 0 + pan_y_image = pan_y / image_scale if image_scale > 0 else 0 + visible_center_x = center_x - (pan_x_image / zoom) + visible_center_y = center_y - (pan_y_image / zoom) + + visible_x_start = max(0, int(visible_center_x - visible_width / 2)) + visible_y_start = max(0, int(visible_center_y - visible_height / 2)) + visible_x_end = min(decoded.width, int(visible_center_x + visible_width / 2)) + visible_y_end = min(decoded.height, int(visible_center_y + visible_height / 2)) + + if visible_x_end > visible_x_start and visible_y_end > visible_y_start: + arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] + + bins = 256 + value_range = (0, 256) + + r_hist = np.histogram(arr[:, :, 0], bins=bins, range=value_range)[0] + g_hist = np.histogram(arr[:, :, 1], bins=bins, range=value_range)[0] + b_hist = np.histogram(arr[:, :, 2], bins=bins, range=value_range)[0] + + r_clip_count = int(r_hist[255]) + g_clip_count = int(g_hist[255]) + b_clip_count = int(b_hist[255]) + + r_preclip_count = int(np.sum(r_hist[250:255])) + g_preclip_count = int(np.sum(g_hist[250:255])) + b_preclip_count = int(np.sum(b_hist[250:255])) + + log_r_hist = [float(x) for x in np.log1p(r_hist)] + log_g_hist = [float(x) for x in np.log1p(g_hist)] + log_b_hist = [float(x) for x in np.log1p(b_hist)] + + hist = { + 'r': log_r_hist, + 'g': log_g_hist, + 'b': log_b_hist, + 'r_clip': r_clip_count, + 'g_clip': g_clip_count, + 'b_clip': b_clip_count, + 'r_preclip': r_preclip_count, + 'g_preclip': g_preclip_count, + 'b_preclip': b_preclip_count, + } + return token, hist + except Exception: + return token, None + + def _on_histogram_done(self, fut): + if getattr(self, "_shutting_down", False): + return + + try: + token, hist = fut.result() + except Exception: + token, hist = None, None + + # bounce back to UI thread via signal + self.histogramReady.emit((token, hist)) + + @Slot(object) + def _apply_histogram_result(self, payload): + if getattr(self, "_shutting_down", False): + return + + token, hist = payload + + with self._hist_lock: + self._hist_inflight = False + + if hist is not None: + if token == self._hist_token: + self.ui_state.histogramData = hist + self.ui_state.highlightStateChanged.emit() + + # If more updates arrived while we computed, run again soon + pending = self._hist_pending is not None + + if pending: + self.histogram_timer.start() + + def _kick_preview_worker(self): + """Kicks off a background preview render task.""" + if getattr(self, "_shutting_down", False): + return + + with self._preview_lock: + if self._preview_inflight: + self._preview_pending = True + return + + self._preview_inflight = True + self._preview_pending = False + self._preview_token += 1 + token = self._preview_token + + # Submit task to dedicated preview executor + try: + fut = self._preview_executor.submit(self._render_preview_worker, token, self.image_editor) + fut.add_done_callback(self._on_preview_done) + except RuntimeError: + log.warning("Preview executor failed (shutting down?)") + with self._preview_lock: + self._preview_inflight = False + + @staticmethod + def _render_preview_worker(token, image_editor): + # Heavy work (PIL apply_edits) happens here off-thread + try: + # allow_compute=True ensures we actually do the work + decoded = image_editor.get_preview_data_cached(allow_compute=True) + return token, decoded + except Exception: + log.exception("Preview render failed") + return token, None + + def _on_preview_done(self, fut): + if getattr(self, "_shutting_down", False): + return + + try: + token, decoded = fut.result() + except Exception: + token, decoded = None, None + + # Emit from worker thread; Qt will queue to UI thread + self.previewReady.emit((token, decoded)) + + @Slot(object) + def _apply_preview_result(self, payload): + if getattr(self, "_shutting_down", False): + return + + token, decoded = payload + should_kick = False + should_accept = False + + with self._preview_lock: + self._preview_inflight = False + + # Accept result only if: + # 1. We got valid decoded data + # 2. Token matches (not stale from an old request) + # 3. No pending request waiting (avoid "snap back" stale frame flash) + if decoded is not None and token == self._preview_token and not self._preview_pending: + self._last_rendered_preview = decoded + self.ui_refresh_generation += 1 + self._last_rendered_preview_index = self.current_index + self._last_rendered_preview_gen = self.ui_refresh_generation + should_accept = True + + # Consume pending flag atomically before scheduling + if self._preview_pending: + self._preview_pending = False + should_kick = True + + # Emit outside lock to avoid holding lock during UI work + if should_accept: + self.ui_state.currentImageSourceChanged.emit() + self.ui_state.highlightStateChanged.emit() + self.update_histogram() + + # Call directly (not via singleShot) since we're on the UI thread. + # This prevents race where a new slider event could interleave between + # scheduling and execution, causing a spurious extra render. + if should_kick: + self._kick_preview_worker() + + + + @Slot() + def cancel_crop_mode(self): + """Cancel crop mode without applying changes.""" + if self.ui_state.isCropping: + self.ui_state.isCropping = False + self.ui_state.currentCropBox = [0, 0, 1000, 1000] + # Ensure preview rotation is cleared + self.image_editor.set_edit_param("straighten_angle", 0.0) + # Force QML to refresh if it's showing provider preview frames + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Crop cancelled") + log.info("Crop mode cancelled") + + @Slot() + def toggle_crop_mode(self): + """Toggle crop mode on/off.""" + self.ui_state.isCropping = not self.ui_state.isCropping + if self.ui_state.isCropping: + # Reset crop box when entering crop mode + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + # Set aspect ratios for QML dropdown + self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + self.ui_state.currentAspectRatioIndex = 0 + + # Pre-load image into editor to ensure smooth rotation + if self.image_files and self.current_index < len(self.image_files): + image_file = self.image_files[self.current_index] + filepath = image_file.path + editor_path = self.image_editor.current_filepath + + # Robust comparison + match = False + if editor_path: + try: + match = Path(editor_path).resolve() == Path(filepath).resolve() + except (OSError, ValueError): + match = str(editor_path) == str(filepath) + + if not match: + log.debug(f"toggle_crop_mode: Loading {filepath} into editor") + # Use cached preview if available to speed up using get_decoded_image(self.current_index) + # note: get_decoded_image verifies index bounds + cached_preview = self.get_decoded_image(self.current_index) + self.image_editor.load_image(str(filepath), cached_preview=cached_preview) + + # Reset rotation to 0 when starting fresh crop mode + self.image_editor.set_edit_param("straighten_angle", 0.0) + + self.update_status_message("Crop mode: Drag to select area, Enter to crop") + log.info("Crop mode enabled") + else: # Exiting crop mode + self.ui_state.isCropping = False + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + self.update_status_message("Crop cancelled") + log.info("Crop mode disabled") + + @Slot() + def stack_source_raws(self): + """ + Finds the source RAW files for the current stacked JPG and launches Helicon Focus. + """ + if not self.image_files or self.current_index >= len(self.image_files): + self.update_status_message("No image selected.") + return + + current_image_path = self.image_files[self.current_index].path + filename = current_image_path.name + + # Ensure it's a stacked JPG + if not filename.lower().endswith(" stacked.jpg"): + self.update_status_message("Current image is not a stacked JPG.") + return + + # Extract base name and number, e.g., "PB210633" from "20251121-PB210633 stacked.JPG" + match = re.search(r'([A-Z]+)(\d+)\s+stacked\.JPG', filename, re.IGNORECASE) + if not match: + self.update_status_message("Could not parse stacked JPG filename format.") + log.error("Could not parse stacked JPG filename: %s", filename) + return + + base_prefix = match.group(1) # e.g., "PB" + base_number_str = match.group(2) # e.g., "210633" + base_number = int(base_number_str) + + # Determine the RAW source directory + raw_source_dir_str = config.get('raw', 'source_dir') + if not raw_source_dir_str: + self.update_status_message("RAW source directory not configured in settings.") + log.warning("RAW source directory (raw.source_dir) is not set in config.") + return + + raw_base_dir = Path(raw_source_dir_str) + if not raw_base_dir.is_dir(): + self.update_status_message(f"RAW source directory not found: {raw_base_dir}") + log.warning("Configured RAW source directory does not exist: %s", raw_base_dir) + return + + # Get the mirror base from config + mirror_base_str = config.get('raw', 'mirror_base') + if not mirror_base_str: + self.update_status_message("RAW mirror base directory not configured in settings.") + log.warning("RAW mirror base (raw.mirror_base) is not set in config.") + return + + mirror_base_dir = Path(mirror_base_str) + if not mirror_base_dir.is_dir(): + self.update_status_message(f"RAW mirror base directory not found: {mirror_base_dir}") + log.warning("Configured RAW mirror base directory does not exist: %s", mirror_base_dir) + return + + # The date structure in the RAW directory mirrors the structure relative to the mirror_base + try: + relative_part = current_image_path.parent.relative_to(mirror_base_dir) + except ValueError: + self.update_status_message("Current image is not in the configured mirror base directory.") + log.error( + "Could not find relative path for '%s' from base '%s'. Check 'mirror_base' config.", + current_image_path.parent, + mirror_base_dir + ) + return + + raw_search_dir = raw_base_dir / relative_part + + if not raw_search_dir.is_dir(): + self.update_status_message(f"RAW directory for this date not found: {raw_search_dir}") + log.warning("RAW search directory does not exist: %s", raw_search_dir) + return + + # Find RAW files by decrementing the number + found_raw_files: List[Path] = [] + # Start one number less than the stacked image number + current_raw_number = base_number - 1 + + # Limit to reasonable number of RAWs to avoid infinite loop or too many files + max_raw_search = 15 # As per user request, typically between 3 and 15 + search_count = 0 + + while current_raw_number >= 0 and search_count < max_raw_search: + raw_filename_stem = f"{base_prefix}{current_raw_number:06d}" # e.g., PB210632 + + # Look for any of the common RAW extensions + potential_raw_paths = [] + for ext in RAW_EXTENSIONS: + potential_raw_paths.append(raw_search_dir / f"{raw_filename_stem}{ext}") + + found_this_number = False + for p in potential_raw_paths: + if p.is_file(): + found_raw_files.append(p) + found_this_number = True + break + + if not found_this_number: + # User specified "continue until there is a gap in the numbers" + # If we don't find any RAW for a number, assume it's a gap and stop + if found_raw_files: # Only break if we've found at least one file before this gap + break + + current_raw_number -= 1 + search_count += 1 + + if not found_raw_files: + self.update_status_message(f"No source RAW files found in {raw_search_dir} for {filename}.") + log.info("No source RAWs found for %s in %s", filename, raw_search_dir) + return + + # Sort the files by name to ensure Helicon Focus receives them in sequence + found_raw_files.sort() + + self.update_status_message(f"Launching Helicon Focus with {len(found_raw_files)} RAWs...") + log.info("Launching Helicon Focus for %s with RAWs: %s", filename, [str(p) for p in found_raw_files]) + success = self._launch_helicon_with_files(found_raw_files) + + if success: + # Mark as restacked on success + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + meta.restacked = True + meta.restacked_date = today + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + self.update_status_message("Helicon Focus launched successfully.") + else: + self.update_status_message("Failed to launch Helicon Focus.") + + + + + @Slot() + def execute_crop(self): + """Execute the crop operation: crop image, save, backup, and refresh.""" + if not self.image_files or self.current_index >= len(self.image_files): + self.update_status_message("No image to crop") + return + + if not self.ui_state.isCropping: + return + + # Capture current rotation (straighten_angle) from editor state BEFORE any reload + # This is the single source of truth since set_straighten_angle updates it live. + current_rotation = float(self.image_editor.current_edits.get("straighten_angle", 0.0)) + + crop_box_raw = self.ui_state.currentCropBox + + # Normalize crop_box_raw to a tuple of 4 ints + try: + # Handle QJSValue/QVariant wrapper if present + if hasattr(crop_box_raw, "toVariant"): + crop_box_raw = crop_box_raw.toVariant() + + # Convert list to tuple if needed + if isinstance(crop_box_raw, list): + crop_box_raw = tuple(crop_box_raw) + + if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: + raise ValueError(f"Expected 4-item tuple, got {type(crop_box_raw)}: {crop_box_raw}") + + # Coerce elements to int and clamp to [0, 1000] + l, t, r, b = [max(0, min(1000, int(x))) for x in crop_box_raw] + + # Ensure correct order (left <= right, top <= bottom) + crop_box_raw = (min(l, r), min(t, b), max(l, r), max(t, b)) + + except (ValueError, TypeError, AttributeError) as e: + log.warning("Invalid crop box format: %s", e) + self.update_status_message("Invalid crop selection") + return + + if crop_box_raw == (0, 0, 1000, 1000): + self.update_status_message("No crop area selected") + return + + # Ensure image is loaded in editor + image_file = self.image_files[self.current_index] + filepath = image_file.path + + # Robust path comparison + editor_path = self.image_editor.current_filepath + paths_match = False + if editor_path: + try: + paths_match = Path(editor_path).resolve() == Path(filepath).resolve() + except (OSError, ValueError): + paths_match = str(editor_path) == str(filepath) + + if not paths_match: + log.debug(f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}") + cached_preview = self.get_decoded_image(self.current_index) + if not self.image_editor.load_image(str(filepath), cached_preview=cached_preview): + self.update_status_message("Failed to load image for cropping") + return + + self.image_editor.set_crop_box(crop_box_raw) + + # Re-apply the captured rotation. + # This handles cases where we reloaded the image (resetting edits) or where UI state sync was flaky. + self.image_editor.set_edit_param('straighten_angle', current_rotation) + + # Save via ImageEditor (handles rotation + crop correctly) + try: + save_result = self.image_editor.save_image() + except RuntimeError as e: + log.warning(f"execute_crop: Save failed: {e}") + self.update_status_message(f"Failed to save cropped image: {e}") + return + except Exception as e: + log.exception(f"execute_crop: Unexpected error during save: {e}") + self.update_status_message("Failed to save cropped image") + return + + if save_result: + saved_path, backup_path = save_result + + # Track for undo + import time + timestamp = time.time() + self.undo_history.append(("crop", (str(saved_path), str(backup_path)), timestamp)) + + # Exit crop mode + self.ui_state.isCropping = False + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + + # Refresh the view + self.refresh_image_list() + + # Find the edited image + for i, img_file in enumerate(self.image_files): + if img_file.path == saved_path: + self.current_index = i + break + + # Invalidate cache and refresh display + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Reset zoom/pan + self.ui_state.resetZoomPan() + + if self.ui_state.isHistogramVisible: + self.update_histogram() + + self.update_status_message("Image cropped and saved") + log.info("Crop operation completed for %s", saved_path) + + # Force reload of editor to ensure subsequent edits operate on the cropped image + self.image_editor.clear() + self.reset_edit_parameters() + + else: + self.update_status_message("Failed to save cropped image") + + @Slot() + def auto_levels(self): + """Calculates and applies auto levels (preview only). Returns False if skipped.""" + if not self.image_files: + self.update_status_message("No image to adjust") + return False + + image_file = self.image_files[self.current_index] + filepath = str(image_file.path) + + # Ensure image is loaded in editor + if not self.image_editor.current_filepath or str(self.image_editor.current_filepath) != filepath: + cached_preview = self.get_decoded_image(self.current_index) + if not self.image_editor.load_image(filepath, cached_preview=cached_preview): + self.update_status_message("Failed to load image") + return False + + # Calculate auto levels + # Calculate auto levels - now returns (blacks, whites, p_low, p_high) + blacks, whites, p_low, p_high = self.image_editor.auto_levels(self.auto_level_threshold) + + # Auto-strength computation using stretch-factor capping + # + # Philosophy: threshold_percent defines acceptable clipping (e.g., 0.1% at each end). + # Auto-strength should NOT prevent that clipping - it's intentional. + # Instead, auto-strength prevents INSANE levels on low-dynamic-range images. + # + # Approach: Cap the stretch factor to a reasonable maximum (e.g., 3-4x). + # - Full strength: stretch = 255 / (p_high - p_low) + # - If stretch is reasonable (<= cap), use full strength + # - If stretch is extreme (> cap), blend to limit effective stretch to cap + # + if self.auto_level_strength_auto: + # Calculate full-strength stretch factor + dynamic_range = p_high - p_low + if dynamic_range < 1.0: + # Degenerate case: nearly flat image + strength = 0.0 + log.debug(f"Auto levels: degenerate dynamic range ({dynamic_range:.2f}), strength=0") + else: + stretch_full = 255.0 / dynamic_range + + # Cap stretch to prevent insane levels + # E.g., if image spans only 50-200 (range=150), full stretch would be 255/150 = 1.7x (fine) + # But if image spans 100-110 (range=10), full stretch would be 255/10 = 25.5x (insane!) + STRETCH_CAP = 4.0 # Maximum allowed stretch factor + + if stretch_full <= STRETCH_CAP: + # Reasonable stretch, use full strength + strength = 1.0 + else: + # Excessive stretch - blend to cap it + # effective_stretch = 1 + strength * (stretch_full - 1) = STRETCH_CAP + # solving for strength: strength = (STRETCH_CAP - 1) / (stretch_full - 1) + strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) + strength = max(0.0, min(1.0, strength)) + + log.debug(f"Auto levels: p_low={p_low:.1f}, p_high={p_high:.1f}, " + f"range={dynamic_range:.1f}, stretch_full={stretch_full:.2f}, strength={strength:.3f}") + else: + strength = self.auto_level_strength + + # Apply strength scaling to blacks and whites parameters + blacks *= strength + whites *= strength + + # Apply scaled values + self.image_editor.set_edit_param('blacks', blacks) + self.image_editor.set_edit_param('whites', whites) + + # Update UI state + self.ui_state.blacks = blacks + self.ui_state.whites = whites + + # Trigger preview update + self.ui_state.currentImageSourceChanged.emit() + + if self.ui_state.isHistogramVisible: + self.update_histogram() + + # Determine status message based on whether endpoints were pinned (clipping detected) + # We check p_high/p_low directly because whites/blacks might be small due to strength scaling + # even if not pinned. + msg = "Auto levels applied" + + # Check for essentially no-op (degenerate or already full range) + # Degenerate: dynamic range is tiny (< 1.0) + # Full range: p_low is near 0 and p_high near 255 + if abs(p_high - p_low) < 1.0: + msg = "Auto levels: no changes (degenerate range)" + elif p_low <= 0 and p_high >= 255: + # We already cover the full range + msg = "Auto levels: no changes (image already covers full range)" + # Check for pinning + elif p_high >= 255.0: + msg = "Auto levels: highlights already clipped; only adjusting shadows" + elif p_low <= 0.0: + msg = "Auto levels: shadows already clipped; only adjusting highlights" + + self._kick_preview_worker() + + self.update_status_message(f"{msg} (preview only)") + log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s", + filepath, self.auto_level_threshold, strength, msg) + return True + + @Slot() + def quick_auto_levels(self): + """Applies auto levels and immediately saves (with undo).""" + if not self.image_files: + self.update_status_message("No image to adjust") + return + + # Apply the preview first (loads image + sets params) + applied = self.auto_levels() + + # If in auto mode and no changes were made (skipped), don't save + if self.auto_level_strength_auto and not applied: + # Status message already set by auto_levels ("No changes made...") + return + + # Save + import time + try: + save_result = self.image_editor.save_image() + except RuntimeError as e: + log.warning(f"quick_auto_levels: Save failed: {e}") + self.update_status_message(f"Failed to save image: {e}") + return + except Exception as e: + log.exception(f"quick_auto_levels: Unexpected error during save: {e}") + self.update_status_message("Failed to save image") + return + + if save_result: + saved_path, backup_path = save_result + timestamp = time.time() + self.undo_history.append(("auto_levels", (saved_path, backup_path), timestamp)) + + # Force reload to ensure disk consistency + self.image_editor.clear() + + # Refresh list/cache/UI (standard save pattern) + # Note: We must locate the saved_path again because the list order + # might have changed (e.g., if a backup file was inserted before it). + self.refresh_image_list() + + # Find image again using robust path matching + new_index = -1 + target_name = Path(saved_path).name + + for i, img_file in enumerate(self.image_files): + # Match by filename alone - safest for flat directory structures + # avoiding drive letter/symlink/casing issues with full paths + if img_file.path.name == target_name: + new_index = i + break + + if new_index != -1: + self.current_index = new_index + else: + log.warning("Auto levels: Could not find saved image %s (name: %s) in refreshed list", saved_path, target_name) + + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + if self.ui_state.isHistogramVisible: + self.update_histogram() + + self.update_status_message("Auto levels applied and saved") + log.info("Quick auto levels saved for %s. New index: %d", saved_path, self.current_index) + else: + self.update_status_message("Failed to save image") + + + + @Slot() + def quick_auto_white_balance(self): + """Quickly apply auto white balance, save the image, and track for undo.""" + if not self.image_files: + self.update_status_message("No image to adjust") + return + + import time + image_file = self.image_files[self.current_index] + filepath = str(image_file.path) + + # Load the image into the editor if not already loaded + cached_preview = self.get_decoded_image(self.current_index) + if not self.image_editor.load_image(filepath, cached_preview=cached_preview): + self.update_status_message("Failed to load image") + return + + # Calculate and apply auto white balance + self.auto_white_balance() + + # Save the edited image (this creates a backup automatically) + try: + save_result = self.image_editor.save_image() + except RuntimeError as e: + log.warning(f"quick_auto_white_balance: Save failed: {e}") + self.update_status_message(f"Failed to save image: {e}") + return + except Exception as e: + log.exception(f"quick_auto_white_balance: Unexpected error during save: {e}") + self.update_status_message("Failed to save image") + return + + if save_result: + saved_path, backup_path = save_result + # Track this action for undo + timestamp = time.time() + self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) + + # Force the image editor to clear its current state so it reloads fresh + self.image_editor.clear() + + # Refresh the view - need to refresh image list since backup file was created + original_path = Path(filepath) + self.refresh_image_list() + + # Find the edited image (not the backup) in the refreshed list + for i, img_file in enumerate(self.image_files): + if img_file.path == original_path: + self.current_index = i + break + + # Invalidate cache for the edited image so it's reloaded from disk + # This ensures the Image Editor will see the updated version + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + + self.update_status_message("Auto white balance applied and saved") + log.info("Quick auto white balance applied to %s", filepath) + else: + self.update_status_message("Failed to save image") + + @Slot() + def auto_white_balance(self): + """ + Dispatcher for auto white balance. Calls the appropriate method based on + the mode set in the config ('lab' or 'rgb'). + """ + mode = config.get('awb', 'mode', fallback='lab') + if mode == 'lab': + self.auto_white_balance_lab() + elif mode == 'rgb': + self.auto_white_balance_legacy() + else: + log.error(f"Unknown AWB mode: {mode}") + self.update_status_message(f"Error: Unknown AWB mode '{mode}'") + + def auto_white_balance_legacy(self): + """ + Calculates and applies auto white balance using the legacy grey world + assumption on the entire RGB image. + """ + if not self.image_editor.original_image: + log.warning("No image loaded in editor for auto white balance") + return + + try: + import numpy as np + except ImportError: + log.error("NumPy not found. Please install with: pip install numpy") + self.update_status_message("Error: NumPy not installed") + return + + log.info("Applying legacy (RGB Grey World) Auto White Balance") + + img = self.image_editor.original_image + arr = np.array(img, dtype=np.float32) + + r_mean = arr[:, :, 0].mean() + g_mean = arr[:, :, 1].mean() + b_mean = arr[:, :, 2].mean() + + grey_target = (r_mean + g_mean + b_mean) / 3.0 + + r_diff = r_mean - grey_target + g_diff = g_mean - grey_target + + by_shift = -(r_diff + g_diff) / 2.0 + mg_shift = -(r_diff - g_diff) / 2.0 + + by_value = by_shift / 63.75 + mg_value = mg_shift / 63.75 + + by_value = float(np.clip(by_value, -1.0, 1.0)) + mg_value = float(np.clip(mg_value, -1.0, 1.0)) + + self.image_editor.set_edit_param('white_balance_by', by_value) + self.image_editor.set_edit_param('white_balance_mg', mg_value) + + self.ui_state.white_balance_by = by_value + self.ui_state.white_balance_mg = mg_value + + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Auto white balance applied (Legacy)") + + + def auto_white_balance_lab(self): + """ + Calculates and applies auto white balance using the Lab color space, + filtering out clipped and saturated pixels for a more robust result. + """ + if not self.image_editor.original_image: + log.warning("No image loaded in editor for auto white balance") + return + + try: + import cv2 + import numpy as np + except ImportError: + log.error("OpenCV or NumPy not found. Please install with: pip install opencv-python numpy") + self.update_status_message("Error: OpenCV or NumPy not installed") + return + + img = self.image_editor.original_image + # Ensure image is RGB before processing + if img.mode != 'RGB': + img = img.convert('RGB') + + arr = np.array(img, dtype=np.uint8) + + # --- Tunable Constants for Auto White Balance (from config) --- + _LOWER_BOUND_RGB = config.getint('awb', 'rgb_lower_bound', 5) + _UPPER_BOUND_RGB = config.getint('awb', 'rgb_upper_bound', 250) + _LUMA_LOWER_BOUND = config.getint('awb', 'luma_lower_bound', 30) + _LUMA_UPPER_BOUND = config.getint('awb', 'luma_upper_bound', 220) + warm_bias = config.getint('awb', 'warm_bias', 6) + tint_bias = config.getint('awb', 'tint_bias', 0) + _TARGET_A_LAB = 128.0 + tint_bias + _TARGET_B_LAB = 128.0 + warm_bias + _SCALING_FACTOR_LAB_TO_SLIDER = 128.0 + _CORRECTION_STRENGTH = config.getfloat('awb', 'strength', 0.7) + + # --- 1. Reject clipped channels and use a luma midtone mask --- + mask = ( + (arr[:, :, 0] > _LOWER_BOUND_RGB) & (arr[:, :, 0] < _UPPER_BOUND_RGB) & + (arr[:, :, 1] > _LOWER_BOUND_RGB) & (arr[:, :, 1] < _UPPER_BOUND_RGB) & + (arr[:, :, 2] > _LOWER_BOUND_RGB) & (arr[:, :, 2] < _UPPER_BOUND_RGB) + ) + + luma = (0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2]) + mask &= (luma > _LUMA_LOWER_BOUND) & (luma < _LUMA_UPPER_BOUND) + + if not np.any(mask): + log.warning("Auto white balance: No pixels found after clipping and luma filter. Aborting.") + self.update_status_message("AWB failed: no valid pixels found") + return + + # --- 2. Work in Lab color space --- + lab_image = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB) + + a_channel = lab_image[:, :, 1] + b_channel = lab_image[:, :, 2] + + masked_a = a_channel[mask] + masked_b = b_channel[mask] + + a_mean = masked_a.mean() + b_mean = masked_b.mean() + + a_shift = _TARGET_A_LAB - a_mean + b_shift = _TARGET_B_LAB - b_mean + + log.info( + "Auto WB (Lab) - means: a*=%.1f, b*=%.1f; targets: a*=%.1f, b*=%.1f; shifts: a*=%.1f, b*=%.1f", + a_mean, b_mean, _TARGET_A_LAB, _TARGET_B_LAB, a_shift, b_shift + ) + + # --- 3. Convert Lab shift to our slider values with strength factor --- + by_value = (b_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH + mg_value = (a_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH + + by_value = float(np.clip(by_value, -1.0, 1.0)) + mg_value = float(np.clip(mg_value, -1.0, 1.0)) + + log.info(f"Auto white balance values: B/Y={by_value:.3f}, M/G={mg_value:.3f}") + + self.image_editor.set_edit_param('white_balance_by', by_value) + self.image_editor.set_edit_param('white_balance_mg', mg_value) + + self.ui_state.white_balance_by = by_value + self.ui_state.white_balance_mg = mg_value + + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Auto white balance applied") + + def _get_stack_info(self, index: int) -> str: + info = "" + for i, (start, end) in enumerate(self.stacks): + if start <= index <= end: + count_in_stack = end - start + 1 + pos_in_stack = index - start + 1 + info = f"Stack {i+1} ({pos_in_stack}/{count_in_stack})" + break + if not info and self.stack_start_index is not None and self.stack_start_index == index: + info = "Stack Start Marked" + log.debug("_get_stack_info for index %d: %s", index, info) + return info + + def _get_batch_info(self, index: int) -> str: + """Get batch info for the given index.""" + info = "" + # Check if current image is in any batch + in_batch = False + for start, end in self.batches: + if start <= index <= end: + in_batch = True + break + + if in_batch: + # Calculate total count across all batches + total_count = sum(end - start + 1 for start, end in self.batches) + info = f"{total_count} in Batch" + elif self.batch_start_index is not None and self.batch_start_index == index: + info = "Batch Start Marked" + + log.debug("_get_batch_info for index %d: %s", index, info) + return info + + def get_stack_summary(self) -> str: + if not self.stacks: + return "No stacks defined." + summary = [] + for i, (start, end) in enumerate(self.stacks): + summary.append(f"Stack {i+1}: {start}-{end}") + return "; ".join(summary) + + def is_stacked(self) -> bool: + if not self.image_files or self.current_index >= len(self.image_files): + return False + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + return meta.stacked + + def _update_cache_stats(self): + if self.debug_cache: + hits = self.image_cache.hits + misses = self.image_cache.misses + total = hits + misses + hit_rate = (hits / total * 100) if total > 0 else 0 + size_mb = self.image_cache.currsize / (1024 * 1024) + self.ui_state.cacheStats = f"Cache: {hits} hits, {misses} misses ({hit_rate:.1f}%), {size_mb:.1f} MB" + +def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): + """FastStack Application Entry Point""" + global _debug_mode + _debug_mode = debug + + t0 = time.perf_counter() + setup_logging(debug) + if debug: + log.info("Startup: after setup_logging: %.3fs", time.perf_counter() - t0) + log.info("Starting FastStack") + + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") + + app = QApplication(sys.argv) # QApplication is correct for desktop apps with widgets + if debug: + log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) + + if not image_dir: + image_dir_str = config.get('core', 'default_directory') + if not image_dir_str: + log.warning("No image directory provided and no default directory set. Opening directory selection dialog.") + selected_dir = QFileDialog.getExistingDirectory(None, "Select Image Directory") + if not selected_dir: + log.error("No image directory selected. Exiting.") + sys.exit(1) + image_dir_str = selected_dir + image_dir_path = Path(image_dir_str) + else: + image_dir_path = Path(image_dir) + + if not image_dir_path.is_dir(): + log.error("Image directory not found: %s", image_dir_path) + sys.exit(1) + app.setOrganizationName("FastStack") + app.setOrganizationDomain("faststack.dev") + app.setApplicationName("FastStack") + + engine = QQmlApplicationEngine() + engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml")) + engine.addImportPath("qrc:/qt-project.org/imports") + engine.addImportPath(os.path.join(os.path.dirname(__file__), "qml")) + # Add the path to Qt5Compat.GraphicalEffects to QML import paths + engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat")) + + controller = AppController(image_dir_path, engine, debug_cache=debug_cache) + if debug: + log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) + image_provider = ImageProvider(controller) + engine.addImageProvider("provider", image_provider) + + # Expose controller and UI state to QML + context = engine.rootContext() + context.setContextProperty("uiState", controller.ui_state) + context.setContextProperty("controller", controller) + + qml_file = Path(__file__).parent / "qml" / "Main.qml" + engine.load(QUrl.fromLocalFile(str(qml_file))) + if debug: + log.info("Startup: after engine.load(QML): %.3fs", time.perf_counter() - t0) + + if not engine.rootObjects(): + log.error("Failed to load QML.") + sys.exit(-1) + + # Connect key events from the main window + main_window = engine.rootObjects()[0] + controller.main_window = main_window + main_window.installEventFilter(controller) + + # Load data and start services + controller.load() + if debug: + log.info("Startup: after controller.load(): %.3fs", time.perf_counter() - t0) + + # Graceful shutdown + app.aboutToQuit.connect(controller.shutdown) + + sys.exit(app.exec()) + +def cli(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description="FastStack - Ultra-fast JPG Viewer for Focus Stacking Selection") + parser.add_argument("image_dir", nargs="?", default="", help="Directory of images to view") + parser.add_argument("--debug", action="store_true", help="Enable debug logging and timing information") + parser.add_argument("--debugcache", action="store_true", help="Enable debug cache features") + args = parser.parse_args() + main(image_dir=args.image_dir, debug=args.debug, debug_cache=args.debugcache) + +if __name__ == "__main__": + cli() diff --git a/faststack/check_scipy.py b/faststack/check_scipy.py index b6a42a7..a8506a2 100644 --- a/faststack/check_scipy.py +++ b/faststack/check_scipy.py @@ -1,6 +1,6 @@ - -try: - import scipy.ndimage - print("scipy available") -except ImportError: - print("scipy NOT available") + +try: + import scipy.ndimage + print("scipy available") +except ImportError: + print("scipy NOT available") diff --git a/faststack/config.py b/faststack/config.py index 0fcda7a..7c273ae 100644 --- a/faststack/config.py +++ b/faststack/config.py @@ -1,194 +1,194 @@ -"""Manages application configuration via an INI file.""" - -import configparser -import logging -import sys -import glob -import os -import re -from pathlib import Path, PureWindowsPath - -from faststack.logging_setup import get_app_data_dir - -log = logging.getLogger(__name__) - - -def detect_rawtherapee_path(): - """Attempts to find the RawTherapee executable on Windows.""" - if sys.platform != "win32": - return None - - # Pattern to match RawTherapee CLI installations in Program Files (both x64 and x86) - # The CLI version (rawtherapee-cli.exe) is required for batch processing with -t -Y -o -c flags - # Finds paths like C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe - base_patterns = [ - r"C:\Program Files\RawTherapee*\**\rawtherapee-cli.exe", - r"C:\Program Files (x86)\RawTherapee*\**\rawtherapee-cli.exe" - ] - - try: - matches = [] - for pattern in base_patterns: - matches.extend(glob.glob(pattern, recursive=True)) - - if not matches: - return None - - # Helper to extract version numbers for natural sorting - # e.g., "5.10" -> [5, 10] - def version_sort_key(path): - for part in reversed(PureWindowsPath(path).parts): - if re.fullmatch(r'\d+(?:\.\d+)*', part): - return [int(n) for n in part.split(".")] - return [0] - - # Sort matches to try and get the latest version (by path name) - # 5.10 > 5.9 - matches.sort(key=version_sort_key, reverse=True) - return matches[0] - except Exception as e: - log.warning(f"Error detecting RawTherapee path: {e}") - return None - - -# Determine default RawTherapee CLI path based on OS -# The CLI version is required for batch processing with command-line flags -if sys.platform == "win32": - DEFAULT_RT_PATH = r"C:\Program Files\RawTherapee\5.12\rawtherapee-cli.exe" -elif sys.platform == "darwin": - DEFAULT_RT_PATH = "/Applications/RawTherapee.app/Contents/MacOS/rawtherapee-cli" -else: - DEFAULT_RT_PATH = "/usr/bin/rawtherapee-cli" - -DEFAULT_CONFIG = { - "core": { - "cache_size_gb": "1.5", - "prefetch_radius": "4", - "theme": "dark", - "default_directory": "", - "optimize_for": "speed", # "speed" or "quality" - - # --- Auto Levels Configuration --- - # - # Behavior: - # Auto Levels are triggered when the user explicitly clicks "Auto Levels" in the - # image editor or uses the "Quick Auto Levels" hotkey. - # - # Algorithm: - # 1. Compute black/white points by clipping `auto_level_threshold` fraction of pixels - # (0.0-1.0) at the dark and light ends of the histogram. - # 2. Construct a levels transform to map these points to 0 and 255. - # 3. Blend the transformed image with the original using `auto_level_strength`. - # 4. If `auto_level_strength_auto` is True, `auto_level_strength` acts as a maximum; - # the system will automatically reduce the applied strength if the computed - # transform would cause excessive clipping or color instability. - # - # Practical Tuning: - # - auto_level_threshold: A fraction (not percent). - # Higher values (e.g. 0.05 = 5%) increase contrast but risk hard clipping. - # Lower values (e.g. 0.001 = 0.1%) are gentler and preserve more dynamic range. - # - auto_level_strength: 1.0 applies the full mathematical correction. Lower values - # blend the result for a subtler effect. - - "auto_level_threshold": "0.1", - "auto_level_strength": "1.0", - "auto_level_strength_auto": "False", - }, - "helicon": { - "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", - "args": "", - }, - "photoshop": { - "exe": "C:\\Program Files\\Adobe\\Adobe Photoshop 2026\\Photoshop.exe", - "args": "", - }, - "color": { - "mode": "none", # Options: "none", "saturation", "icc" - "saturation_factor": "0.85", # For 'saturation' mode: 0.0-1.0, lower = less saturated - "monitor_icc_path": "", # For 'icc' mode: path to monitor ICC profile - }, - "awb": { - "mode": "lab", # "lab" or "rgb" - "strength": "0.7", - "warm_bias": "6", - "tint_bias": "0", - "luma_lower_bound": "30", - "luma_upper_bound": "220", - "rgb_lower_bound": "5", - "rgb_upper_bound": "250", - }, - "rawtherapee": { - "exe": DEFAULT_RT_PATH, - "args": "", - }, - "raw": { - "source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos", - "mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom", - } -} - -class AppConfig: - def __init__(self): - self.config_path = get_app_data_dir() / "faststack.ini" - self.config = configparser.ConfigParser() - self.load() - - def load(self): - """Loads the config, creating it with defaults if it doesn't exist.""" - if not self.config_path.exists(): - log.info(f"Creating default config at {self.config_path}") - self.config.read_dict(DEFAULT_CONFIG) - self.save() - else: - log.info(f"Loading config from {self.config_path}") - self.config.read(self.config_path) - # Ensure all sections and keys exist - for section, keys in DEFAULT_CONFIG.items(): - if not self.config.has_section(section): - self.config.add_section(section) - for key, value in keys.items(): - if not self.config.has_option(section, key): - self.config.set(section, key, value) - self.save() # Save to add any missing keys - - # Validate RawTherapee path (re-detect if missing) - if sys.platform == "win32": - current_rt_path = self.get("rawtherapee", "exe") - if not os.path.exists(current_rt_path): - log.warning(f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection...") - new_path = detect_rawtherapee_path() - if new_path and new_path != current_rt_path: - log.info(f"Found new RawTherapee path: {new_path}") - self.set("rawtherapee", "exe", new_path) - self.save() - - - def save(self): - """Saves the current configuration to the INI file.""" - try: - self.config_path.parent.mkdir(parents=True, exist_ok=True) - with self.config_path.open("w") as f: - self.config.write(f) - log.info(f"Saved config to {self.config_path}") - except IOError as e: - log.error(f"Failed to save config to {self.config_path}: {e}") - - def get(self, section, key, fallback=None): - return self.config.get(section, key, fallback=fallback) - - def getint(self, section, key, fallback=None): - return self.config.getint(section, key, fallback=fallback) - - def getfloat(self, section, key, fallback=None): - return self.config.getfloat(section, key, fallback=fallback) - - def getboolean(self, section, key, fallback=None): - return self.config.getboolean(section, key, fallback=fallback) - - def set(self, section, key, value): - if not self.config.has_section(section): - self.config.add_section(section) - self.config.set(section, key, str(value)) - -# Global config instance -config = AppConfig() +"""Manages application configuration via an INI file.""" + +import configparser +import logging +import sys +import glob +import os +import re +from pathlib import Path, PureWindowsPath + +from faststack.logging_setup import get_app_data_dir + +log = logging.getLogger(__name__) + + +def detect_rawtherapee_path(): + """Attempts to find the RawTherapee executable on Windows.""" + if sys.platform != "win32": + return None + + # Pattern to match RawTherapee CLI installations in Program Files (both x64 and x86) + # The CLI version (rawtherapee-cli.exe) is required for batch processing with -t -Y -o -c flags + # Finds paths like C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe + base_patterns = [ + r"C:\Program Files\RawTherapee*\**\rawtherapee-cli.exe", + r"C:\Program Files (x86)\RawTherapee*\**\rawtherapee-cli.exe" + ] + + try: + matches = [] + for pattern in base_patterns: + matches.extend(glob.glob(pattern, recursive=True)) + + if not matches: + return None + + # Helper to extract version numbers for natural sorting + # e.g., "5.10" -> [5, 10] + def version_sort_key(path): + for part in reversed(PureWindowsPath(path).parts): + if re.fullmatch(r'\d+(?:\.\d+)*', part): + return [int(n) for n in part.split(".")] + return [0] + + # Sort matches to try and get the latest version (by path name) + # 5.10 > 5.9 + matches.sort(key=version_sort_key, reverse=True) + return matches[0] + except Exception as e: + log.warning(f"Error detecting RawTherapee path: {e}") + return None + + +# Determine default RawTherapee CLI path based on OS +# The CLI version is required for batch processing with command-line flags +if sys.platform == "win32": + DEFAULT_RT_PATH = r"C:\Program Files\RawTherapee\5.12\rawtherapee-cli.exe" +elif sys.platform == "darwin": + DEFAULT_RT_PATH = "/Applications/RawTherapee.app/Contents/MacOS/rawtherapee-cli" +else: + DEFAULT_RT_PATH = "/usr/bin/rawtherapee-cli" + +DEFAULT_CONFIG = { + "core": { + "cache_size_gb": "1.5", + "prefetch_radius": "4", + "theme": "dark", + "default_directory": "", + "optimize_for": "speed", # "speed" or "quality" + + # --- Auto Levels Configuration --- + # + # Behavior: + # Auto Levels are triggered when the user explicitly clicks "Auto Levels" in the + # image editor or uses the "Quick Auto Levels" hotkey. + # + # Algorithm: + # 1. Compute black/white points by clipping `auto_level_threshold` fraction of pixels + # (0.0-1.0) at the dark and light ends of the histogram. + # 2. Construct a levels transform to map these points to 0 and 255. + # 3. Blend the transformed image with the original using `auto_level_strength`. + # 4. If `auto_level_strength_auto` is True, `auto_level_strength` acts as a maximum; + # the system will automatically reduce the applied strength if the computed + # transform would cause excessive clipping or color instability. + # + # Practical Tuning: + # - auto_level_threshold: A fraction (not percent). + # Higher values (e.g. 0.05 = 5%) increase contrast but risk hard clipping. + # Lower values (e.g. 0.001 = 0.1%) are gentler and preserve more dynamic range. + # - auto_level_strength: 1.0 applies the full mathematical correction. Lower values + # blend the result for a subtler effect. + + "auto_level_threshold": "0.1", + "auto_level_strength": "1.0", + "auto_level_strength_auto": "False", + }, + "helicon": { + "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", + "args": "", + }, + "photoshop": { + "exe": "C:\\Program Files\\Adobe\\Adobe Photoshop 2026\\Photoshop.exe", + "args": "", + }, + "color": { + "mode": "none", # Options: "none", "saturation", "icc" + "saturation_factor": "0.85", # For 'saturation' mode: 0.0-1.0, lower = less saturated + "monitor_icc_path": "", # For 'icc' mode: path to monitor ICC profile + }, + "awb": { + "mode": "lab", # "lab" or "rgb" + "strength": "0.7", + "warm_bias": "6", + "tint_bias": "0", + "luma_lower_bound": "30", + "luma_upper_bound": "220", + "rgb_lower_bound": "5", + "rgb_upper_bound": "250", + }, + "rawtherapee": { + "exe": DEFAULT_RT_PATH, + "args": "", + }, + "raw": { + "source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos", + "mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom", + } +} + +class AppConfig: + def __init__(self): + self.config_path = get_app_data_dir() / "faststack.ini" + self.config = configparser.ConfigParser() + self.load() + + def load(self): + """Loads the config, creating it with defaults if it doesn't exist.""" + if not self.config_path.exists(): + log.info(f"Creating default config at {self.config_path}") + self.config.read_dict(DEFAULT_CONFIG) + self.save() + else: + log.info(f"Loading config from {self.config_path}") + self.config.read(self.config_path) + # Ensure all sections and keys exist + for section, keys in DEFAULT_CONFIG.items(): + if not self.config.has_section(section): + self.config.add_section(section) + for key, value in keys.items(): + if not self.config.has_option(section, key): + self.config.set(section, key, value) + self.save() # Save to add any missing keys + + # Validate RawTherapee path (re-detect if missing) + if sys.platform == "win32": + current_rt_path = self.get("rawtherapee", "exe") + if not os.path.exists(current_rt_path): + log.warning(f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection...") + new_path = detect_rawtherapee_path() + if new_path and new_path != current_rt_path: + log.info(f"Found new RawTherapee path: {new_path}") + self.set("rawtherapee", "exe", new_path) + self.save() + + + def save(self): + """Saves the current configuration to the INI file.""" + try: + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with self.config_path.open("w") as f: + self.config.write(f) + log.info(f"Saved config to {self.config_path}") + except IOError as e: + log.error(f"Failed to save config to {self.config_path}: {e}") + + def get(self, section, key, fallback=None): + return self.config.get(section, key, fallback=fallback) + + def getint(self, section, key, fallback=None): + return self.config.getint(section, key, fallback=fallback) + + def getfloat(self, section, key, fallback=None): + return self.config.getfloat(section, key, fallback=fallback) + + def getboolean(self, section, key, fallback=None): + return self.config.getboolean(section, key, fallback=fallback) + + def set(self, section, key, value): + if not self.config.has_section(section): + self.config.add_section(section) + self.config.set(section, key, str(value)) + +# Global config instance +config = AppConfig() diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index 3ebfd45..47ccc9e 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -520,16 +520,14 @@ def load_image( cached_preview.buffer, dtype=np.uint8 ).reshape((cached_preview.height, cached_preview.width, 3)) - # IMPORTANT: The cached_preview coming from the viewer is already "cooked" - # (it has Color Management / Saturation applied). - # Our editor expects a "raw" float buffer (non-managed) as its starting point for _apply_edits. - # To prevent a color "pop" when edits start, we have two choices: - # 1. "Un-cook" the preview (expensive/inaccurate). - # 2. Use the cooked preview for the VERY FIRST frame, but immediately - # re-render from the master float_image in the background. - # Since we already apply EXIF orientation to the master float_image above, - # we should also ensure the preview_arr matches orientation if it doesn't already. - # Generally, the Prefetcher already applies orientation to the cached preview. + # IMPORTANT: The cached_preview coming from the Prefetcher already has + # EXIF orientation applied (in prefetch.py's "Unified EXIF Orientation Application"). + # Do NOT apply orientation again here - that would cause double rotation! + # The cached_preview is also "cooked" (has Color Management / Saturation applied). + # We use it for the VERY FIRST frame for fast display, then immediately + # re-render from the master float_image in the background. + log.debug("Using cached preview (assumed orientation-correct from prefetcher)") + loaded_float_preview = preview_arr.astype(np.float32) / 255.0 else: # Downscale from float_image (which now has orientation applied) @@ -538,9 +536,8 @@ def load_image( thumb_rgb = thumb.convert("RGB") loaded_float_preview = np.array(thumb_rgb).astype(np.float32) / 255.0 - # If we applied orientation to the original, the thumbnail will already be correct - # because we derived it from loaded_original AFTER exif_transpose. - # If we derived from cached_preview, we might still need to apply orientation. + # Thumbnail is derived from loaded_original AFTER exif_transpose, + # so orientation is already correct. # Assign all state atomically under lock to prevent race with preview worker with self._lock: @@ -1718,7 +1715,10 @@ def save_image( exif_bytes = sanitize_exif_orientation(self._source_exif_bytes) elif self.original_image: # Fallback to current image's EXIF (may be empty for TIFFs) - exif_bytes = self.original_image.info.get("exif") + # Must sanitize orientation because we baked it on load! + exif_bytes = sanitize_exif_orientation( + self.original_image.info.get("exif") + ) # Use the same uint8 data # Legacy soft shoulder moved to linear space diff --git a/faststack/imaging/jpeg.py b/faststack/imaging/jpeg.py index cf23033..0d2cd4a 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -1,181 +1,181 @@ -"""High-performance JPEG decoding using PyTurboJPEG with a Pillow fallback.""" - -import logging -from typing import Optional, Tuple - -import numpy as np -from PIL import Image - -log = logging.getLogger(__name__) - -# Attempt to import PyTurboJPEG - -try: - from turbojpeg import TurboJPEG, TJPF_RGB -except ImportError: - jpeg_decoder = None - TURBO_AVAILABLE = False - log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") -else: - try: - jpeg_decoder = TurboJPEG() - except Exception: - jpeg_decoder = None - TURBO_AVAILABLE = False - log.exception("PyTurboJPEG initialization failed. Falling back to Pillow.") - else: - TURBO_AVAILABLE = True - log.info("PyTurboJPEG is available. Using it for JPEG decoding.") - - -def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.ndarray]: - """Decodes JPEG bytes into an RGB numpy array.""" - if TURBO_AVAILABLE and jpeg_decoder: - try: - # Decode with proper color space handling (no TJFLAG_FASTDCT) - # This ensures proper YCbCr->RGB conversion with correct gamma - flags = 0 - if fast_dct: - # TJFLAG_FASTDCT = 2048 - flags |= 2048 - return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=flags) - except Exception as e: - log.exception(f"PyTurboJPEG failed to decode image: {e}. Trying Pillow.") - # Fall through to Pillow fallback - - # Fallback to Pillow - try: - from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") - return np.array(img) - except Exception as e: - log.exception(f"Pillow also failed to decode image: {e}") - return None - - -def decode_jpeg_thumb_rgb( - jpeg_bytes: bytes, - max_dim: int = 256 -) -> Optional[np.ndarray]: - """Decodes a JPEG into a thumbnail-sized RGB numpy array.""" - if TURBO_AVAILABLE and jpeg_decoder: - try: - # Get image header to determine dimensions - width, height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) - - # Find the best scaling factor - scaling_factor = _get_turbojpeg_scaling_factor(width, height, max_dim) - - decoded = jpeg_decoder.decode( - jpeg_bytes, - scaling_factor=scaling_factor, - pixel_format=TJPF_RGB, - flags=0, # Proper color space handling - ) - if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim: - img = Image.fromarray(decoded) - img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS) - return np.array(img) - return decoded - except Exception as e: - log.exception(f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow.") - - # Fallback to Pillow - try: - from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)) - img.thumbnail((max_dim, max_dim)) - return np.array(img.convert("RGB")) - except Exception as e: - log.exception(f"Pillow also failed to decode thumbnail: {e}") - return None - - -def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Optional[Tuple[int, int]]: - """Finds the best libjpeg-turbo scaling factor to get a thumbnail <= max_dim.""" - if not TURBO_AVAILABLE or not jpeg_decoder: - return None - - # PyTurboJPEG provides a set of supported scaling factors - supported_factors = sorted( - jpeg_decoder.scaling_factors, - key=lambda x: x[0] / x[1], - reverse=True, - ) - - for num, den in supported_factors: - if (width * num / den) <= max_dim and (height * num / den) <= max_dim: - return (num, den) - - # If no suitable factor is found, return the smallest one - return supported_factors[-1] if supported_factors else None - - -def decode_jpeg_resized( - jpeg_bytes: bytes, width: int, height: int, fast_dct: bool = False -) -> Optional[np.ndarray]: - """Decodes and resizes a JPEG to fit within the given dimensions.""" - if width <= 0 or height <= 0: - return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct) - - if TURBO_AVAILABLE and jpeg_decoder: - try: - # Get image header to determine dimensions - img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) - - # Determine which dimension is the limiting factor - if img_width * height > img_height * width: - # Image is wider relative to target box; width is the constraint - max_dim = width - else: - # Image is taller relative to target box; height is the constraint - max_dim = height - - scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) - - if scale_factor: - flags = 0 - if fast_dct: - # TJFLAG_FASTDCT = 2048 - flags |= 2048 - - decoded = jpeg_decoder.decode( - jpeg_bytes, - scaling_factor=scale_factor, - pixel_format=TJPF_RGB, - flags=flags # Proper color space handling - ) - - # Only use Pillow for final resize if needed - if decoded.shape[0] > height or decoded.shape[1] > width: - from io import BytesIO - img = Image.fromarray(decoded) - # Use BILINEAR for speed - img.thumbnail((width, height), Image.Resampling.BILINEAR) - return np.array(img) - return decoded - except Exception as e: - log.exception(f"PyTurboJPEG failed: {e}") - - # Fallback to Pillow (existing code) - try: - from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)) - - - if width <= 0 or height <= 0: - return np.array(img.convert("RGB")) - - scale_factor_ratio = min(img.width / width, img.height / height) - - # Use faster BILINEAR for large downscales, LANCZOS for smaller - if scale_factor_ratio > 4: - resampling = Image.Resampling.BILINEAR # Much faster - else: - resampling = Image.Resampling.LANCZOS # Higher quality for smaller downscales - - img.thumbnail((width, height), resampling) - return np.array(img.convert("RGB")) - except Exception as e: - log.exception(f"Pillow failed to decode and resize image: {e}") - return None +"""High-performance JPEG decoding using PyTurboJPEG with a Pillow fallback.""" + +import logging +from typing import Optional, Tuple + +import numpy as np +from PIL import Image + +log = logging.getLogger(__name__) + +# Attempt to import PyTurboJPEG + +try: + from turbojpeg import TurboJPEG, TJPF_RGB +except ImportError: + jpeg_decoder = None + TURBO_AVAILABLE = False + log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") +else: + try: + jpeg_decoder = TurboJPEG() + except Exception: + jpeg_decoder = None + TURBO_AVAILABLE = False + log.exception("PyTurboJPEG initialization failed. Falling back to Pillow.") + else: + TURBO_AVAILABLE = True + log.info("PyTurboJPEG is available. Using it for JPEG decoding.") + + +def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.ndarray]: + """Decodes JPEG bytes into an RGB numpy array.""" + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Decode with proper color space handling (no TJFLAG_FASTDCT) + # This ensures proper YCbCr->RGB conversion with correct gamma + flags = 0 + if fast_dct: + # TJFLAG_FASTDCT = 2048 + flags |= 2048 + return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=flags) + except Exception as e: + log.exception(f"PyTurboJPEG failed to decode image: {e}. Trying Pillow.") + # Fall through to Pillow fallback + + # Fallback to Pillow + try: + from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") + return np.array(img) + except Exception as e: + log.exception(f"Pillow also failed to decode image: {e}") + return None + + +def decode_jpeg_thumb_rgb( + jpeg_bytes: bytes, + max_dim: int = 256 +) -> Optional[np.ndarray]: + """Decodes a JPEG into a thumbnail-sized RGB numpy array.""" + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Get image header to determine dimensions + width, height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + + # Find the best scaling factor + scaling_factor = _get_turbojpeg_scaling_factor(width, height, max_dim) + + decoded = jpeg_decoder.decode( + jpeg_bytes, + scaling_factor=scaling_factor, + pixel_format=TJPF_RGB, + flags=0, # Proper color space handling + ) + if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim: + img = Image.fromarray(decoded) + img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS) + return np.array(img) + return decoded + except Exception as e: + log.exception(f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow.") + + # Fallback to Pillow + try: + from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)) + img.thumbnail((max_dim, max_dim)) + return np.array(img.convert("RGB")) + except Exception as e: + log.exception(f"Pillow also failed to decode thumbnail: {e}") + return None + + +def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Optional[Tuple[int, int]]: + """Finds the best libjpeg-turbo scaling factor to get a thumbnail <= max_dim.""" + if not TURBO_AVAILABLE or not jpeg_decoder: + return None + + # PyTurboJPEG provides a set of supported scaling factors + supported_factors = sorted( + jpeg_decoder.scaling_factors, + key=lambda x: x[0] / x[1], + reverse=True, + ) + + for num, den in supported_factors: + if (width * num / den) <= max_dim and (height * num / den) <= max_dim: + return (num, den) + + # If no suitable factor is found, return the smallest one + return supported_factors[-1] if supported_factors else None + + +def decode_jpeg_resized( + jpeg_bytes: bytes, width: int, height: int, fast_dct: bool = False +) -> Optional[np.ndarray]: + """Decodes and resizes a JPEG to fit within the given dimensions.""" + if width <= 0 or height <= 0: + return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct) + + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Get image header to determine dimensions + img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + + # Determine which dimension is the limiting factor + if img_width * height > img_height * width: + # Image is wider relative to target box; width is the constraint + max_dim = width + else: + # Image is taller relative to target box; height is the constraint + max_dim = height + + scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) + + if scale_factor: + flags = 0 + if fast_dct: + # TJFLAG_FASTDCT = 2048 + flags |= 2048 + + decoded = jpeg_decoder.decode( + jpeg_bytes, + scaling_factor=scale_factor, + pixel_format=TJPF_RGB, + flags=flags # Proper color space handling + ) + + # Only use Pillow for final resize if needed + if decoded.shape[0] > height or decoded.shape[1] > width: + from io import BytesIO + img = Image.fromarray(decoded) + # Use BILINEAR for speed + img.thumbnail((width, height), Image.Resampling.BILINEAR) + return np.array(img) + return decoded + except Exception as e: + log.exception(f"PyTurboJPEG failed: {e}") + + # Fallback to Pillow (existing code) + try: + from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)) + + + if width <= 0 or height <= 0: + return np.array(img.convert("RGB")) + + scale_factor_ratio = min(img.width / width, img.height / height) + + # Use faster BILINEAR for large downscales, LANCZOS for smaller + if scale_factor_ratio > 4: + resampling = Image.Resampling.BILINEAR # Much faster + else: + resampling = Image.Resampling.LANCZOS # Higher quality for smaller downscales + + img.thumbnail((width, height), resampling) + return np.array(img.convert("RGB")) + except Exception as e: + log.exception(f"Pillow failed to decode and resize image: {e}") + return None diff --git a/faststack/imaging/math_utils.py b/faststack/imaging/math_utils.py index 8c78a82..414cab6 100644 --- a/faststack/imaging/math_utils.py +++ b/faststack/imaging/math_utils.py @@ -1,266 +1,262 @@ -import numpy as np -from typing import Optional, Dict - -# ---------------------------- -# sRGB ↔ Linear Conversion Helpers -# ---------------------------- - -def _srgb_to_linear(x: np.ndarray) -> np.ndarray: - """Convert sRGB values to linear light. - - Preserves headroom (values > 1.0) for highlight recovery. - Clamps negatives to 0 since the power function requires non-negative input. - """ - # Clamp negatives to 0, but preserve values > 1.0 for headroom - x = np.clip(x, 0.0, None) - a = 0.055 - - # Apply the standard sRGB transfer function - works for values > 1.0 too - return np.where(x <= 0.04045, x / 12.92, ((x + a) / (1.0 + a)) ** 2.4) - - -def _linear_to_srgb(x: np.ndarray) -> np.ndarray: - """Convert linear light values to sRGB (0-1).""" - x = np.clip(x, 0.0, None) - a = 0.055 - return np.where(x <= 0.0031308, 12.92 * x, (1.0 + a) * (x ** (1.0 / 2.4)) - a) - - - - - -def _smoothstep01(x: np.ndarray) -> np.ndarray: - """Hermite smoothstep: 0 at x<=0, 1 at x>=1, smooth S-curve between.""" - x = np.clip(x, 0.0, 1.0) - return x * x * (3.0 - 2.0 * x) - - -def _apply_headroom_shoulder(x: np.ndarray, max_overshoot: float = 0.05) -> np.ndarray: - """Compress values above 1.0 smoothly into a very small headroom. - - Maps headroom (x > 1.0) into [1.0, 1.0 + max_overshoot). - Asymptotes to 1.0 + max_overshoot as x -> inf. - Maintains continuity and monotonicity at 1.0. - - Args: - x: Float32 array in linear light, may have values > 1.0 - max_overshoot: Maximum amount to overshoot 1.0 (e.g. 0.05 means max 1.05) - """ - mask = x > 1.0 - if not np.any(mask): - return x - - out = x.copy() - excess = x[mask] - 1.0 - # Rational compression targeting asymptote of 'max_overshoot' - # y = saturation * x / (saturation + x) -> asymptotes to saturation - # Here x=excess, saturation=max_overshoot - compressed_excess = max_overshoot * excess / (max_overshoot + excess) - out[mask] = 1.0 + compressed_excess - return out - - -# Constants for chroma rolloff -_CHROMA_ROLLOFF_START = 0.85 -_CHROMA_ROLLOFF_WIDTH = 0.15 - - -# Precomputed thresholds for JPEG clipping detection in linear space -# These correspond to sRGB u8 values 250, 253, 254 converted to linear -_LINEAR_THRESHOLD_250 = ((250.0 / 255.0 + 0.055) / 1.055) ** 2.4 # ~0.871 -_LINEAR_THRESHOLD_253 = ((253.0 / 255.0 + 0.055) / 1.055) ** 2.4 # ~0.948 -_LINEAR_THRESHOLD_254 = ((254.0 / 255.0 + 0.055) / 1.055) ** 2.4 # ~0.972 - - -def _analyze_highlight_state(rgb_linear: np.ndarray, srgb_u8: Optional[np.ndarray] = None, pre_exposure_linear: Optional[np.ndarray] = None) -> dict: - """Analyze image for headroom and clipping to tune recovery parameters. - - Args: - rgb_linear: Float32 RGB array in linear light (post-exposure/WB) - srgb_u8: Optional uint8 sRGB array (source image) for accurate JPEG clipping detection. - MUST have same H×W dimensions as rgb_linear (or be stride-compatible). - - Returns: - Dict with: - - headroom_pct: Fraction of pixels with max(rgb) > 1.0 (current state recoverable data) - - clipped_pct: Fraction of pixels with true source clipping (flat-top JPEG clip at 254+ if srgb_u8 provided) - - source_clipped_pct: Alias for clipped_pct (true source clipping) - - near_white_pct: Alias for current_nearwhite_pct (for UI display) - - current_nearwhite_pct: Fraction of pixels currently near white [250, 253] equivalent in processed linear space. - """ - total_pixels = rgb_linear.shape[0] * rgb_linear.shape[1] - if total_pixels == 0: - return { - 'headroom_pct': 0.0, - 'clipped_pct': 0.0, - 'source_clipped_pct': 0.0, - 'near_white_pct': 0.0, - 'current_nearwhite_pct': 0.0 - } - - # Headroom detection: Use pre-exposure buffer if available for "True Headroom" - if pre_exposure_linear is not None: - max_source = pre_exposure_linear.max(axis=2) - headroom_pct = float(np.count_nonzero(max_source > 1.0)) / total_pixels - else: - max_rgb = rgb_linear.max(axis=2) - headroom_pct = float(np.count_nonzero(max_rgb > 1.0)) / total_pixels - - # 1. Source Clipping Statistics (True JPEG Clipping) - # If srgb_u8 is provided, use it. Otherwise approximate from linear (less accurate if exposure shifted). - if srgb_u8 is not None and srgb_u8.shape[:2] == rgb_linear.shape[:2]: - max_u8 = srgb_u8.max(axis=2) - source_clipped_pct = float(np.count_nonzero(max_u8 >= 254)) / total_pixels - # Note: We don't necessarily use srgb_u8 for 'near_white' pivoting logic if user wants "current" state logic, - # but checking source near-white is useful for "is this image naturally bright". - else: - # Fallback: estimate "source clipping" from pre-exposure linear if available, else current - if pre_exposure_linear is not None: - max_to_check = pre_exposure_linear.max(axis=2) - else: - max_to_check = rgb_linear.max(axis=2) - - source_clipped_pct = float(np.count_nonzero(max_to_check >= _LINEAR_THRESHOLD_254)) / total_pixels - - # 2. Current Near-White Statistics (for Pivot Nudging) - # This drives the "micro-contrast feel" based on how bright the image IS NOW. - # Re-calculate max_rgb if we didn't do it earlier (optimization) - if pre_exposure_linear is not None: - max_rgb = rgb_linear.max(axis=2) - elif 'max_rgb' not in locals(): - max_rgb = rgb_linear.max(axis=2) - - current_nearwhite_pct = float(np.count_nonzero( - (max_rgb >= _LINEAR_THRESHOLD_250) & (max_rgb < _LINEAR_THRESHOLD_254) - )) / total_pixels - - # Legacy compat: near_white_pct usually referred to current state in previous logic? - # Actually previous logic tried to use srgb_u8 if available for 'near_white_pct', which implies source. - # But for pivot nudging, we might want current? - # The user said: "drive 'pivot nudging' off current_nearwhite_pct" and "drive 'JPEG fallback' off source_clipped_pct". - # So we provide both. - - return { - 'headroom_pct': headroom_pct, - 'source_clipped_pct': source_clipped_pct, - 'current_nearwhite_pct': current_nearwhite_pct, - } - - -def _lerp(a: float, b: float, t: float) -> float: - """Linear interpolation between a and b by t (clamped 0-1).""" - t_clamped = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t) - return a + (b - a) * t_clamped - - -def _highlight_recover_linear( - rgb_linear: np.ndarray, - amount: float, - *, - pivot: float = 0.7, - k: float = 6.0, - chroma_rolloff: float = 0.15, - headroom_ceiling: float = 1.0, -) -> np.ndarray: - """Apply highlight recovery using brightness-based rescaling to preserve hue. - - Why brightness-based rescale? - - Per-channel compression causes hue/chroma shifts (e.g., bright red becomes pink). - - By computing a single brightness metric and rescaling all channels equally, - we preserve the original RGB color ratios (hue and relative saturation). - - For 16-bit sources with headroom (values > 1.0), the curve compresses into - [pivot, headroom_ceiling] rather than [pivot, 1.0], preserving subtle tonal - separation above 1.0 that represents real recovered detail. - - Args: - rgb_linear: Float32 RGB array (H, W, 3) in linear light, may have values > 1.0 - amount: Recovery strength 0.0-1.0 (mapped from slider -100 to 0) - pivot: Brightness threshold below which no recovery occurs - k: Shoulder curve steepness (higher = more aggressive compression) - chroma_rolloff: Desaturation amount in extreme highlights (0-1) - headroom_ceiling: Maximum output brightness (> 1.0 preserves headroom detail) - - Returns: - Recovered float32 RGB array (linear) - """ - if amount < 0.001: - return rgb_linear - - eps = 1e-7 - - # Use max-channel as brightness metric - handles saturated highlights better than luminance - brightness = rgb_linear.max(axis=2) - - # Build smooth highlight mask: 0 below pivot, 1 in highlights - # Use headroom_ceiling instead of 1.0 for the normalization range - mask = _smoothstep01((brightness - pivot) / (headroom_ceiling - pivot + eps)) - - # Highlights recovery should DIM bright areas to reveal detail/contrast. - # We use a gain-based approach that preserves the pivot and pull down highlights. - # strength of 0.3 means max 30% darkening at pure white. - recovery_strength = 0.3 - target_brightness = brightness * (1.0 - amount * recovery_strength * mask) - - # Rescale RGB to preserve hue/chroma - # Protect against div-by-zero or huge scale factors for near-black pixels - scale = np.clip(target_brightness / (brightness + eps), 0.0, 2.0) - scale = np.expand_dims(scale, axis=2) - recovered = rgb_linear * scale - - # Optional chroma rolloff in extreme highlights to reduce "neon" colors - if chroma_rolloff > 0.001: - # Use target_brightness (post-compression) for the mask to maintain monotonicity - # Normalize against headroom_ceiling for consistent behavior - extreme_mask = _smoothstep01((target_brightness - _CHROMA_ROLLOFF_START * headroom_ceiling) / (_CHROMA_ROLLOFF_WIDTH * headroom_ceiling)) - extreme_mask = np.expand_dims(extreme_mask, axis=2) - - # Compute grayscale (luminance) of recovered image - gray = recovered[:, :, 0:1] * 0.2126 + recovered[:, :, 1:2] * 0.7152 + recovered[:, :, 2:3] * 0.0722 - - # Desaturate in extreme highlights - # Note: This preserves monotonicity because both recovered and gray are - # monotonic with respect to input brightness, and we blend between them. - desat_amount = chroma_rolloff * amount * extreme_mask - recovered = recovered * (1.0 - desat_amount) + gray * desat_amount - - return recovered - - -def _highlight_boost_linear( - rgb_linear: np.ndarray, - amount: float, - *, - pivot: float = 0.5, -) -> np.ndarray: - """Apply highlight boost using brightness-based rescaling to preserve hue. - - Uses same hue-preserving approach as recovery for symmetry. - - Args: - rgb_linear: Float32 RGB array (H, W, 3) in linear light - amount: Boost strength 0.0-1.0 (mapped from slider 0 to 100) - pivot: Brightness threshold below which minimal boost occurs - - Returns: - Boosted float32 RGB array (linear) - """ - if amount < 0.001: - return rgb_linear - - eps = 1e-7 - - brightness = rgb_linear.max(axis=2) - - # Build mask for highlights - mask = _smoothstep01((brightness - pivot) / (1.0 - pivot + eps)) - - # Target brightness: lift with curve - target_brightness = brightness * (1.0 + amount * 1.5 * mask) - - # Rescale RGB to preserve hue, cap scale at 1.5x to prevent blowout - scale = np.clip(target_brightness / (brightness + eps), 0.0, 2.0) - scale = np.minimum(scale, 1.5) # Direct cap on scale - scale = np.expand_dims(scale, axis=2) - - return rgb_linear * scale +import numpy as np +from typing import Optional, Dict + +# ---------------------------- +# sRGB ↔ Linear Conversion Helpers +# ---------------------------- + +def _srgb_to_linear(x: np.ndarray) -> np.ndarray: + """Convert sRGB values to linear light. + + Preserves headroom (values > 1.0) for highlight recovery. + Clamps negatives to 0 since the power function requires non-negative input. + """ + # Clamp negatives to 0, but preserve values > 1.0 for headroom + x = np.clip(x, 0.0, None) + a = 0.055 + + # Apply the standard sRGB transfer function - works for values > 1.0 too + return np.where(x <= 0.04045, x / 12.92, ((x + a) / (1.0 + a)) ** 2.4) + + +def _linear_to_srgb(x: np.ndarray) -> np.ndarray: + """Convert linear light values to sRGB (0-1).""" + x = np.clip(x, 0.0, None) + a = 0.055 + return np.where(x <= 0.0031308, 12.92 * x, (1.0 + a) * (x ** (1.0 / 2.4)) - a) + + + + + +def _smoothstep01(x: np.ndarray) -> np.ndarray: + """Hermite smoothstep: 0 at x<=0, 1 at x>=1, smooth S-curve between.""" + x = np.clip(x, 0.0, 1.0) + return x * x * (3.0 - 2.0 * x) + + +def _apply_headroom_shoulder(x: np.ndarray, max_overshoot: float = 0.05) -> np.ndarray: + """Compress values above 1.0 smoothly into a very small headroom. + + Maps headroom (x > 1.0) into [1.0, 1.0 + max_overshoot). + Asymptotes to 1.0 + max_overshoot as x -> inf. + Maintains continuity and monotonicity at 1.0. + + Args: + x: Float32 array in linear light, may have values > 1.0 + max_overshoot: Maximum amount to overshoot 1.0 (e.g. 0.05 means max 1.05) + """ + mask = x > 1.0 + if not np.any(mask): + return x + + out = x.copy() + excess = x[mask] - 1.0 + # Rational compression targeting asymptote of 'max_overshoot' + # y = saturation * x / (saturation + x) -> asymptotes to saturation + # Here x=excess, saturation=max_overshoot + compressed_excess = max_overshoot * excess / (max_overshoot + excess) + out[mask] = 1.0 + compressed_excess + return out + + +# Constants for chroma rolloff +_CHROMA_ROLLOFF_START = 0.85 +_CHROMA_ROLLOFF_WIDTH = 0.15 + + +# Precomputed thresholds for JPEG clipping detection in linear space +# These correspond to sRGB u8 values 250, 253, 254 converted to linear +_LINEAR_THRESHOLD_250 = ((250.0 / 255.0 + 0.055) / 1.055) ** 2.4 # ~0.871 +_LINEAR_THRESHOLD_253 = ((253.0 / 255.0 + 0.055) / 1.055) ** 2.4 # ~0.948 +_LINEAR_THRESHOLD_254 = ((254.0 / 255.0 + 0.055) / 1.055) ** 2.4 # ~0.972 + + +def _analyze_highlight_state(rgb_linear: np.ndarray, srgb_u8: Optional[np.ndarray] = None, pre_exposure_linear: Optional[np.ndarray] = None) -> dict: + """Analyze image for headroom and clipping to tune recovery parameters. + + Args: + rgb_linear: Float32 RGB array in linear light (post-exposure/WB) + srgb_u8: Optional uint8 sRGB array (source image) for accurate JPEG clipping detection. + MUST have same H×W dimensions as rgb_linear (or be stride-compatible). + + Returns: + Dict with: + - headroom_pct: Fraction of pixels with max(rgb) > 1.0 (current state recoverable data) + - clipped_pct: Fraction of pixels with true source clipping (flat-top JPEG clip at 254+ if srgb_u8 provided) + - source_clipped_pct: Alias for clipped_pct (true source clipping) + - near_white_pct: Alias for current_nearwhite_pct (for UI display) + - current_nearwhite_pct: Fraction of pixels currently near white [250, 253] equivalent in processed linear space. + """ + total_pixels = rgb_linear.shape[0] * rgb_linear.shape[1] + if total_pixels == 0: + return { + 'headroom_pct': 0.0, + 'clipped_pct': 0.0, + 'source_clipped_pct': 0.0, + 'near_white_pct': 0.0, + 'current_nearwhite_pct': 0.0 + } + + # Headroom detection: Use pre-exposure buffer if available for "True Headroom" + if pre_exposure_linear is not None: + max_source = pre_exposure_linear.max(axis=2) + headroom_pct = float(np.count_nonzero(max_source > 1.0)) / total_pixels + else: + max_rgb = rgb_linear.max(axis=2) + headroom_pct = float(np.count_nonzero(max_rgb > 1.0)) / total_pixels + + # 1. Source Clipping Statistics (True JPEG Clipping) + # If srgb_u8 is provided, use it. Otherwise approximate from linear (less accurate if exposure shifted). + if srgb_u8 is not None and srgb_u8.shape[:2] == rgb_linear.shape[:2]: + max_u8 = srgb_u8.max(axis=2) + source_clipped_pct = float(np.count_nonzero(max_u8 >= 254)) / total_pixels + # Note: We don't necessarily use srgb_u8 for 'near_white' pivoting logic if user wants "current" state logic, + # but checking source near-white is useful for "is this image naturally bright". + else: + # Fallback: estimate "source clipping" from pre-exposure linear if available, else current + if pre_exposure_linear is not None: + max_to_check = pre_exposure_linear.max(axis=2) + else: + max_to_check = rgb_linear.max(axis=2) + + source_clipped_pct = float(np.count_nonzero(max_to_check >= _LINEAR_THRESHOLD_254)) / total_pixels + + # 2. Current Near-White Statistics (for Pivot Nudging) + # This drives the "micro-contrast feel" based on how bright the image IS NOW. + # Calculate max_rgb if we didn't do it earlier (when pre_exposure_linear was provided) + if pre_exposure_linear is not None: + max_rgb = rgb_linear.max(axis=2) + + current_nearwhite_pct = float(np.count_nonzero( + (max_rgb >= _LINEAR_THRESHOLD_250) & (max_rgb < _LINEAR_THRESHOLD_254) + )) / total_pixels + + # Legacy compat: near_white_pct usually referred to current state in previous logic? + # Actually previous logic tried to use srgb_u8 if available for 'near_white_pct', which implies source. + # But for pivot nudging, we might want current? + # The user said: "drive 'pivot nudging' off current_nearwhite_pct" and "drive 'JPEG fallback' off source_clipped_pct". + # So we provide both. + + return { + 'headroom_pct': headroom_pct, + 'source_clipped_pct': source_clipped_pct, + 'current_nearwhite_pct': current_nearwhite_pct, + } + + +def _lerp(a: float, b: float, t: float) -> float: + """Linear interpolation between a and b by t (clamped 0-1).""" + t_clamped = 0.0 if t < 0.0 else (1.0 if t > 1.0 else t) + return a + (b - a) * t_clamped + + +def _highlight_recover_linear( + rgb_linear: np.ndarray, + amount: float, + *, + pivot: float = 0.7, + chroma_rolloff: float = 0.15, + headroom_ceiling: float = 1.0, +) -> np.ndarray: + """Apply highlight recovery using brightness-based rescaling to preserve hue. + + Why brightness-based rescale? + - Per-channel compression causes hue/chroma shifts (e.g., bright red becomes pink). + - By computing a single brightness metric and rescaling all channels equally, + we preserve the original RGB color ratios (hue and relative saturation). + + For 16-bit sources with headroom (values > 1.0), the curve compresses into + [pivot, headroom_ceiling] rather than [pivot, 1.0], preserving subtle tonal + separation above 1.0 that represents real recovered detail. + + Args: + rgb_linear: Float32 RGB array (H, W, 3) in linear light, may have values > 1.0 + amount: Recovery strength 0.0-1.0 (mapped from slider -100 to 0) + pivot: Brightness threshold below which no recovery occurs + chroma_rolloff: Desaturation amount in extreme highlights (0-1) + headroom_ceiling: Maximum output brightness (> 1.0 preserves headroom detail) + + Returns: + Recovered float32 RGB array (linear) + """ + if amount < 0.001: + return rgb_linear + + eps = 1e-7 + + # Use max-channel as brightness metric - handles saturated highlights better than luminance + brightness = rgb_linear.max(axis=2) + + # Build smooth highlight mask: 0 below pivot, 1 in highlights + # Use headroom_ceiling instead of 1.0 for the normalization range + mask = _smoothstep01((brightness - pivot) / (headroom_ceiling - pivot + eps)) + + # Highlights recovery should DIM bright areas to reveal detail/contrast. + # We use a gain-based approach that preserves the pivot and pull down highlights. + # strength of 0.3 means max 30% darkening at pure white. + recovery_strength = 0.3 + target_brightness = brightness * (1.0 - amount * recovery_strength * mask) + + # Rescale RGB to preserve hue/chroma + # Protect against div-by-zero or huge scale factors for near-black pixels + scale = np.clip(target_brightness / (brightness + eps), 0.0, 2.0) + scale = np.expand_dims(scale, axis=2) + recovered = rgb_linear * scale + + # Optional chroma rolloff in extreme highlights to reduce "neon" colors + if chroma_rolloff > 0.001: + # Use target_brightness (post-compression) for the mask to maintain monotonicity + # Normalize against headroom_ceiling for consistent behavior + extreme_mask = _smoothstep01((target_brightness - _CHROMA_ROLLOFF_START * headroom_ceiling) / (_CHROMA_ROLLOFF_WIDTH * headroom_ceiling)) + extreme_mask = np.expand_dims(extreme_mask, axis=2) + + # Compute grayscale (luminance) of recovered image + gray = recovered[:, :, 0:1] * 0.2126 + recovered[:, :, 1:2] * 0.7152 + recovered[:, :, 2:3] * 0.0722 + + # Desaturate in extreme highlights + # Note: This preserves monotonicity because both recovered and gray are + # monotonic with respect to input brightness, and we blend between them. + desat_amount = chroma_rolloff * amount * extreme_mask + recovered = recovered * (1.0 - desat_amount) + gray * desat_amount + + return recovered + + +def _highlight_boost_linear( + rgb_linear: np.ndarray, + amount: float, + *, + pivot: float = 0.5, +) -> np.ndarray: + """Apply highlight boost using brightness-based rescaling to preserve hue. + + Uses same hue-preserving approach as recovery for symmetry. + + Args: + rgb_linear: Float32 RGB array (H, W, 3) in linear light + amount: Boost strength 0.0-1.0 (mapped from slider 0 to 100) + pivot: Brightness threshold below which minimal boost occurs + + Returns: + Boosted float32 RGB array (linear) + """ + if amount < 0.001: + return rgb_linear + + eps = 1e-7 + + brightness = rgb_linear.max(axis=2) + + # Build mask for highlights + mask = _smoothstep01((brightness - pivot) / (1.0 - pivot + eps)) + + # Target brightness: lift with curve + target_brightness = brightness * (1.0 + amount * 1.5 * mask) + + # Rescale RGB to preserve hue, cap scale at 1.5x to prevent blowout + scale = np.clip(target_brightness / (brightness + eps), 0.0, 2.0) + scale = np.minimum(scale, 1.5) # Direct cap on scale + scale = np.expand_dims(scale, axis=2) + + return rgb_linear * scale diff --git a/faststack/imaging/metadata.py b/faststack/imaging/metadata.py index 1835819..03b261c 100644 --- a/faststack/imaging/metadata.py +++ b/faststack/imaging/metadata.py @@ -1,199 +1,199 @@ - -import logging -from pathlib import Path -from typing import Dict, Any, Union -from PIL import Image, ExifTags - -log = logging.getLogger(__name__) - -def clean_exif_value(value: Any) -> str: - """ - Cleans EXIF values for display. - - Decodes bytes if possible, otherwise returns a placeholder. - - Strips null bytes and unprintable characters from strings. - - Formats tuples/lists recursively. - """ - if isinstance(value, bytes): - try: - # Try to decode as UTF-8, stripping nulls - decoded = value.decode('utf-8').strip('\x00') - # Check if the result is printable - if decoded.isprintable(): - return decoded - return f"" - except UnicodeDecodeError: - return f"" - - if isinstance(value, str): - # Strip null bytes and other common garbage - cleaned = value.strip('\x00').strip() - # Remove other non-printable characters if necessary, but keep basic text - # For now, just stripping nulls is the most important - return cleaned - - if isinstance(value, (list, tuple)): - return str([clean_exif_value(v) for v in value]) - - return str(value) - -def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: - """ - Extracts EXIF data from an image file. - - Returns a dictionary with two keys: - - 'summary': A dictionary of formatted common fields (Date, ISO, Aperture, etc.) - - 'full': A dictionary of all decoded EXIF tags. - """ - path = Path(path) - if not path.exists(): - return {"summary": {}, "full": {}} - - try: - img = Image.open(path) - try: - exif = img._getexif() - finally: - img.close() - - if not exif: - return {"summary": {}, "full": {}} - except Exception as e: # noqa: BLE001 - defensive catch for arbitrary EXIF parsing issues - log.warning(f"Failed to extract EXIF from {path}: {e}") - return {"summary": {}, "full": {}} - - decoded_exif = {} - for tag_id, value in exif.items(): - tag_name = ExifTags.TAGS.get(tag_id, tag_id) - decoded_exif[tag_name] = value - - summary = {} - - # Helper to safely get value - def get_val(key): - return decoded_exif.get(key) - - # Date Taken - date_taken = get_val("DateTimeOriginal") or get_val("DateTime") - if date_taken: - summary["Date Taken"] = clean_exif_value(date_taken) - - # Camera Model - make = get_val("Make") - model = get_val("Model") - - # Clean make and model first - if make: make = clean_exif_value(make) - if model: model = clean_exif_value(model) - - if make and model: - if make.lower() in model.lower(): - summary["Camera"] = model - else: - summary["Camera"] = f"{make} {model}" - elif model: - summary["Camera"] = model - elif make: - summary["Camera"] = make - - # Lens - lens = get_val("LensModel") or get_val("LensInfo") - if lens: - summary["Lens"] = clean_exif_value(lens) - - # ISO - iso = get_val("ISOSpeedRatings") - if iso: - summary["ISO"] = clean_exif_value(iso) - - # Aperture (FNumber) - f_number = get_val("FNumber") - if f_number: - try: - # FNumber is often a tuple (numerator, denominator) or a float - if isinstance(f_number, tuple) and len(f_number) == 2: - val = f_number[0] / f_number[1] - else: - val = float(f_number) - summary["Aperture"] = f"f/{val:.1f}" - except Exception: - summary["Aperture"] = clean_exif_value(f_number) - - # Shutter Speed (ExposureTime) - exposure_time = get_val("ExposureTime") - if exposure_time: - try: - if isinstance(exposure_time, tuple) and len(exposure_time) == 2: - val = exposure_time[0] / exposure_time[1] - else: - val = float(exposure_time) - - if val < 1: - summary["Shutter Speed"] = f"1/{int(1/val)}s" - else: - summary["Shutter Speed"] = f"{val}s" - except Exception: - summary["Shutter Speed"] = clean_exif_value(exposure_time) - - # Focal Length - focal_length = get_val("FocalLength") - if focal_length: - try: - if isinstance(focal_length, tuple) and len(focal_length) == 2: - val = focal_length[0] / focal_length[1] - else: - val = float(focal_length) - summary["Focal Length"] = f"{int(val)}mm" - except Exception: - summary["Focal Length"] = clean_exif_value(focal_length) - - # Flash - flash = get_val("Flash") - if flash is not None: - # Flash is a bitmask, but for now just showing the value or a simple string is a good start. - # Common values: 0 (No Flash), 1 (Fired), 16 (No Flash, Auto), 24 (No Flash, Auto), 25 (Fired, Auto) - # We can just clean it for now. - summary["Flash"] = clean_exif_value(flash) - - # GPS - gps_info = get_val("GPSInfo") - if gps_info: - try: - def convert_to_degrees(value): - d = float(value[0]) - m = float(value[1]) - s = float(value[2]) - return d + (m / 60.0) + (s / 3600.0) - - lat = None - lon = None - - # GPSInfo keys are integers. - # 1: GPSLatitudeRef, 2: GPSLatitude - # 3: GPSLongitudeRef, 4: GPSLongitude - - if 2 in gps_info and 4 in gps_info: - lat = convert_to_degrees(gps_info[2]) - lon = convert_to_degrees(gps_info[4]) - - if 1 in gps_info and gps_info[1] == 'S': - lat = -lat - if 3 in gps_info and gps_info[3] == 'W': - lon = -lon - - summary["GPS"] = f"{lat:.5f}, {lon:.5f}" - except Exception as e: - log.warning(f"Failed to parse GPS info: {e}") - # Fallback to cleaning the raw info if parsing fails - # But user specifically asked for decimal, so maybe just don't show if it fails or show raw? - # Let's show raw if parsing fails but cleaned - # summary["GPS"] = clean_exif_value(gps_info) - pass - - # Convert all values in full dict to string to ensure JSON serializability for QML - # Apply cleaning to all values - full_str = {str(k): clean_exif_value(v) for k, v in decoded_exif.items()} - - return { - "summary": summary, - "full": full_str - } + +import logging +from pathlib import Path +from typing import Dict, Any, Union +from PIL import Image, ExifTags + +log = logging.getLogger(__name__) + +def clean_exif_value(value: Any) -> str: + """ + Cleans EXIF values for display. + - Decodes bytes if possible, otherwise returns a placeholder. + - Strips null bytes and unprintable characters from strings. + - Formats tuples/lists recursively. + """ + if isinstance(value, bytes): + try: + # Try to decode as UTF-8, stripping nulls + decoded = value.decode('utf-8').strip('\x00') + # Check if the result is printable + if decoded.isprintable(): + return decoded + return f"" + except UnicodeDecodeError: + return f"" + + if isinstance(value, str): + # Strip null bytes and other common garbage + cleaned = value.strip('\x00').strip() + # Remove other non-printable characters if necessary, but keep basic text + # For now, just stripping nulls is the most important + return cleaned + + if isinstance(value, (list, tuple)): + return str([clean_exif_value(v) for v in value]) + + return str(value) + +def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: + """ + Extracts EXIF data from an image file. + + Returns a dictionary with two keys: + - 'summary': A dictionary of formatted common fields (Date, ISO, Aperture, etc.) + - 'full': A dictionary of all decoded EXIF tags. + """ + path = Path(path) + if not path.exists(): + return {"summary": {}, "full": {}} + + try: + img = Image.open(path) + try: + exif = img._getexif() + finally: + img.close() + + if not exif: + return {"summary": {}, "full": {}} + except Exception as e: # noqa: BLE001 - defensive catch for arbitrary EXIF parsing issues + log.warning(f"Failed to extract EXIF from {path}: {e}") + return {"summary": {}, "full": {}} + + decoded_exif = {} + for tag_id, value in exif.items(): + tag_name = ExifTags.TAGS.get(tag_id, tag_id) + decoded_exif[tag_name] = value + + summary = {} + + # Helper to safely get value + def get_val(key): + return decoded_exif.get(key) + + # Date Taken + date_taken = get_val("DateTimeOriginal") or get_val("DateTime") + if date_taken: + summary["Date Taken"] = clean_exif_value(date_taken) + + # Camera Model + make = get_val("Make") + model = get_val("Model") + + # Clean make and model first + if make: make = clean_exif_value(make) + if model: model = clean_exif_value(model) + + if make and model: + if make.lower() in model.lower(): + summary["Camera"] = model + else: + summary["Camera"] = f"{make} {model}" + elif model: + summary["Camera"] = model + elif make: + summary["Camera"] = make + + # Lens + lens = get_val("LensModel") or get_val("LensInfo") + if lens: + summary["Lens"] = clean_exif_value(lens) + + # ISO + iso = get_val("ISOSpeedRatings") + if iso: + summary["ISO"] = clean_exif_value(iso) + + # Aperture (FNumber) + f_number = get_val("FNumber") + if f_number: + try: + # FNumber is often a tuple (numerator, denominator) or a float + if isinstance(f_number, tuple) and len(f_number) == 2: + val = f_number[0] / f_number[1] + else: + val = float(f_number) + summary["Aperture"] = f"f/{val:.1f}" + except Exception: + summary["Aperture"] = clean_exif_value(f_number) + + # Shutter Speed (ExposureTime) + exposure_time = get_val("ExposureTime") + if exposure_time: + try: + if isinstance(exposure_time, tuple) and len(exposure_time) == 2: + val = exposure_time[0] / exposure_time[1] + else: + val = float(exposure_time) + + if val < 1: + summary["Shutter Speed"] = f"1/{int(1/val)}s" + else: + summary["Shutter Speed"] = f"{val}s" + except Exception: + summary["Shutter Speed"] = clean_exif_value(exposure_time) + + # Focal Length + focal_length = get_val("FocalLength") + if focal_length: + try: + if isinstance(focal_length, tuple) and len(focal_length) == 2: + val = focal_length[0] / focal_length[1] + else: + val = float(focal_length) + summary["Focal Length"] = f"{int(val)}mm" + except Exception: + summary["Focal Length"] = clean_exif_value(focal_length) + + # Flash + flash = get_val("Flash") + if flash is not None: + # Flash is a bitmask, but for now just showing the value or a simple string is a good start. + # Common values: 0 (No Flash), 1 (Fired), 16 (No Flash, Auto), 24 (No Flash, Auto), 25 (Fired, Auto) + # We can just clean it for now. + summary["Flash"] = clean_exif_value(flash) + + # GPS + gps_info = get_val("GPSInfo") + if gps_info: + try: + def convert_to_degrees(value): + d = float(value[0]) + m = float(value[1]) + s = float(value[2]) + return d + (m / 60.0) + (s / 3600.0) + + lat = None + lon = None + + # GPSInfo keys are integers. + # 1: GPSLatitudeRef, 2: GPSLatitude + # 3: GPSLongitudeRef, 4: GPSLongitude + + if 2 in gps_info and 4 in gps_info: + lat = convert_to_degrees(gps_info[2]) + lon = convert_to_degrees(gps_info[4]) + + if 1 in gps_info and gps_info[1] == 'S': + lat = -lat + if 3 in gps_info and gps_info[3] == 'W': + lon = -lon + + summary["GPS"] = f"{lat:.5f}, {lon:.5f}" + except Exception as e: + log.warning(f"Failed to parse GPS info: {e}") + # Fallback to cleaning the raw info if parsing fails + # But user specifically asked for decimal, so maybe just don't show if it fails or show raw? + # Let's show raw if parsing fails but cleaned + # summary["GPS"] = clean_exif_value(gps_info) + pass + + # Convert all values in full dict to string to ensure JSON serializability for QML + # Apply cleaning to all values + full_str = {str(k): clean_exif_value(v) for k, v in decoded_exif.items()} + + return { + "summary": summary, + "full": full_str + } diff --git a/faststack/imaging/orientation.py b/faststack/imaging/orientation.py index 98b053d..a6ef532 100644 --- a/faststack/imaging/orientation.py +++ b/faststack/imaging/orientation.py @@ -1,81 +1,87 @@ -"""Centralized utilities for EXIF orientation handling.""" - -import logging -from pathlib import Path -from typing import Optional -import numpy as np -from PIL import Image - -log = logging.getLogger(__name__) - -def get_exif_orientation(image_path: Path, exif: Optional[Image.Exif] = None) -> int: - """Read the EXIF Orientation tag from an image file or provided EXIF object. - - Args: - image_path: Path to the image file - exif: Optional pre-read PIL Exif object - - Returns: - Orientation value (1-8), defaults to 1 if missing or error. - """ - try: - if exif is None: - with Image.open(image_path) as img: - exif = img.getexif() - - if not exif: - return 1 - - # EXIF Orientation tag ID is 274 - return exif.get(274, 1) - except (OSError, IOError, AttributeError) as e: - log.debug("Could not read EXIF orientation for %s: %s", image_path, e) - return 1 - -def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray: - """Apply EXIF orientation transformation to a numpy image buffer. - - Args: - buffer: Image as numpy array (H, W, 3) RGB uint8 or float32 - orientation: Orientation value (1-8) - - Returns: - Transformed numpy array. Guaranteed to be C-contiguous. - """ - if orientation <= 1: - return buffer - - # Apply transformation based on orientation - if orientation == 2: - # Mirrored horizontally - result = np.fliplr(buffer) - elif orientation == 3: - # Rotated 180 degrees - result = np.rot90(buffer, k=2) - elif orientation == 4: - # Mirrored vertically - result = np.flipud(buffer) - elif orientation == 5: - # Mirrored horizontally then rotated 90 CCW - result = np.rot90(np.fliplr(buffer), k=1) - elif orientation == 6: - # Rotated 90 CW (270 CCW) - result = np.rot90(buffer, k=3) - elif orientation == 7: - # Mirrored horizontally then rotated 90 CW - result = np.rot90(np.fliplr(buffer), k=3) - elif orientation == 8: - # Rotated 90 CCW - result = np.rot90(buffer, k=1) - else: - return buffer - - # Ensure result is C-contiguous after flip/rotate - if not result.flags['C_CONTIGUOUS']: - result = np.ascontiguousarray(result) - return result - -def apply_exif_orientation(buffer: np.ndarray, image_path: Path, exif: Optional[Image.Exif] = None) -> np.ndarray: - """Helper that reads orientation and applies it to a numpy buffer.""" - orientation = get_exif_orientation(image_path, exif) - return apply_orientation_to_np(buffer, orientation) +"""Centralized utilities for EXIF orientation handling.""" + +import logging +from pathlib import Path +from typing import Optional +import numpy as np +from PIL import Image + +log = logging.getLogger(__name__) + +def get_exif_orientation(image_path: Path, exif: Optional[Image.Exif] = None) -> int: + """Read the EXIF Orientation tag from an image file or provided EXIF object. + + Args: + image_path: Path to the image file + exif: Optional pre-read PIL Exif object + + Returns: + Orientation value (1-8), defaults to 1 if missing or error. + """ + try: + if exif is None: + with Image.open(image_path) as img: + exif = img.getexif() + + if not exif: + return 1 + + # EXIF Orientation tag ID is 274 + return exif.get(274, 1) + except (OSError, IOError, AttributeError) as e: + log.debug("Could not read EXIF orientation for %s: %s", image_path, e) + return 1 + +def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray: + """Apply EXIF orientation transformation to a numpy image buffer. + + Args: + buffer: Image as numpy array (H, W, 3) RGB uint8 or float32 + orientation: Orientation value (1-8) + + Returns: + Transformed numpy array. Guaranteed to be C-contiguous. + """ + if orientation <= 1: + # Ensure C-contiguity even for identity orientation + if not buffer.flags['C_CONTIGUOUS']: + return np.ascontiguousarray(buffer) + return buffer + + # Apply transformation based on orientation + if orientation == 2: + # Mirrored horizontally + result = np.fliplr(buffer) + elif orientation == 3: + # Rotated 180 degrees + result = np.rot90(buffer, k=2) + elif orientation == 4: + # Mirrored vertically + result = np.flipud(buffer) + elif orientation == 5: + # Mirrored horizontally then rotated 90 CCW + result = np.rot90(np.fliplr(buffer), k=1) + elif orientation == 6: + # Rotated 90 CW (270 CCW) + result = np.rot90(buffer, k=3) + elif orientation == 7: + # Mirrored horizontally then rotated 90 CW + result = np.rot90(np.fliplr(buffer), k=3) + elif orientation == 8: + # Rotated 90 CCW + result = np.rot90(buffer, k=1) + else: + # Unknown orientation - ensure C-contiguity + if not buffer.flags['C_CONTIGUOUS']: + return np.ascontiguousarray(buffer) + return buffer + + # Ensure result is C-contiguous after flip/rotate + if not result.flags['C_CONTIGUOUS']: + result = np.ascontiguousarray(result) + return result + +def apply_exif_orientation(buffer: np.ndarray, image_path: Path, exif: Optional[Image.Exif] = None) -> np.ndarray: + """Helper that reads orientation and applies it to a numpy buffer.""" + orientation = get_exif_orientation(image_path, exif) + return apply_orientation_to_np(buffer, orientation) diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index 54fe3f2..8070642 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -1,675 +1,674 @@ -"""Handles prefetching and decoding of adjacent images in a background thread pool.""" - -import logging -import os -import io -import hashlib -from pathlib import Path -from concurrent.futures import ThreadPoolExecutor, Future -from typing import List, Dict, Optional, Callable -import mmap - -import numpy as np -from PIL import Image as PILImage, ImageCms -try: - from PySide6.QtCore import QTimer - from PySide6.QtGui import QImage -except ImportError: - QTimer = None - QImage = None - -from faststack.models import ImageFile, DecodedImage -from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized, TURBO_AVAILABLE -from faststack.imaging.cache import build_cache_key -from faststack.imaging.orientation import apply_exif_orientation -from faststack.config import config - -log = logging.getLogger(__name__) - -import threading - -# ---- Option C: ICC Color Management Setup ---- -SRGB_PROFILE = ImageCms.createProfile("sRGB") - -# Cache for monitor ICC profile to avoid reloading on every decode -_monitor_profile_cache: Dict[str, Optional[ImageCms.ImageCmsProfile]] = {} -_monitor_profile_warning_logged = False - -# Cache for ICC transforms to avoid rebuilding on every image -_icc_transform_cache: Dict[tuple, ImageCms.ImageCmsTransform] = {} - -# Thread lock for all ICC caches -_icc_cache_lock = threading.Lock() - -def get_icc_transform( - src_profile: ImageCms.ImageCmsProfile, - monitor_profile: ImageCms.ImageCmsProfile, - src_profile_key: str, - monitor_profile_path: str, -) -> ImageCms.ImageCmsTransform: - """Get or create a cached ICC transform. - - Building transforms is expensive, so we cache them by stable keys: - - src_profile_key: SHA-256 digest of the embedded ICC bytes - - monitor_profile_path: file path to the monitor ICC profile - """ - key = (src_profile_key, monitor_profile_path) - with _icc_cache_lock: - if key not in _icc_transform_cache: - _icc_transform_cache[key] = ImageCms.buildTransform( - src_profile, monitor_profile, "RGB", "RGB" - ) - log.debug("Built new ICC transform for profile pair (src=%s, monitor=%s)", src_profile_key[:16], monitor_profile_path) - return _icc_transform_cache[key] - -def clear_icc_caches(): - """Clear all ICC-related caches (profiles and transforms).""" - global _monitor_profile_cache, _icc_transform_cache, _monitor_profile_warning_logged - with _icc_cache_lock: - _monitor_profile_cache.clear() - _icc_transform_cache.clear() - _monitor_profile_warning_logged = False - log.info("Cleared ICC profile and transform caches") - -def get_monitor_profile() -> Optional[ImageCms.ImageCmsProfile]: - """Dynamically load monitor ICC profile based on current config. - - Caches the profile by path to reduce overhead and log spam. - """ - global _monitor_profile_warning_logged - - monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() - - with _icc_cache_lock: - # Check cache first - if monitor_icc_path in _monitor_profile_cache: - return _monitor_profile_cache[monitor_icc_path] - - # Handle empty path case - if not monitor_icc_path: - if not _monitor_profile_warning_logged: - log.warning("ICC mode enabled but no monitor_icc_path configured") - _monitor_profile_warning_logged = True - _monitor_profile_cache[monitor_icc_path] = None - return None - - # Load and cache the profile - try: - profile = ImageCms.ImageCmsProfile(monitor_icc_path) - log.debug("Loaded monitor ICC profile: %s", monitor_icc_path) - _monitor_profile_cache[monitor_icc_path] = profile - except (OSError, ImageCms.PyCMSError) as e: - log.warning("Failed to load monitor ICC profile from %s: %s", monitor_icc_path, e) - _monitor_profile_cache[monitor_icc_path] = None - - return _monitor_profile_cache[monitor_icc_path] - - -# apply_exif_orientation imported from orientation.py - -def apply_saturation_compensation( - arr: np.ndarray, - width: int, - height: int, - bytes_per_line: int, - factor: float, -): - """ - In-place saturation scale in RGB space (Option A). - - arr: 1D uint8 array of length height * bytes_per_line - width, height, bytes_per_line: dimensions of the image stored in arr - factor: 0.0-1.0 range, where 1.0 = no change, <1.0 = less saturated - - Note: While the algorithm supports values >1.0 for increased saturation, - the UI constrains the factor to [0.0, 1.0] for saturation reduction only. - """ - if factor == 1.0: - return - - # Treat the buffer as [height, bytes_per_line] - assert arr.size == height * bytes_per_line, ( - f"Unexpected buffer size for saturation compensation: " - f"{arr.size} != {height} * {bytes_per_line}" - ) - buf2d = arr.reshape((height, bytes_per_line)) - - # Only the first width*3 bytes per row are actual RGB pixels - rgb_region = buf2d[:, : width * 3] - - # Interpret as H x W x 3 - rgb = rgb_region.reshape((height, width, 3)).astype(np.float32) - - # Simple saturation scaling: move each channel toward its per-pixel average - gray = rgb.mean(axis=2, keepdims=True) - rgb = gray + factor * (rgb - gray) - - np.clip(rgb, 0, 255, out=rgb) - - # Write back into the same memory - rgb_region[:] = rgb.reshape(height, width * 3).astype(np.uint8) - -class Prefetcher: - def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable, debug: bool = False): - self.image_files = image_files - self.cache_put = cache_put - self.prefetch_radius = prefetch_radius - self.get_display_info = get_display_info - self.debug = debug - # 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, 4) # Cap at 4 - - self.executor = ThreadPoolExecutor( - max_workers=optimal_workers, - thread_name_prefix="Prefetcher" - ) - self._futures_lock = threading.RLock() - self.futures: Dict[int, Future] = {} - self.generation = 0 - self._scheduled: Dict[int, set] = {} # generation -> set of scheduled indices - - # Adaptive prefetch: start with smaller radius, expand after user navigates - self._initial_radius = 2 # Small radius at startup to reduce cache thrash - self._navigation_count = 0 # Track how many times user has navigated - self._radius_expanded = False - - # Directional prefetching - self._last_navigation_direction: int = 1 # 1 = forward, -1 = backward - self._direction_bias: float = 0.7 # 70% of radius in travel direction - - 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, is_navigation: bool = False, direction: Optional[int] = None): - """Updates the prefetching queue based on the current image index. - - Args: - current_index: The index to prefetch around - is_navigation: True if this is from user navigation (arrow keys, etc.) - direction: 1 for forward, -1 for backward, None to use last direction - """ - # NOTE: Generation is NOT incremented here. It only changes when display size, - # zoom state, or color mode changes - events that actually invalidate cached images. - # Navigation just shifts which indices to prefetch. - - # OLD GENERATION CLEANUP MOVED TO INSIDE LOCK BELOW - - # Track navigation direction - if direction is not None: - self._last_navigation_direction = direction - - # Track navigation to expand radius after user starts moving - if is_navigation: - self._navigation_count += 1 - if not self._radius_expanded and self._navigation_count >= 2: - self._radius_expanded = True - log.info("Expanding prefetch radius from %d to %d after user navigation", self._initial_radius, self.prefetch_radius) - - # Use smaller radius initially to reduce cache thrash before display size is stable - effective_radius = self._initial_radius if not self._radius_expanded else self.prefetch_radius - - if self.debug: - log.info("Prefetch radius: initial=%d, configured=%d, effective=%d", - self._initial_radius, self.prefetch_radius, effective_radius) - - # Calculate asymmetric range based on direction - if self._last_navigation_direction > 0: # Moving forward - behind = max(1, int(effective_radius * (1 - self._direction_bias))) - ahead = effective_radius - behind + 1 - else: # Moving backward - ahead = max(1, int(effective_radius * (1 - self._direction_bias))) - behind = effective_radius - ahead + 1 - - start = max(0, current_index - behind) - end = min(len(self.image_files), current_index + ahead + 1) - - log.debug("Prefetch range: [%d, %d) for index %d (direction=%d, behind=%d, ahead=%d)", - start, end, current_index, self._last_navigation_direction, behind, ahead) - - # Cancel stale futures and remove from scheduled - with self._futures_lock: - # Clean up old generation entries to prevent memory leak - # MOVED INSIDE LOCK to prevent race with cancel_all() - old_generations = [g for g in self._scheduled if g < self.generation] - for g in old_generations: - del self._scheduled[g] - - # Get scheduled set for current generation (inside lock to prevent race) - scheduled = self._scheduled.setdefault(self.generation, set()) - stale_keys = [] - for index, future in list(self.futures.items()): - if index < start or index >= end: - if future.cancel(): - stale_keys.append(index) - scheduled.discard(index) # Remove from scheduled set - for key in stale_keys: - del self.futures[key] - - # Submit new tasks - prioritize current image and direction of travel - - # Build priority order: current first, then in direction of travel - priority_order = [current_index] - if self._last_navigation_direction > 0: - priority_order.extend(range(current_index + 1, end)) - priority_order.extend(range(current_index - 1, start - 1, -1)) - else: - priority_order.extend(range(current_index - 1, start - 1, -1)) - priority_order.extend(range(current_index + 1, end)) - - for i in priority_order: - if i < 0 or i >= len(self.image_files): - continue - if i not in scheduled and i not in self.futures: - self.submit_task(i, self.generation) - scheduled.add(i) - - def submit_task(self, index: int, generation: int, priority: bool = False) -> Optional[Future]: - """Submits a decoding task for a given index. - - Args: - index: Image index to decode - generation: Generation number for cache invalidation - priority: If True, cancels lower-priority pending tasks to free up workers - """ - with self._futures_lock: - if index in self.futures and not self.futures[index].done(): - return self.futures[index] # Already submitted - - # For high-priority tasks (current image), cancel pending prefetch tasks - # to free up worker threads and reduce blocking time - if priority: - cancelled_count = 0 - # Don't cancel tasks that are very close to the requested index (e.g. +/- 2) - # This prevents thrashing when the user is navigating quickly - safe_radius = 2 - - for task_index, future in list(self.futures.items()): - # Skip the current task - if task_index == index: - continue - - # Skip tasks within safe radius - if abs(task_index - index) <= safe_radius: - continue - - if not future.done() and future.cancel(): - cancelled_count += 1 - del self.futures[task_index] - if cancelled_count > 0: - log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index) - - 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("Submitted %s task for index %d", "priority" if priority else "prefetch", 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[Path, int]]: - """The actual work done by the thread pool.""" - import time - - t_start = time.perf_counter() - exif_obj = None # Ensure variable is always initialized - - # Early check: if generation has already advanced since this task was submitted, skip it - if generation != self.generation: - log.debug("Skipping stale task for index %d (submitted gen %d != current gen %d)", index, generation, self.generation) - return None - - try: - # Check for empty file to avoid mmap error - if os.path.getsize(image_file.path) == 0: - log.warning("Skipping empty image file: %s", image_file.path) - return None - - # Get current color management mode and optimization setting - color_mode = config.get('color', 'mode', fallback="none").lower() - optimize_for = config.get('core', 'optimize_for', fallback='speed').lower() - fast_dct = (optimize_for == 'speed') - use_resized = (optimize_for == 'speed') # Use decode_jpeg_resized for speed, decode_jpeg_rgb for quality - - # Determine if we should resize - should_resize = (display_width > 0 and display_height > 0) - - # Determine file type - is_jpeg = image_file.path.suffix.lower() in {'.jpg', '.jpeg', '.jpe'} - - # Option C: Full ICC pipeline - Use TurboJPEG for decode, Pillow only for ICC conversion - if color_mode == "icc": - monitor_profile = get_monitor_profile() - monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() - - if monitor_profile is not None: - # FAST: Use TurboJPEG for decode + resize (ONLY for JPEGs) - buffer = None - t_before_read = time.perf_counter() - - if is_jpeg: - try: - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - # Pass mmap directly - no copy! Decoders accept bytes-like objects - if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode or Full Res: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None and should_resize: - img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) - except Exception: - log.debug("TurboJPEG failed on JPEG %s, falling back", image_file.path) - buffer = None - - # If not JPEG or TurboJPEG failed, try generic Pillow load - if buffer is None: - try: - # We can't use mmap for Generic Pillow open widely (some formats need seek/tell on file) - # So we open nominally. - with PILImage.open(image_file.path) as img: - img = img.convert("RGB") - if should_resize: - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) - except Exception as e: - log.warning("Failed to decode image %s: %s", image_file.path, e) - return None - - t_after_read = time.perf_counter() - if buffer is None: - return None - t_after_decode = time.perf_counter() - - # Convert numpy array to PIL Image for ICC conversion - img = PILImage.fromarray(buffer) - t_after_array_to_pil = time.perf_counter() - - # Extract ICC profile AND EXIF from original file (need to read header only) - t_before_profile_read = time.perf_counter() - exif_obj = None - with PILImage.open(image_file.path) as orig: - icc_bytes = orig.info.get("icc_profile") - exif_obj = orig.getexif() # Capture EXIF while open - t_after_profile_read = time.perf_counter() - - src_profile = None - src_profile_key = None - if icc_bytes: - try: - src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) - # Compute stable key: SHA-256 digest of ICC bytes - src_profile_key = hashlib.sha256(icc_bytes).hexdigest() - log.debug("Using embedded ICC profile from %s", image_file.path) - except (OSError, ImageCms.PyCMSError, ValueError) as e: - log.warning("Failed to parse ICC profile from %s: %s", image_file.path, e) - - if src_profile is None: - src_profile = SRGB_PROFILE - # Use a constant key for sRGB since it's always the same - src_profile_key = "srgb_builtin" - log.debug("No embedded profile, assuming sRGB for %s", image_file.path) - - # Convert from source profile to monitor profile using cached transform - try: - log.debug("Converting image from source to monitor profile") - t_before_icc = time.perf_counter() - transform = get_icc_transform(src_profile, monitor_profile, src_profile_key, monitor_icc_path) - # Alan 11-20-25 - Add inPlace=True to speed up copy, shouldn't have many negative effects - ImageCms.applyTransform(img, transform, inPlace=True) - t_after_icc = time.perf_counter() - - rgb = np.array(img, dtype=np.uint8) - - # Note: We do NOT apply EXIF orientation here anymore. - # It is handled in the Unified EXIF Orientation Application block below. - # This avoids "double rotation" or potential "apply and discard" bugs. - - # Memory Optimization: Avoid explicit copy - buffer = np.ascontiguousarray(rgb) - bytes_per_line = buffer.strides[0] - mv = memoryview(buffer).cast("B") - t_after_copy = time.perf_counter() - - if self.debug: - decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("ICC decode timing for index %d (%s): read=%.3fs, decode=%.3fs, array_to_pil=%.3fs, profile_read=%.3fs, icc=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_array_to_pil - t_after_decode, t_after_profile_read - t_before_profile_read, - t_after_icc - t_before_icc, t_after_copy - t_after_icc, - t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) - except (OSError, ImageCms.PyCMSError, ValueError) as e: - # ICC conversion failed, fall back to standard decode - log.warning("ICC profile conversion failed for %s: %s, falling back to standard decode", image_file.path, e) - t_before_fallback_read = time.perf_counter() - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - # Pass mmap directly - no copy! - if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode or Full Res: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None and should_resize: - img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) - t_after_fallback_read = time.perf_counter() - if buffer is None: - return None - t_after_fallback_decode = time.perf_counter() - - # EXIF orientation correction - - pass - - # Memory Optimization: Avoid explicit copy - buffer = np.ascontiguousarray(buffer) - bytes_per_line = buffer.strides[0] - mv = memoryview(buffer).cast("B") - - # Align with non-fallback paths for timing/logging - t_after_copy = time.perf_counter() - - if self.debug: - decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_fallback_read - t_before_fallback_read, - t_after_fallback_decode - t_after_fallback_read, - t_after_copy - t_after_fallback_decode, - t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) - else: - # Fall back to standard decode if ICC profile not available - log.warning("ICC mode selected but no monitor profile available, using standard decode") - t_before_read = time.perf_counter() - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - # Pass mmap directly - no copy! - if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode or Full Res: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None and should_resize: - img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) - t_after_read = time.perf_counter() - if buffer is None: - return None - t_after_decode = time.perf_counter() - - # EXIF orientation application - - # Memory Optimization: Avoid explicit copy - buffer = np.ascontiguousarray(buffer) - bytes_per_line = buffer.strides[0] - mv = memoryview(buffer).cast("B") - - # Align with non-fallback paths for timing/logging - t_after_copy = time.perf_counter() - - if self.debug: - decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, - t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) - - else: - # Standard decode path (Option A or no color management) - t_before_read = time.perf_counter() - - buffer = None - if is_jpeg: - try: - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None and should_resize: - img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) - except Exception: - buffer = None - - if buffer is None: - try: - with PILImage.open(image_file.path) as img: - img = img.convert("RGB") - if should_resize: - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) - except Exception as e: - log.warning("Failed to decode image %s: %s", image_file.path, e) - return None - t_after_read = time.perf_counter() - if buffer is None: - return None - t_after_decode = time.perf_counter() - - # EXIF orientation correction moved to post-decode block - - # Memory Optimization: Avoid explicit copy - buffer = np.ascontiguousarray(buffer) - bytes_per_line = buffer.strides[0] - mv = memoryview(buffer).cast("B") - - t_after_copy = time.perf_counter() - - # Unified EXIF Orientation Application - if buffer is not None: - pre_h, pre_w = buffer.shape[:2] - try: - # Optimization: Use pre-read EXIF object if available (ICC path) - # For non-ICC path, we might still need to open it. - if exif_obj is not None: - buffer = apply_exif_orientation(buffer, image_file.path, exif=exif_obj) - else: - # Fallback to opening (Non-ICC path or where we didn't capture it) - with PILImage.open(image_file.path) as img: - buffer = apply_exif_orientation(buffer, image_file.path, exif=img.getexif()) - except Exception as e: - log.warning("Failed to apply EXIF orientation for %s: %s", image_file.path, e) - - # Always re-establish these no matter what happened - h, w = buffer.shape[:2] - buffer = np.ascontiguousarray(buffer) - bytes_per_line = buffer.strides[0] - mv = memoryview(buffer).cast("B") - t_after_orient = time.perf_counter() - - if self.debug and (w != pre_w or h != pre_h): - log.info("Applied EXIF orientation for index %d: %dx%d -> %dx%d", index, pre_w, pre_h, w, h) - - # Apply saturation compensation if enabled - if color_mode == "saturation": - try: - factor = float(config.get('color', 'saturation_factor', fallback="1.0")) - - # Ensure buffer is contiguous and create a 1D view for saturation compensation - # Note: buffer is already made contiguous (np.ascontiguousarray) in the decode blocks above or orientation block - arr = buffer.ravel() - - # Verify shape expectations - if self.debug: - assert buffer.flags['C_CONTIGUOUS'], "Buffer must be C-contiguous for in-place modification" - assert arr.size == h * bytes_per_line, f"Buffer size mismatch: {arr.size} != {h} * {bytes_per_line}" - assert arr.dtype == np.uint8, f"Buffer dtype must be uint8, got {arr.dtype}" - - apply_saturation_compensation(arr, w, h, bytes_per_line, factor) - t_after_saturation = time.perf_counter() - - if self.debug: - decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Saturation decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, saturation=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, t_after_saturation - t_after_copy, - t_after_saturation - t_start, w, h) - except (ValueError, AssertionError) as e: - log.warning("Failed to apply saturation compensation: %s", e) - else: - # No color management - log standard timing - if self.debug: - decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Standard decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, t_after_copy - t_start, w, h) - - # Re-check generation before caching (in case it changed during decode) - if self.generation != generation: - log.debug("Generation changed for index %d before caching (current gen %d != submitted gen %d). Skipping cache_put.", index, self.generation, generation) - return None - - decoded_image = DecodedImage( - buffer=mv, - width=w, - height=h, - bytes_per_line=bytes_per_line, - format=QImage.Format.Format_RGB888 if QImage else None - ) - cache_key = build_cache_key(image_file.path, display_generation) - self.cache_put(cache_key, decoded_image) - log.debug("Successfully decoded and cached image at index %d for display gen %d", index, display_generation) - return image_file.path, display_generation - - except Exception: - log.exception("Error decoding image %s at index %d", image_file.path, index) - - return None - - def _is_in_prefetch_range(self, index: int, current_index: int, radius: Optional[int] = None) -> bool: - """Checks if an index is within the current prefetch window. - - Args: - index: The index to check - current_index: The center of the prefetch window - radius: Optional custom radius; if None, uses self.prefetch_radius - """ - if radius is None: - radius = self.prefetch_radius - return abs(index - current_index) <= radius - - def cancel_all(self): - """Cancels all pending prefetch tasks.""" - with self._futures_lock: - log.info("Cancelling all prefetch tasks.") - self.generation += 1 - for future in self.futures.values(): - future.cancel() - self.futures.clear() - self._scheduled.clear() # Clear scheduled indices when bumping generation - - def shutdown(self): - """Shuts down the thread pool executor.""" - log.info("Shutting down prefetcher thread pool.") - self.cancel_all() - self.executor.shutdown(wait=False) +"""Handles prefetching and decoding of adjacent images in a background thread pool.""" + +import logging +import os +import io +import hashlib +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, Future +from typing import List, Dict, Optional, Callable +import mmap + +import numpy as np +from PIL import Image as PILImage, ImageCms +try: + from PySide6.QtCore import QTimer + from PySide6.QtGui import QImage +except ImportError: + QTimer = None + QImage = None + +from faststack.models import ImageFile, DecodedImage +from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized, TURBO_AVAILABLE +from faststack.imaging.cache import build_cache_key +from faststack.imaging.orientation import apply_exif_orientation +from faststack.config import config + +log = logging.getLogger(__name__) + +import threading + +# ---- Option C: ICC Color Management Setup ---- +SRGB_PROFILE = ImageCms.createProfile("sRGB") + +# Cache for monitor ICC profile to avoid reloading on every decode +_monitor_profile_cache: Dict[str, Optional[ImageCms.ImageCmsProfile]] = {} +_monitor_profile_warning_logged = False + +# Cache for ICC transforms to avoid rebuilding on every image +_icc_transform_cache: Dict[tuple, ImageCms.ImageCmsTransform] = {} + +# Thread lock for all ICC caches +_icc_cache_lock = threading.Lock() + +def get_icc_transform( + src_profile: ImageCms.ImageCmsProfile, + monitor_profile: ImageCms.ImageCmsProfile, + src_profile_key: str, + monitor_profile_path: str, +) -> ImageCms.ImageCmsTransform: + """Get or create a cached ICC transform. + + Building transforms is expensive, so we cache them by stable keys: + - src_profile_key: SHA-256 digest of the embedded ICC bytes + - monitor_profile_path: file path to the monitor ICC profile + """ + key = (src_profile_key, monitor_profile_path) + with _icc_cache_lock: + if key not in _icc_transform_cache: + _icc_transform_cache[key] = ImageCms.buildTransform( + src_profile, monitor_profile, "RGB", "RGB" + ) + log.debug("Built new ICC transform for profile pair (src=%s, monitor=%s)", src_profile_key[:16], monitor_profile_path) + return _icc_transform_cache[key] + +def clear_icc_caches(): + """Clear all ICC-related caches (profiles and transforms).""" + global _monitor_profile_cache, _icc_transform_cache, _monitor_profile_warning_logged + with _icc_cache_lock: + _monitor_profile_cache.clear() + _icc_transform_cache.clear() + _monitor_profile_warning_logged = False + log.info("Cleared ICC profile and transform caches") + +def get_monitor_profile() -> Optional[ImageCms.ImageCmsProfile]: + """Dynamically load monitor ICC profile based on current config. + + Caches the profile by path to reduce overhead and log spam. + """ + global _monitor_profile_warning_logged + + monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() + + with _icc_cache_lock: + # Check cache first + if monitor_icc_path in _monitor_profile_cache: + return _monitor_profile_cache[monitor_icc_path] + + # Handle empty path case + if not monitor_icc_path: + if not _monitor_profile_warning_logged: + log.warning("ICC mode enabled but no monitor_icc_path configured") + _monitor_profile_warning_logged = True + _monitor_profile_cache[monitor_icc_path] = None + return None + + # Load and cache the profile + try: + profile = ImageCms.ImageCmsProfile(monitor_icc_path) + log.debug("Loaded monitor ICC profile: %s", monitor_icc_path) + _monitor_profile_cache[monitor_icc_path] = profile + except (OSError, ImageCms.PyCMSError) as e: + log.warning("Failed to load monitor ICC profile from %s: %s", monitor_icc_path, e) + _monitor_profile_cache[monitor_icc_path] = None + + return _monitor_profile_cache[monitor_icc_path] + + +# apply_exif_orientation imported from orientation.py + +def apply_saturation_compensation( + arr: np.ndarray, + width: int, + height: int, + bytes_per_line: int, + factor: float, +): + """ + In-place saturation scale in RGB space (Option A). + + arr: 1D uint8 array of length height * bytes_per_line + width, height, bytes_per_line: dimensions of the image stored in arr + factor: 0.0-1.0 range, where 1.0 = no change, <1.0 = less saturated + + Note: While the algorithm supports values >1.0 for increased saturation, + the UI constrains the factor to [0.0, 1.0] for saturation reduction only. + """ + if factor == 1.0: + return + + # Treat the buffer as [height, bytes_per_line] + assert arr.size == height * bytes_per_line, ( + f"Unexpected buffer size for saturation compensation: " + f"{arr.size} != {height} * {bytes_per_line}" + ) + buf2d = arr.reshape((height, bytes_per_line)) + + # Only the first width*3 bytes per row are actual RGB pixels + rgb_region = buf2d[:, : width * 3] + + # Interpret as H x W x 3 + rgb = rgb_region.reshape((height, width, 3)).astype(np.float32) + + # Simple saturation scaling: move each channel toward its per-pixel average + gray = rgb.mean(axis=2, keepdims=True) + rgb = gray + factor * (rgb - gray) + + np.clip(rgb, 0, 255, out=rgb) + + # Write back into the same memory + rgb_region[:] = rgb.reshape(height, width * 3).astype(np.uint8) + +class Prefetcher: + def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable, debug: bool = False): + self.image_files = image_files + self.cache_put = cache_put + self.prefetch_radius = prefetch_radius + self.get_display_info = get_display_info + self.debug = debug + # 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, 4) # Cap at 4 + + self.executor = ThreadPoolExecutor( + max_workers=optimal_workers, + thread_name_prefix="Prefetcher" + ) + self._futures_lock = threading.RLock() + self.futures: Dict[int, Future] = {} + self.generation = 0 + self._scheduled: Dict[int, set] = {} # generation -> set of scheduled indices + + # Adaptive prefetch: start with smaller radius, expand after user navigates + self._initial_radius = 2 # Small radius at startup to reduce cache thrash + self._navigation_count = 0 # Track how many times user has navigated + self._radius_expanded = False + + # Directional prefetching + self._last_navigation_direction: int = 1 # 1 = forward, -1 = backward + self._direction_bias: float = 0.7 # 70% of radius in travel direction + + 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, is_navigation: bool = False, direction: Optional[int] = None): + """Updates the prefetching queue based on the current image index. + + Args: + current_index: The index to prefetch around + is_navigation: True if this is from user navigation (arrow keys, etc.) + direction: 1 for forward, -1 for backward, None to use last direction + """ + # NOTE: Generation is NOT incremented here. It only changes when display size, + # zoom state, or color mode changes - events that actually invalidate cached images. + # Navigation just shifts which indices to prefetch. + + # OLD GENERATION CLEANUP MOVED TO INSIDE LOCK BELOW + + # Track navigation direction + if direction is not None: + self._last_navigation_direction = direction + + # Track navigation to expand radius after user starts moving + if is_navigation: + self._navigation_count += 1 + if not self._radius_expanded and self._navigation_count >= 2: + self._radius_expanded = True + log.info("Expanding prefetch radius from %d to %d after user navigation", self._initial_radius, self.prefetch_radius) + + # Use smaller radius initially to reduce cache thrash before display size is stable + effective_radius = self._initial_radius if not self._radius_expanded else self.prefetch_radius + + if self.debug: + log.info("Prefetch radius: initial=%d, configured=%d, effective=%d", + self._initial_radius, self.prefetch_radius, effective_radius) + + # Calculate asymmetric range based on direction + if self._last_navigation_direction > 0: # Moving forward + behind = max(1, int(effective_radius * (1 - self._direction_bias))) + ahead = effective_radius - behind + 1 + else: # Moving backward + ahead = max(1, int(effective_radius * (1 - self._direction_bias))) + behind = effective_radius - ahead + 1 + + start = max(0, current_index - behind) + end = min(len(self.image_files), current_index + ahead + 1) + + log.debug("Prefetch range: [%d, %d) for index %d (direction=%d, behind=%d, ahead=%d)", + start, end, current_index, self._last_navigation_direction, behind, ahead) + + # Cancel stale futures and remove from scheduled + with self._futures_lock: + # Clean up old generation entries to prevent memory leak + # MOVED INSIDE LOCK to prevent race with cancel_all() + old_generations = [g for g in self._scheduled if g < self.generation] + for g in old_generations: + del self._scheduled[g] + + # Get scheduled set for current generation (inside lock to prevent race) + scheduled = self._scheduled.setdefault(self.generation, set()) + stale_keys = [] + for index, future in list(self.futures.items()): + if index < start or index >= end: + if future.cancel(): + stale_keys.append(index) + scheduled.discard(index) # Remove from scheduled set + for key in stale_keys: + del self.futures[key] + + # Submit new tasks - prioritize current image and direction of travel + + # Build priority order: current first, then in direction of travel + priority_order = [current_index] + if self._last_navigation_direction > 0: + priority_order.extend(range(current_index + 1, end)) + priority_order.extend(range(current_index - 1, start - 1, -1)) + else: + priority_order.extend(range(current_index - 1, start - 1, -1)) + priority_order.extend(range(current_index + 1, end)) + + for i in priority_order: + if i < 0 or i >= len(self.image_files): + continue + if i not in scheduled and i not in self.futures: + self.submit_task(i, self.generation) + scheduled.add(i) + + def submit_task(self, index: int, generation: int, priority: bool = False) -> Optional[Future]: + """Submits a decoding task for a given index. + + Args: + index: Image index to decode + generation: Generation number for cache invalidation + priority: If True, cancels lower-priority pending tasks to free up workers + """ + with self._futures_lock: + if index in self.futures and not self.futures[index].done(): + return self.futures[index] # Already submitted + + # For high-priority tasks (current image), cancel pending prefetch tasks + # to free up worker threads and reduce blocking time + if priority: + cancelled_count = 0 + # Don't cancel tasks that are very close to the requested index (e.g. +/- 2) + # This prevents thrashing when the user is navigating quickly + safe_radius = 2 + + for task_index, future in list(self.futures.items()): + # Skip the current task + if task_index == index: + continue + + # Skip tasks within safe radius + if abs(task_index - index) <= safe_radius: + continue + + if not future.done() and future.cancel(): + cancelled_count += 1 + del self.futures[task_index] + if cancelled_count > 0: + log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index) + + 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("Submitted %s task for index %d", "priority" if priority else "prefetch", 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[Path, int]]: + """The actual work done by the thread pool.""" + import time + + t_start = time.perf_counter() + exif_obj = None # Ensure variable is always initialized + + # Early check: if generation has already advanced since this task was submitted, skip it + if generation != self.generation: + log.debug("Skipping stale task for index %d (submitted gen %d != current gen %d)", index, generation, self.generation) + return None + + try: + # Check for empty file to avoid mmap error + if os.path.getsize(image_file.path) == 0: + log.warning("Skipping empty image file: %s", image_file.path) + return None + + # Get current color management mode and optimization setting + color_mode = config.get('color', 'mode', fallback="none").lower() + optimize_for = config.get('core', 'optimize_for', fallback='speed').lower() + fast_dct = (optimize_for == 'speed') + use_resized = (optimize_for == 'speed') # Use decode_jpeg_resized for speed, decode_jpeg_rgb for quality + + # Determine if we should resize + should_resize = (display_width > 0 and display_height > 0) + + # Determine file type + is_jpeg = image_file.path.suffix.lower() in {'.jpg', '.jpeg', '.jpe'} + + # Option C: Full ICC pipeline - Use TurboJPEG for decode, Pillow only for ICC conversion + if color_mode == "icc": + monitor_profile = get_monitor_profile() + monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() + + if monitor_profile is not None: + # FAST: Use TurboJPEG for decode + resize (ONLY for JPEGs) + buffer = None + t_before_read = time.perf_counter() + + if is_jpeg: + try: + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + # Pass mmap directly - no copy! Decoders accept bytes-like objects + if use_resized and should_resize: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode or Full Res: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None and should_resize: + img = PILImage.fromarray(buffer) + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + except Exception: + log.debug("TurboJPEG failed on JPEG %s, falling back", image_file.path) + buffer = None + + # If not JPEG or TurboJPEG failed, try generic Pillow load + if buffer is None: + try: + # We can't use mmap for Generic Pillow open widely (some formats need seek/tell on file) + # So we open nominally. + with PILImage.open(image_file.path) as img: + img = img.convert("RGB") + if should_resize: + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + except Exception as e: + log.warning("Failed to decode image %s: %s", image_file.path, e) + return None + + t_after_read = time.perf_counter() + if buffer is None: + return None + t_after_decode = time.perf_counter() + + # Convert numpy array to PIL Image for ICC conversion + img = PILImage.fromarray(buffer) + t_after_array_to_pil = time.perf_counter() + + # Extract ICC profile AND EXIF from original file (need to read header only) + t_before_profile_read = time.perf_counter() + exif_obj = None + with PILImage.open(image_file.path) as orig: + icc_bytes = orig.info.get("icc_profile") + exif_obj = orig.getexif() # Capture EXIF while open + t_after_profile_read = time.perf_counter() + + src_profile = None + src_profile_key = None + if icc_bytes: + try: + src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) + # Compute stable key: SHA-256 digest of ICC bytes + src_profile_key = hashlib.sha256(icc_bytes).hexdigest() + log.debug("Using embedded ICC profile from %s", image_file.path) + except (OSError, ImageCms.PyCMSError, ValueError) as e: + log.warning("Failed to parse ICC profile from %s: %s", image_file.path, e) + + if src_profile is None: + src_profile = SRGB_PROFILE + # Use a constant key for sRGB since it's always the same + src_profile_key = "srgb_builtin" + log.debug("No embedded profile, assuming sRGB for %s", image_file.path) + + # Convert from source profile to monitor profile using cached transform + try: + log.debug("Converting image from source to monitor profile") + t_before_icc = time.perf_counter() + transform = get_icc_transform(src_profile, monitor_profile, src_profile_key, monitor_icc_path) + # Alan 11-20-25 - Add inPlace=True to speed up copy, shouldn't have many negative effects + ImageCms.applyTransform(img, transform, inPlace=True) + t_after_icc = time.perf_counter() + + rgb = np.array(img, dtype=np.uint8) + + # Note: We do NOT apply EXIF orientation here anymore. + # It is handled in the Unified EXIF Orientation Application block below. + # This avoids "double rotation" or potential "apply and discard" bugs. + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(rgb) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + t_after_copy = time.perf_counter() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("ICC decode timing for index %d (%s): read=%.3fs, decode=%.3fs, array_to_pil=%.3fs, profile_read=%.3fs, icc=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_array_to_pil - t_after_decode, t_after_profile_read - t_before_profile_read, + t_after_icc - t_before_icc, t_after_copy - t_after_icc, + t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) + except (OSError, ImageCms.PyCMSError, ValueError) as e: + # ICC conversion failed, fall back to standard decode + log.warning("ICC profile conversion failed for %s: %s, falling back to standard decode", image_file.path, e) + t_before_fallback_read = time.perf_counter() + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + # Pass mmap directly - no copy! + if use_resized and should_resize: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode or Full Res: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None and should_resize: + img = PILImage.fromarray(buffer) + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + t_after_fallback_read = time.perf_counter() + if buffer is None: + return None + t_after_fallback_decode = time.perf_counter() + + # EXIF orientation correction + + pass + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(buffer) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + + # Align with non-fallback paths for timing/logging + t_after_copy = time.perf_counter() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_fallback_read - t_before_fallback_read, + t_after_fallback_decode - t_after_fallback_read, + t_after_copy - t_after_fallback_decode, + t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) + else: + # Fall back to standard decode if ICC profile not available + log.warning("ICC mode selected but no monitor profile available, using standard decode") + t_before_read = time.perf_counter() + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + # Pass mmap directly - no copy! + if use_resized and should_resize: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode or Full Res: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None and should_resize: + img = PILImage.fromarray(buffer) + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + t_after_read = time.perf_counter() + if buffer is None: + return None + t_after_decode = time.perf_counter() + + # EXIF orientation application + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(buffer) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + + # Align with non-fallback paths for timing/logging + t_after_copy = time.perf_counter() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_copy - t_after_decode, + t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) + + else: + # Standard decode path (Option A or no color management) + t_before_read = time.perf_counter() + + buffer = None + if is_jpeg: + try: + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + if use_resized and should_resize: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None and should_resize: + img = PILImage.fromarray(buffer) + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + except Exception: + buffer = None + + if buffer is None: + try: + with PILImage.open(image_file.path) as img: + img = img.convert("RGB") + if should_resize: + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + buffer = np.array(img) + except Exception as e: + log.warning("Failed to decode image %s: %s", image_file.path, e) + return None + t_after_read = time.perf_counter() + if buffer is None: + return None + t_after_decode = time.perf_counter() + + # EXIF orientation correction moved to post-decode block + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(buffer) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + + t_after_copy = time.perf_counter() + + # Unified EXIF Orientation Application + if buffer is not None: + pre_h, pre_w = buffer.shape[:2] + try: + # Optimization: Use pre-read EXIF object if available (ICC path) + # For non-ICC path, we might still need to open it. + if exif_obj is not None: + buffer = apply_exif_orientation(buffer, image_file.path, exif=exif_obj) + else: + # Fallback to opening (Non-ICC path or where we didn't capture it) + with PILImage.open(image_file.path) as img: + buffer = apply_exif_orientation(buffer, image_file.path, exif=img.getexif()) + except Exception as e: + log.warning("Failed to apply EXIF orientation for %s: %s", image_file.path, e) + + # Always re-establish these no matter what happened + h, w = buffer.shape[:2] + buffer = np.ascontiguousarray(buffer) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + + if self.debug and (w != pre_w or h != pre_h): + log.info("Applied EXIF orientation for index %d: %dx%d -> %dx%d", index, pre_w, pre_h, w, h) + + # Apply saturation compensation if enabled + if color_mode == "saturation": + try: + factor = float(config.get('color', 'saturation_factor', fallback="1.0")) + + # Ensure buffer is contiguous and create a 1D view for saturation compensation + # Note: buffer is already made contiguous (np.ascontiguousarray) in the decode blocks above or orientation block + arr = buffer.ravel() + + # Verify shape expectations + if self.debug: + assert buffer.flags['C_CONTIGUOUS'], "Buffer must be C-contiguous for in-place modification" + assert arr.size == h * bytes_per_line, f"Buffer size mismatch: {arr.size} != {h} * {bytes_per_line}" + assert arr.dtype == np.uint8, f"Buffer dtype must be uint8, got {arr.dtype}" + + apply_saturation_compensation(arr, w, h, bytes_per_line, factor) + t_after_saturation = time.perf_counter() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("Saturation decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, saturation=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_copy - t_after_decode, t_after_saturation - t_after_copy, + t_after_saturation - t_start, w, h) + except (ValueError, AssertionError) as e: + log.warning("Failed to apply saturation compensation: %s", e) + else: + # No color management - log standard timing + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("Standard decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_copy - t_after_decode, t_after_copy - t_start, w, h) + + # Re-check generation before caching (in case it changed during decode) + if self.generation != generation: + log.debug("Generation changed for index %d before caching (current gen %d != submitted gen %d). Skipping cache_put.", index, self.generation, generation) + return None + + decoded_image = DecodedImage( + buffer=mv, + width=w, + height=h, + bytes_per_line=bytes_per_line, + format=QImage.Format.Format_RGB888 if QImage else None + ) + cache_key = build_cache_key(image_file.path, display_generation) + self.cache_put(cache_key, decoded_image) + log.debug("Successfully decoded and cached image at index %d for display gen %d", index, display_generation) + return image_file.path, display_generation + + except (OSError, IOError, ValueError, MemoryError) as e: + log.warning("Error decoding image %s at index %d: %s", image_file.path, index, e) + + return None + + def _is_in_prefetch_range(self, index: int, current_index: int, radius: Optional[int] = None) -> bool: + """Checks if an index is within the current prefetch window. + + Args: + index: The index to check + current_index: The center of the prefetch window + radius: Optional custom radius; if None, uses self.prefetch_radius + """ + if radius is None: + radius = self.prefetch_radius + return abs(index - current_index) <= radius + + def cancel_all(self): + """Cancels all pending prefetch tasks.""" + with self._futures_lock: + log.info("Cancelling all prefetch tasks.") + self.generation += 1 + for future in self.futures.values(): + future.cancel() + self.futures.clear() + self._scheduled.clear() # Clear scheduled indices when bumping generation + + 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/io/executable_validator.py b/faststack/io/executable_validator.py index 0807962..b8f32a5 100644 --- a/faststack/io/executable_validator.py +++ b/faststack/io/executable_validator.py @@ -1,119 +1,119 @@ -"""Secure validation of executable paths before execution.""" - -import logging -import os -from pathlib import Path -from typing import Optional, List - -log = logging.getLogger(__name__) - -# Known safe installation directories for common applications on Windows -KNOWN_SAFE_PATHS = [ - r"C:\Program Files", - r"C:\Program Files (x86)", -] - -# Known executable names that are safe to run -KNOWN_SAFE_EXECUTABLES = { - "photoshop": ["Photoshop.exe"], - "helicon": ["HeliconFocus.exe"], -} - - -def validate_executable_path( - exe_path: str, - app_type: Optional[str] = None, - allow_custom_paths: bool = True -) -> tuple[bool, Optional[str]]: - """ - Validates an executable path before execution. - - Args: - exe_path: Path to the executable to validate - app_type: Type of application (e.g., 'photoshop', 'helicon') for additional checks - allow_custom_paths: Whether to allow executables outside known safe paths - - Returns: - Tuple of (is_valid, error_message) - If valid, error_message is None - If invalid, error_message contains reason - """ - if not exe_path: - return False, "Executable path is empty" - - try: - path = Path(exe_path).resolve() - except (ValueError, OSError) as e: - log.exception(f"Invalid path format: {exe_path}") - return False, f"Invalid path format: {e}" - - # Check if file exists - if not path.exists(): - return False, f"Executable not found: {exe_path}" - - if not path.is_file(): - return False, f"Path is not a file: {exe_path}" - - # Check if it's actually an executable - if not _is_executable(path): - return False, f"File is not executable: {exe_path}" - - # Check if the executable name matches expected names for the app type - if app_type and app_type in KNOWN_SAFE_EXECUTABLES: - expected_names = KNOWN_SAFE_EXECUTABLES[app_type] - if path.name not in expected_names: - log.warning( - f"Executable name '{path.name}' does not match expected names " - f"for {app_type}: {expected_names}" - ) - if not allow_custom_paths: - return False, f"Executable name mismatch: {path.name}" - - # Check if in known safe directory - in_safe_path = any( - _is_subpath(path, Path(safe_path)) - for safe_path in KNOWN_SAFE_PATHS - ) - - if not in_safe_path: - if not allow_custom_paths: - return False, f"Executable not in allowed directory: {exe_path}" - else: - log.warning( - f"Executable '{exe_path}' is not in a known safe directory. " - f"Proceeding with caution." - ) - - # Check for suspicious paths (potential directory traversal, etc.) - try: - normalized = os.path.normpath(exe_path) - if ".." in normalized or normalized != str(path): - log.warning(f"Suspicious path detected: {exe_path}") - if not allow_custom_paths: - return False, f"Suspicious path detected: {exe_path}" - except (ValueError, OSError) as e: - log.exception("Error normalizing path") - return False, f"Path validation error: {e}" - - return True, None - - -def _is_executable(path: Path) -> bool: - """Check if a file is executable (has .exe extension on Windows).""" - # Always accept .exe extension (mocked tests might run on Linux) - if path.suffix.lower() == '.exe': - return True - - if os.name == 'nt': # Windows - return path.suffix.lower() == '.exe' - else: # Unix-like - return os.access(path, os.X_OK) - - -def _is_subpath(path: Path, parent: Path) -> bool: - """Check if path is a subpath of parent.""" - try: - path.resolve().relative_to(parent.resolve()) - return True - except (ValueError, RuntimeError): - return False +"""Secure validation of executable paths before execution.""" + +import logging +import os +from pathlib import Path +from typing import Optional, List + +log = logging.getLogger(__name__) + +# Known safe installation directories for common applications on Windows +KNOWN_SAFE_PATHS = [ + r"C:\Program Files", + r"C:\Program Files (x86)", +] + +# Known executable names that are safe to run +KNOWN_SAFE_EXECUTABLES = { + "photoshop": ["Photoshop.exe"], + "helicon": ["HeliconFocus.exe"], +} + + +def validate_executable_path( + exe_path: str, + app_type: Optional[str] = None, + allow_custom_paths: bool = True +) -> tuple[bool, Optional[str]]: + """ + Validates an executable path before execution. + + Args: + exe_path: Path to the executable to validate + app_type: Type of application (e.g., 'photoshop', 'helicon') for additional checks + allow_custom_paths: Whether to allow executables outside known safe paths + + Returns: + Tuple of (is_valid, error_message) + If valid, error_message is None + If invalid, error_message contains reason + """ + if not exe_path: + return False, "Executable path is empty" + + try: + path = Path(exe_path).resolve() + except (ValueError, OSError) as e: + log.exception(f"Invalid path format: {exe_path}") + return False, f"Invalid path format: {e}" + + # Check if file exists + if not path.exists(): + return False, f"Executable not found: {exe_path}" + + if not path.is_file(): + return False, f"Path is not a file: {exe_path}" + + # Check if it's actually an executable + if not _is_executable(path): + return False, f"File is not executable: {exe_path}" + + # Check if the executable name matches expected names for the app type + if app_type and app_type in KNOWN_SAFE_EXECUTABLES: + expected_names = KNOWN_SAFE_EXECUTABLES[app_type] + if path.name not in expected_names: + log.warning( + f"Executable name '{path.name}' does not match expected names " + f"for {app_type}: {expected_names}" + ) + if not allow_custom_paths: + return False, f"Executable name mismatch: {path.name}" + + # Check if in known safe directory + in_safe_path = any( + _is_subpath(path, Path(safe_path)) + for safe_path in KNOWN_SAFE_PATHS + ) + + if not in_safe_path: + if not allow_custom_paths: + return False, f"Executable not in allowed directory: {exe_path}" + else: + log.warning( + f"Executable '{exe_path}' is not in a known safe directory. " + f"Proceeding with caution." + ) + + # Check for suspicious paths (potential directory traversal, etc.) + try: + normalized = os.path.normpath(exe_path) + if ".." in normalized or normalized != str(path): + log.warning(f"Suspicious path detected: {exe_path}") + if not allow_custom_paths: + return False, f"Suspicious path detected: {exe_path}" + except (ValueError, OSError) as e: + log.exception("Error normalizing path") + return False, f"Path validation error: {e}" + + return True, None + + +def _is_executable(path: Path) -> bool: + """Check if a file is executable (has .exe extension on Windows).""" + # Always accept .exe extension (mocked tests might run on Linux) + if path.suffix.lower() == '.exe': + return True + + if os.name == 'nt': # Windows + return path.suffix.lower() == '.exe' + else: # Unix-like + return os.access(path, os.X_OK) + + +def _is_subpath(path: Path, parent: Path) -> bool: + """Check if path is a subpath of parent.""" + try: + path.resolve().relative_to(parent.resolve()) + return True + except (ValueError, RuntimeError): + return False diff --git a/faststack/io/helicon.py b/faststack/io/helicon.py index c943a3a..5969048 100644 --- a/faststack/io/helicon.py +++ b/faststack/io/helicon.py @@ -1,94 +1,94 @@ -"""Handles launching Helicon Focus with a list of RAW files.""" - -import logging -import os -import shlex -import subprocess -import tempfile -from pathlib import Path -from typing import List, Optional, Tuple - -from faststack.config import config -from faststack.io.executable_validator import validate_executable_path - -log = logging.getLogger(__name__) - -def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: - """Launches Helicon Focus with the provided list of RAW files. - - Args: - raw_files: A list of absolute paths to RAW files. - - Returns: - Tuple of (success: bool, tmp_path: Optional[Path]). - Returns (True, tmp_path) if launched successfully, (False, None) otherwise. - On success, the caller is responsible for deleting the returned temporary file - after Helicon Focus completes processing. - """ - helicon_exe = config.get("helicon", "exe") - if not helicon_exe or not isinstance(helicon_exe, str): - log.error("Helicon Focus executable path not configured or invalid.") - return False, None - - # Validate executable path securely - is_valid, error_msg = validate_executable_path( - helicon_exe, - app_type="helicon", - allow_custom_paths=True - ) - - if not is_valid: - log.error(f"Helicon Focus executable validation failed: {error_msg}") - return False, None - - if not raw_files: - log.warning("No RAW files selected to open in Helicon Focus.") - return False, None - - try: - with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt", encoding='utf-8') as tmp: - for f in raw_files: - # Ensure file path is resolved and exists - if not f.exists(): - log.warning(f"RAW file does not exist, skipping: {f}") - continue - tmp.write(f"{f.resolve()}\n") - tmp_path = Path(tmp.name) - - log.info(f"Temporary file for Helicon Focus: {tmp_path}") - log.info(f"Input files: {[str(f) for f in raw_files]}") - - # Build command list safely - args = [helicon_exe, "-i", str(tmp_path.resolve())] - - # Parse additional args safely using shlex (handles quotes and escapes properly) - extra_args = config.get("helicon", "args") - if extra_args: - try: - # Use shlex to properly parse arguments with quotes/escapes - # On Windows, use posix=False to handle Windows-style paths - parsed_args = shlex.split(extra_args, posix=(os.name != 'nt')) - args.extend(parsed_args) - except ValueError as e: - log.exception(f"Invalid helicon args format: {e}") - return False, None - - log.info(f"Launching Helicon Focus with {len(raw_files)} files") - log.info(f"Command: {' '.join(args)}") - - # SECURITY: Explicitly disable shell execution - subprocess.Popen( - args, - shell=False, # CRITICAL: Never use shell=True with user input - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - close_fds=True # Close unused file descriptors - ) - return True, tmp_path - except (OSError, subprocess.SubprocessError) as e: - log.exception(f"Failed to launch Helicon Focus: {e}") - return False, None - except (IOError, PermissionError) as e: - log.exception(f"Failed to create temporary file for Helicon Focus: {e}") - return False, None +"""Handles launching Helicon Focus with a list of RAW files.""" + +import logging +import os +import shlex +import subprocess +import tempfile +from pathlib import Path +from typing import List, Optional, Tuple + +from faststack.config import config +from faststack.io.executable_validator import validate_executable_path + +log = logging.getLogger(__name__) + +def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: + """Launches Helicon Focus with the provided list of RAW files. + + Args: + raw_files: A list of absolute paths to RAW files. + + Returns: + Tuple of (success: bool, tmp_path: Optional[Path]). + Returns (True, tmp_path) if launched successfully, (False, None) otherwise. + On success, the caller is responsible for deleting the returned temporary file + after Helicon Focus completes processing. + """ + helicon_exe = config.get("helicon", "exe") + if not helicon_exe or not isinstance(helicon_exe, str): + log.error("Helicon Focus executable path not configured or invalid.") + return False, None + + # Validate executable path securely + is_valid, error_msg = validate_executable_path( + helicon_exe, + app_type="helicon", + allow_custom_paths=True + ) + + if not is_valid: + log.error(f"Helicon Focus executable validation failed: {error_msg}") + return False, None + + if not raw_files: + log.warning("No RAW files selected to open in Helicon Focus.") + return False, None + + try: + with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt", encoding='utf-8') as tmp: + for f in raw_files: + # Ensure file path is resolved and exists + if not f.exists(): + log.warning(f"RAW file does not exist, skipping: {f}") + continue + tmp.write(f"{f.resolve()}\n") + tmp_path = Path(tmp.name) + + log.info(f"Temporary file for Helicon Focus: {tmp_path}") + log.info(f"Input files: {[str(f) for f in raw_files]}") + + # Build command list safely + args = [helicon_exe, "-i", str(tmp_path.resolve())] + + # Parse additional args safely using shlex (handles quotes and escapes properly) + extra_args = config.get("helicon", "args") + if extra_args: + try: + # Use shlex to properly parse arguments with quotes/escapes + # On Windows, use posix=False to handle Windows-style paths + parsed_args = shlex.split(extra_args, posix=(os.name != 'nt')) + args.extend(parsed_args) + except ValueError as e: + log.exception(f"Invalid helicon args format: {e}") + return False, None + + log.info(f"Launching Helicon Focus with {len(raw_files)} files") + log.info(f"Command: {' '.join(args)}") + + # SECURITY: Explicitly disable shell execution + subprocess.Popen( + args, + shell=False, # CRITICAL: Never use shell=True with user input + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True # Close unused file descriptors + ) + return True, tmp_path + except (OSError, subprocess.SubprocessError) as e: + log.exception(f"Failed to launch Helicon Focus: {e}") + return False, None + except (IOError, PermissionError) as e: + log.exception(f"Failed to create temporary file for Helicon Focus: {e}") + return False, None diff --git a/faststack/io/indexer.py b/faststack/io/indexer.py index 8597d57..c9a14a4 100644 --- a/faststack/io/indexer.py +++ b/faststack/io/indexer.py @@ -1,139 +1,139 @@ -"""Scans directories for JPGs and pairs them with corresponding RAW files.""" - -import logging -import os -import time -from pathlib import Path -from typing import List, Dict, Tuple - -from faststack.models import ImageFile - -log = logging.getLogger(__name__) - -RAW_EXTENSIONS = { - ".ORF", ".RW2", ".CR2", ".CR3", ".ARW", ".NEF", ".RAF", ".DNG", - ".orf", ".rw2", ".cr2", ".cr3", ".arw", ".nef", ".raf", ".dng", -} - -JPG_EXTENSIONS = { ".JPG", ".JPEG", ".jpg", ".jpeg" } - -def find_images(directory: Path) -> List[ImageFile]: - """Finds all JPGs in a directory and pairs them with RAW files.""" - t_start = time.perf_counter() - log.info("Scanning directory for images: %s", directory) - - # Categorize files - all_jpgs: List[Tuple[Path, os.stat_result]] = [] - raws: Dict[str, List[Tuple[Path, os.stat_result]]] = {} - - try: - for entry in os.scandir(directory): - if entry.is_file(): - p = Path(entry.path) - ext = p.suffix - if ext in JPG_EXTENSIONS: - all_jpgs.append((p, entry.stat())) - elif ext in RAW_EXTENSIONS: - stem = p.stem - if stem not in raws: - raws[stem] = [] - raws[stem].append((p, entry.stat())) - except OSError as e: - log.exception("Error scanning directory %s", directory) - return [] - - # Separate developed JPGs, build base map, and process normal JPGs - # base_map: filename.casefold() -> (mtime, name) - base_map: Dict[str, Tuple[float, str]] = {} - developed_candidates: List[Tuple[Path, os.stat_result, str]] = [] # path, stat, base_stem - - image_entries: List[Tuple[Tuple[float, str, int, str], ImageFile]] = [] - used_raws = set() - - for p, stat in all_jpgs: - is_dev, base_stem = _parse_developed(p) - if is_dev: - developed_candidates.append((p, stat, base_stem)) - else: - # Register in base_map for developed images to find their parents - base_map[p.name.casefold()] = (stat.st_mtime, p.name) - - # Process as normal JPG - raw_pair = _find_raw_pair(p, stat, raws.get(p.stem, [])) - if raw_pair: - used_raws.add(raw_pair) - - img = ImageFile(path=p, raw_pair=raw_pair, timestamp=stat.st_mtime) - image_entries.append(((stat.st_mtime, p.name.casefold(), 0, p.name.casefold()), img)) - - # 2. Process Developed JPGs - for p, stat, base_stem in developed_candidates: - # Try to find base image in priority order: .jpg, .jpeg, .jpe - effective_ts = stat.st_mtime - effective_name = p.name.casefold() - - for ext in [".jpg", ".jpeg", ".jpe"]: - candidate = (base_stem + ext).casefold() - if candidate in base_map: - base_ts, base_name = base_map[candidate] - effective_ts = base_ts - effective_name = base_name.casefold() - break - - img = ImageFile(path=p, raw_pair=None, timestamp=stat.st_mtime) - image_entries.append(((effective_ts, effective_name, 1, p.name.casefold()), img)) - - # 3. Handle orphaned RAWs - for stem, raw_list in raws.items(): - for raw_path, raw_stat in raw_list: - if raw_path not in used_raws: - img = ImageFile(path=raw_path, raw_pair=raw_path, timestamp=raw_stat.st_mtime) - image_entries.append(((raw_stat.st_mtime, raw_path.name.casefold(), 0, raw_path.name.casefold()), img)) - - # Final Sort - image_entries.sort(key=lambda x: x[0]) - image_files = [x[1] for x in image_entries] - - elapsed = time.perf_counter() - t_start - paired_count = sum(1 for im in image_files if im.raw_pair and im.path.suffix.lower() in JPG_EXTENSIONS) - raw_only_count = sum(1 for im in image_files if im.path.suffix.lower() not in JPG_EXTENSIONS) - - if log.isEnabledFor(logging.DEBUG): - log.info("Found %d total, %d paired, %d raw-only in %.3fs", - len(image_files), paired_count, raw_only_count, elapsed) - else: - log.info("Found %d images (%d paired, %d raw-only).", len(image_files), paired_count, raw_only_count) - return image_files - -def _parse_developed(path: Path) -> Tuple[bool, str]: - """ - Detects if a file is a developed image. - Returns (is_developed, base_stem). - Suffix match for '-developed' is case-insensitive. - """ - stem = path.stem - if stem.lower().endswith("-developed"): - base_stem = stem[:-10] # Remove "-developed" - return True, base_stem - return False, "" - -def _find_raw_pair( - jpg_path: Path, - jpg_stat: os.stat_result, - potential_raws: List[Tuple[Path, os.stat_result]] -) -> Path | None: - """Finds the best RAW pair for a JPG from a list of candidates.""" - if not potential_raws: - return None - - # Find the RAW file with the closest modification time within a 2-second window - best_match: Path | None = None - min_dt = 2.0 # seconds - - for raw_path, raw_stat in potential_raws: - dt = abs(jpg_stat.st_mtime - raw_stat.st_mtime) - if dt <= min_dt: - min_dt = dt - best_match = raw_path - - return best_match +"""Scans directories for JPGs and pairs them with corresponding RAW files.""" + +import logging +import os +import time +from pathlib import Path +from typing import List, Dict, Tuple + +from faststack.models import ImageFile + +log = logging.getLogger(__name__) + +RAW_EXTENSIONS = { + ".ORF", ".RW2", ".CR2", ".CR3", ".ARW", ".NEF", ".RAF", ".DNG", + ".orf", ".rw2", ".cr2", ".cr3", ".arw", ".nef", ".raf", ".dng", +} + +JPG_EXTENSIONS = { ".JPG", ".JPEG", ".jpg", ".jpeg" } + +def find_images(directory: Path) -> List[ImageFile]: + """Finds all JPGs in a directory and pairs them with RAW files.""" + t_start = time.perf_counter() + log.info("Scanning directory for images: %s", directory) + + # Categorize files + all_jpgs: List[Tuple[Path, os.stat_result]] = [] + raws: Dict[str, List[Tuple[Path, os.stat_result]]] = {} + + try: + for entry in os.scandir(directory): + if entry.is_file(): + p = Path(entry.path) + ext = p.suffix + if ext in JPG_EXTENSIONS: + all_jpgs.append((p, entry.stat())) + elif ext in RAW_EXTENSIONS: + stem = p.stem + if stem not in raws: + raws[stem] = [] + raws[stem].append((p, entry.stat())) + except OSError as e: + log.exception("Error scanning directory %s", directory) + return [] + + # Separate developed JPGs, build base map, and process normal JPGs + # base_map: filename.casefold() -> (mtime, name) + base_map: Dict[str, Tuple[float, str]] = {} + developed_candidates: List[Tuple[Path, os.stat_result, str]] = [] # path, stat, base_stem + + image_entries: List[Tuple[Tuple[float, str, int, str], ImageFile]] = [] + used_raws = set() + + for p, stat in all_jpgs: + is_dev, base_stem = _parse_developed(p) + if is_dev: + developed_candidates.append((p, stat, base_stem)) + else: + # Register in base_map for developed images to find their parents + base_map[p.name.casefold()] = (stat.st_mtime, p.name) + + # Process as normal JPG + raw_pair = _find_raw_pair(p, stat, raws.get(p.stem, [])) + if raw_pair: + used_raws.add(raw_pair) + + img = ImageFile(path=p, raw_pair=raw_pair, timestamp=stat.st_mtime) + image_entries.append(((stat.st_mtime, p.name.casefold(), 0, p.name.casefold()), img)) + + # 2. Process Developed JPGs + for p, stat, base_stem in developed_candidates: + # Try to find base image in priority order: .jpg, .jpeg, .jpe + effective_ts = stat.st_mtime + effective_name = p.name.casefold() + + for ext in [".jpg", ".jpeg", ".jpe"]: + candidate = (base_stem + ext).casefold() + if candidate in base_map: + base_ts, base_name = base_map[candidate] + effective_ts = base_ts + effective_name = base_name.casefold() + break + + img = ImageFile(path=p, raw_pair=None, timestamp=stat.st_mtime) + image_entries.append(((effective_ts, effective_name, 1, p.name.casefold()), img)) + + # 3. Handle orphaned RAWs + for stem, raw_list in raws.items(): + for raw_path, raw_stat in raw_list: + if raw_path not in used_raws: + img = ImageFile(path=raw_path, raw_pair=raw_path, timestamp=raw_stat.st_mtime) + image_entries.append(((raw_stat.st_mtime, raw_path.name.casefold(), 0, raw_path.name.casefold()), img)) + + # Final Sort + image_entries.sort(key=lambda x: x[0]) + image_files = [x[1] for x in image_entries] + + elapsed = time.perf_counter() - t_start + paired_count = sum(1 for im in image_files if im.raw_pair and im.path.suffix.lower() in JPG_EXTENSIONS) + raw_only_count = sum(1 for im in image_files if im.path.suffix.lower() not in JPG_EXTENSIONS) + + if log.isEnabledFor(logging.DEBUG): + log.info("Found %d total, %d paired, %d raw-only in %.3fs", + len(image_files), paired_count, raw_only_count, elapsed) + else: + log.info("Found %d images (%d paired, %d raw-only).", len(image_files), paired_count, raw_only_count) + return image_files + +def _parse_developed(path: Path) -> Tuple[bool, str]: + """ + Detects if a file is a developed image. + Returns (is_developed, base_stem). + Suffix match for '-developed' is case-insensitive. + """ + stem = path.stem + if stem.lower().endswith("-developed"): + base_stem = stem[:-10] # Remove "-developed" + return True, base_stem + return False, "" + +def _find_raw_pair( + jpg_path: Path, + jpg_stat: os.stat_result, + potential_raws: List[Tuple[Path, os.stat_result]] +) -> Path | None: + """Finds the best RAW pair for a JPG from a list of candidates.""" + if not potential_raws: + return None + + # Find the RAW file with the closest modification time within a 2-second window + best_match: Path | None = None + min_dt = 2.0 # seconds + + for raw_path, raw_stat in potential_raws: + dt = abs(jpg_stat.st_mtime - raw_stat.st_mtime) + if dt <= min_dt: + min_dt = dt + best_match = raw_path + + return best_match diff --git a/faststack/io/sidecar.py b/faststack/io/sidecar.py index 1a5becb..653ed5e 100644 --- a/faststack/io/sidecar.py +++ b/faststack/io/sidecar.py @@ -1,119 +1,119 @@ -"""Manages reading and writing the faststack.json sidecar file.""" - -import json -import logging -import time -from pathlib import Path -from typing import Optional - -from faststack.models import Sidecar, EntryMetadata - -log = logging.getLogger(__name__) - -def _entrymetadata_from_json(meta: dict) -> EntryMetadata: - """ - Helper to create EntryMetadata from JSON dict, handling legacy fields - and filtering unknown keys. - """ - try: - # Handle legacy keys - # Legacy 'flag' and 'reject' do not map to current EntryMetadata fields, - # so they will be filtered out by valid_keys check below. - - # stack_id IS in the current model, so we keep it (don't delete it). - - # Filter out unknown keys - import dataclasses - valid_keys = {f.name for f in dataclasses.fields(EntryMetadata)} - filtered_meta = {k: v for k, v in meta.items() if k in valid_keys} - - return EntryMetadata(**filtered_meta) - except Exception as e: - log.warning(f"Error parsing metadata entry: {e}") - return EntryMetadata() - -class SidecarManager: - def __init__(self, directory: Path, watcher, debug: bool = False): - self.path = directory / "faststack.json" - self.watcher = watcher - self.debug = debug - self.data = self.load() - - def stop_watcher(self): - if self.watcher: - self.watcher.stop() - - def start_watcher(self): - if self.watcher: - self.watcher.start() - - def load(self) -> Sidecar: - """Loads sidecar data from disk if it exists, otherwise returns a new object.""" - if not self.path.exists(): - log.info(f"No sidecar file found at {self.path}. Creating new one.") - return Sidecar() - try: - t_start = time.perf_counter() - with self.path.open("r") as f: - data = json.load(f) - json_load_time = time.perf_counter() - t_start - - if self.debug: - log.info(f"SidecarManager.load: loading sidecar took {json_load_time:.3f}s") - if data.get("version") != 2: - log.warning("Old sidecar format detected. Starting fresh.") - return Sidecar() - - # Reconstruct nested objects - entries = { - stem: _entrymetadata_from_json(meta) - for stem, meta in data.get("entries", {}).items() - } - return Sidecar( - version=data.get("version", 2), - last_index=data.get("last_index", 0), - entries=entries, - stacks=data.get("stacks", []), - ) - except (json.JSONDecodeError, TypeError) as e: - log.error(f"Failed to load or parse sidecar file {self.path}: {e}") - # Consider backing up the corrupted file here - return Sidecar() - - def save(self): - """Saves the sidecar data to disk atomically.""" - temp_path = self.path.with_suffix(".tmp") - was_watcher_running = False - try: - if self.watcher and hasattr(self.watcher, 'is_alive') and self.watcher.is_alive(): - self.stop_watcher() - was_watcher_running = True - with temp_path.open("w") as f: - # Convert to a dict that json.dump can handle - serializable_data = { - "version": self.data.version, - "last_index": self.data.last_index, - "entries": { - stem: meta.__dict__ - for stem, meta in self.data.entries.items() - }, - "stacks": self.data.stacks, - } - json.dump(serializable_data, f, indent=2) - - # Atomic rename - temp_path.replace(self.path) - log.debug(f"Saved sidecar file to {self.path}") - - except (IOError, TypeError) as e: - log.error(f"Failed to save sidecar file {self.path}: {e}") - finally: - if was_watcher_running: - self.start_watcher() - - def get_metadata(self, image_stem: str) -> EntryMetadata: - """Gets metadata for an image, creating it if it doesn't exist.""" - return self.data.entries.setdefault(image_stem, EntryMetadata()) - - def set_last_index(self, index: int): - self.data.last_index = index +"""Manages reading and writing the faststack.json sidecar file.""" + +import json +import logging +import time +from pathlib import Path +from typing import Optional + +from faststack.models import Sidecar, EntryMetadata + +log = logging.getLogger(__name__) + +def _entrymetadata_from_json(meta: dict) -> EntryMetadata: + """ + Helper to create EntryMetadata from JSON dict, handling legacy fields + and filtering unknown keys. + """ + try: + # Handle legacy keys + # Legacy 'flag' and 'reject' do not map to current EntryMetadata fields, + # so they will be filtered out by valid_keys check below. + + # stack_id IS in the current model, so we keep it (don't delete it). + + # Filter out unknown keys + import dataclasses + valid_keys = {f.name for f in dataclasses.fields(EntryMetadata)} + filtered_meta = {k: v for k, v in meta.items() if k in valid_keys} + + return EntryMetadata(**filtered_meta) + except Exception as e: + log.warning(f"Error parsing metadata entry: {e}") + return EntryMetadata() + +class SidecarManager: + def __init__(self, directory: Path, watcher, debug: bool = False): + self.path = directory / "faststack.json" + self.watcher = watcher + self.debug = debug + self.data = self.load() + + def stop_watcher(self): + if self.watcher: + self.watcher.stop() + + def start_watcher(self): + if self.watcher: + self.watcher.start() + + def load(self) -> Sidecar: + """Loads sidecar data from disk if it exists, otherwise returns a new object.""" + if not self.path.exists(): + log.info(f"No sidecar file found at {self.path}. Creating new one.") + return Sidecar() + try: + t_start = time.perf_counter() + with self.path.open("r") as f: + data = json.load(f) + json_load_time = time.perf_counter() - t_start + + if self.debug: + log.info(f"SidecarManager.load: loading sidecar took {json_load_time:.3f}s") + if data.get("version") != 2: + log.warning("Old sidecar format detected. Starting fresh.") + return Sidecar() + + # Reconstruct nested objects + entries = { + stem: _entrymetadata_from_json(meta) + for stem, meta in data.get("entries", {}).items() + } + return Sidecar( + version=data.get("version", 2), + last_index=data.get("last_index", 0), + entries=entries, + stacks=data.get("stacks", []), + ) + except (json.JSONDecodeError, TypeError) as e: + log.error(f"Failed to load or parse sidecar file {self.path}: {e}") + # Consider backing up the corrupted file here + return Sidecar() + + def save(self): + """Saves the sidecar data to disk atomically.""" + temp_path = self.path.with_suffix(".tmp") + was_watcher_running = False + try: + if self.watcher and hasattr(self.watcher, 'is_alive') and self.watcher.is_alive(): + self.stop_watcher() + was_watcher_running = True + with temp_path.open("w") as f: + # Convert to a dict that json.dump can handle + serializable_data = { + "version": self.data.version, + "last_index": self.data.last_index, + "entries": { + stem: meta.__dict__ + for stem, meta in self.data.entries.items() + }, + "stacks": self.data.stacks, + } + json.dump(serializable_data, f, indent=2) + + # Atomic rename + temp_path.replace(self.path) + log.debug(f"Saved sidecar file to {self.path}") + + except (IOError, TypeError) as e: + log.error(f"Failed to save sidecar file {self.path}: {e}") + finally: + if was_watcher_running: + self.start_watcher() + + def get_metadata(self, image_stem: str) -> EntryMetadata: + """Gets metadata for an image, creating it if it doesn't exist.""" + return self.data.entries.setdefault(image_stem, EntryMetadata()) + + def set_last_index(self, index: int): + self.data.last_index = index diff --git a/faststack/io/watcher.py b/faststack/io/watcher.py index 034a54c..938c169 100644 --- a/faststack/io/watcher.py +++ b/faststack/io/watcher.py @@ -1,74 +1,74 @@ -"""Filesystem watcher to detect changes in the image directory.""" - -import logging -from pathlib import Path -from typing import Optional - -from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer - -log = logging.getLogger(__name__) - -class ImageDirectoryEventHandler(FileSystemEventHandler): - """Handles filesystem events for the image directory.""" - def __init__(self, callback): - super().__init__() - self.callback = callback - - def on_created(self, event): - if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): - return - log.info(f"Detected file creation: {event}. Triggering refresh.") - self.callback() - - def on_deleted(self, event): - if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): - return - log.info(f"Detected file deletion: {event}. Triggering refresh.") - self.callback() - - def on_moved(self, event): - if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): - return - log.info(f"Detected file move: {event}. Triggering refresh.") - self.callback() - - def on_modified(self, event): - # This is a no-op to prevent spurious refreshes from file modifications - # that don't change the content (e.g., antivirus scans). - pass - -class Watcher: - """Manages the filesystem observer.""" - def __init__(self, directory: Path, callback): - self.observer: Optional[Observer] = None # Initialize to None - self.event_handler = ImageDirectoryEventHandler(callback) - self.directory = directory - self.callback = callback # Store callback for new observer - - def start(self): - """Starts watching the directory.""" - if not self.directory.is_dir(): - log.warning(f"Cannot watch non-existent directory: {self.directory}") - return - - if self.observer and self.observer.is_alive(): - return # Already running - - # Create a new observer instance every time, as it cannot be restarted - self.observer = Observer() - self.observer.schedule(self.event_handler, str(self.directory), recursive=False) - self.observer.start() - log.info(f"Started watching directory: {self.directory}") - - def stop(self): - """Stops watching the directory.""" - if self.observer and self.observer.is_alive(): - self.observer.stop() - self.observer.join() - log.info("Stopped watching directory.") - self.observer = None # Clear instance after stopping - - def is_alive(self) -> bool: - """Checks if the watcher thread is alive.""" - return self.observer and self.observer.is_alive() +"""Filesystem watcher to detect changes in the image directory.""" + +import logging +from pathlib import Path +from typing import Optional + +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +log = logging.getLogger(__name__) + +class ImageDirectoryEventHandler(FileSystemEventHandler): + """Handles filesystem events for the image directory.""" + def __init__(self, callback): + super().__init__() + self.callback = callback + + def on_created(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file creation: {event}. Triggering refresh.") + self.callback() + + def on_deleted(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file deletion: {event}. Triggering refresh.") + self.callback() + + def on_moved(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file move: {event}. Triggering refresh.") + self.callback() + + def on_modified(self, event): + # This is a no-op to prevent spurious refreshes from file modifications + # that don't change the content (e.g., antivirus scans). + pass + +class Watcher: + """Manages the filesystem observer.""" + def __init__(self, directory: Path, callback): + self.observer: Optional[Observer] = None # Initialize to None + self.event_handler = ImageDirectoryEventHandler(callback) + self.directory = directory + self.callback = callback # Store callback for new observer + + def start(self): + """Starts watching the directory.""" + if not self.directory.is_dir(): + log.warning(f"Cannot watch non-existent directory: {self.directory}") + return + + if self.observer and self.observer.is_alive(): + return # Already running + + # Create a new observer instance every time, as it cannot be restarted + self.observer = Observer() + self.observer.schedule(self.event_handler, str(self.directory), recursive=False) + self.observer.start() + log.info(f"Started watching directory: {self.directory}") + + def stop(self): + """Stops watching the directory.""" + if self.observer and self.observer.is_alive(): + self.observer.stop() + self.observer.join() + log.info("Stopped watching directory.") + self.observer = None # Clear instance after stopping + + def is_alive(self) -> bool: + """Checks if the watcher thread is alive.""" + return self.observer and self.observer.is_alive() diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index 58cad4c..b16cb4f 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -1,53 +1,53 @@ -"""Configures application-wide logging.""" - -import logging -import logging.handlers -import os -from pathlib import Path - -def get_app_data_dir() -> Path: - """Returns the application data directory.""" - app_data = os.getenv("APPDATA") - if app_data: - return Path(app_data) / "faststack" - return Path.home() / ".faststack" - -def setup_logging(debug: bool = False): - """Sets up logging to a rotating file in the app data directory. - - Args: - debug: If True, sets log level to DEBUG. Otherwise, sets to WARNING to reduce noise. - """ - log_dir = get_app_data_dir() / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - log_file = log_dir / "app.log" - - # File handler - file_handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=10*1024*1024, backupCount=5 - ) - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - file_handler.setFormatter(formatter) - - # Console handler (for seeing logs in terminal) - console_handler = logging.StreamHandler() - console_handler.setFormatter(formatter) - - root_logger = logging.getLogger() - # Set log level based on debug flag - root_logger.setLevel(logging.DEBUG if debug else logging.WARNING) - root_logger.handlers.clear() - root_logger.addHandler(file_handler) - root_logger.addHandler(console_handler) - - # Configure logging for key modules - if debug: - logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG) - logging.getLogger("faststack.imaging.prefetch").setLevel(logging.DEBUG) - else: - # In non-debug mode, only log errors from these noisy modules - logging.getLogger("faststack.imaging.cache").setLevel(logging.ERROR) - logging.getLogger("faststack.imaging.prefetch").setLevel(logging.ERROR) - logging.getLogger("PIL").setLevel(logging.INFO) +"""Configures application-wide logging.""" + +import logging +import logging.handlers +import os +from pathlib import Path + +def get_app_data_dir() -> Path: + """Returns the application data directory.""" + app_data = os.getenv("APPDATA") + if app_data: + return Path(app_data) / "faststack" + return Path.home() / ".faststack" + +def setup_logging(debug: bool = False): + """Sets up logging to a rotating file in the app data directory. + + Args: + debug: If True, sets log level to DEBUG. Otherwise, sets to WARNING to reduce noise. + """ + log_dir = get_app_data_dir() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "app.log" + + # File handler + file_handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=10*1024*1024, backupCount=5 + ) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + file_handler.setFormatter(formatter) + + # Console handler (for seeing logs in terminal) + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + + root_logger = logging.getLogger() + # Set log level based on debug flag + root_logger.setLevel(logging.DEBUG if debug else logging.WARNING) + root_logger.handlers.clear() + root_logger.addHandler(file_handler) + root_logger.addHandler(console_handler) + + # Configure logging for key modules + if debug: + logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG) + logging.getLogger("faststack.imaging.prefetch").setLevel(logging.DEBUG) + else: + # In non-debug mode, only log errors from these noisy modules + logging.getLogger("faststack.imaging.cache").setLevel(logging.ERROR) + logging.getLogger("faststack.imaging.prefetch").setLevel(logging.ERROR) + logging.getLogger("PIL").setLevel(logging.INFO) diff --git a/faststack/models.py b/faststack/models.py index 3452fd3..2562450 100644 --- a/faststack/models.py +++ b/faststack/models.py @@ -1,82 +1,82 @@ -"""Core data types and enumerations for FastStack.""" - -import dataclasses -from pathlib import Path -from typing import Optional, Dict, List - -@dataclasses.dataclass -class ImageFile: - """Represents a single image file on disk.""" - path: Path - raw_pair: Optional[Path] = None - timestamp: float = 0.0 - - @property - def raw_path(self) -> Optional[Path]: - """Returns the path to the RAW file if it exists, otherwise None.""" - if self.raw_pair: - return self.raw_pair - # If the main path itself is a RAW file (orphaned RAW case) - # We need a way to check if 'path' is a raw extension. - # Ideally we check against known extensions, but for now let's assume - # if raw_pair is None but we are treating it as RAW, we might need logic here. - # However, the indexer will set raw_pair = path for orphaned RAWs likely. - return None - - @property - def has_raw(self) -> bool: - return self.raw_pair is not None - - @property - def working_tif_path(self) -> Path: - """Canonical path for the working 16-bit TIFF: stem + -working.tif""" - return self.path.parent / f"{self.path.stem}-working.tif" - - @property - def has_working_tif(self) -> bool: - try: - return self.working_tif_path.exists() and self.working_tif_path.stat().st_size > 0 - except OSError: - return False - - @property - def developed_jpg_path(self) -> Path: - """Canonical path for the developed JPG: stem + -developed.jpg""" - # If the original path is 'photo.jpg', we want 'photo-developed.jpg'. - # If 'photo.CR2', we want 'photo-developed.jpg'. - return self.path.with_name(f"{self.path.stem}-developed.jpg") - - -@dataclasses.dataclass -class EntryMetadata: - """Sidecar metadata for a single image entry.""" - stack_id: Optional[int] = None - stacked: bool = False - stacked_date: Optional[str] = None - uploaded: bool = False - uploaded_date: Optional[str] = None - edited: bool = False - edited_date: Optional[str] = None - restacked: bool = False - restacked_date: Optional[str] = None - - -@dataclasses.dataclass -class Sidecar: - """Represents the entire sidecar JSON file.""" - version: int = 2 - last_index: int = 0 - entries: Dict[str, EntryMetadata] = dataclasses.field(default_factory=dict) - stacks: List[List[int]] = dataclasses.field(default_factory=list) - -@dataclasses.dataclass -class DecodedImage: - """A decoded image buffer ready for display.""" - buffer: memoryview - width: int - height: int - bytes_per_line: int - format: object # QImage.Format - - def __sizeof__(self) -> int: - return self.buffer.nbytes +"""Core data types and enumerations for FastStack.""" + +import dataclasses +from pathlib import Path +from typing import Optional, Dict, List + +@dataclasses.dataclass +class ImageFile: + """Represents a single image file on disk.""" + path: Path + raw_pair: Optional[Path] = None + timestamp: float = 0.0 + + @property + def raw_path(self) -> Optional[Path]: + """Returns the path to the RAW file if it exists, otherwise None.""" + if self.raw_pair: + return self.raw_pair + # If the main path itself is a RAW file (orphaned RAW case) + # We need a way to check if 'path' is a raw extension. + # Ideally we check against known extensions, but for now let's assume + # if raw_pair is None but we are treating it as RAW, we might need logic here. + # However, the indexer will set raw_pair = path for orphaned RAWs likely. + return None + + @property + def has_raw(self) -> bool: + return self.raw_pair is not None + + @property + def working_tif_path(self) -> Path: + """Canonical path for the working 16-bit TIFF: stem + -working.tif""" + return self.path.parent / f"{self.path.stem}-working.tif" + + @property + def has_working_tif(self) -> bool: + try: + return self.working_tif_path.exists() and self.working_tif_path.stat().st_size > 0 + except OSError: + return False + + @property + def developed_jpg_path(self) -> Path: + """Canonical path for the developed JPG: stem + -developed.jpg""" + # If the original path is 'photo.jpg', we want 'photo-developed.jpg'. + # If 'photo.CR2', we want 'photo-developed.jpg'. + return self.path.with_name(f"{self.path.stem}-developed.jpg") + + +@dataclasses.dataclass +class EntryMetadata: + """Sidecar metadata for a single image entry.""" + stack_id: Optional[int] = None + stacked: bool = False + stacked_date: Optional[str] = None + uploaded: bool = False + uploaded_date: Optional[str] = None + edited: bool = False + edited_date: Optional[str] = None + restacked: bool = False + restacked_date: Optional[str] = None + + +@dataclasses.dataclass +class Sidecar: + """Represents the entire sidecar JSON file.""" + version: int = 2 + last_index: int = 0 + entries: Dict[str, EntryMetadata] = dataclasses.field(default_factory=dict) + stacks: List[List[int]] = dataclasses.field(default_factory=list) + +@dataclasses.dataclass +class DecodedImage: + """A decoded image buffer ready for display.""" + buffer: memoryview + width: int + height: int + bytes_per_line: int + format: object # QImage.Format + + def __sizeof__(self) -> int: + return self.buffer.nbytes diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 578242a..9031654 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -1,1335 +1,1335 @@ -import QtQuick -import QtQuick.Window - -// This file is intended to hold QML components like the main image view. -// For simplicity, we'll start with just the main image view. - -Item { - id: loupeView - anchors.fill: parent - focus: true - - // Height of the status bar footer in Main.qml - property int footerHeight: 60 - - Connections { - target: uiState - function onCurrentIndexChanged() { - // Smart High-Res Logic: - // Before the new image loads, decide if we should keep high-res mode. - // Rule: Only keep high-res if we are currently "meaningfully zoomed" (> 1.1x fit). - // This prevents "sticky" high-res where zooming in once keeps it forever. - - if (imageRotator.zoomScale > imageRotator.fitScale * 1.1) { - // Keep high-res (setZoomed true if not already) - if (!uiState.isZoomed) uiState.setZoomed(true) - } else { - // Drop to low-res for the next image - if (uiState.isZoomed) uiState.setZoomed(false) - } - } - } - - Keys.onEscapePressed: (event) => { - if (uiState && uiState.isCropping) { - if (mainMouseArea.isRotating) { - // Revert rotation - mainMouseArea.cropRotation = mainMouseArea.cropStartRotation - if (controller) controller.set_straighten_angle(mainMouseArea.cropRotation, -1) - - mainMouseArea.isRotating = false - mainMouseArea.cropDragMode = "none" - mainMouseArea.isCropDragging = false - event.accepted = true - } else if (controller) { - controller.cancel_crop_mode() - mainMouseArea.cropRotation = 0 // Reset local rotation - event.accepted = true - } - } - } - - - - - Keys.onPressed: (event) => { - // Zoom Shortcuts (Ctrl+1..4) - if (event.modifiers & Qt.ControlModifier) { - if (event.key === Qt.Key_1) { - uiState.request_absolute_zoom(1.0) - event.accepted = true - return - } else if (event.key === Qt.Key_2) { - uiState.request_absolute_zoom(2.0) - event.accepted = true - return - } else if (event.key === Qt.Key_3) { - uiState.request_absolute_zoom(3.0) - event.accepted = true - return - } else if (event.key === Qt.Key_4) { - // 400% zoom - uiState.request_absolute_zoom(4.0) - event.accepted = true - return - } - } - - // Handle Enter for Crop Execution (formerly Keys.onEnterPressed) - // We only accept the event if we actually act on it. - if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && uiState && uiState.isCropping && controller) { - // Force immediate rotation update before executing crop - if (mainMouseArea.cropRotation !== 0) { - controller.set_straighten_angle(mainMouseArea.cropRotation, -1) - } - - uiState.setZoomed(false) // Force unzoom - controller.execute_crop() - event.accepted = true - return - } - - // IMPORTANT: Allow unhandled keys to propagate to Python eventFilter logic - event.accepted = false - } - - - - // Connection to handle zoom/pan reset signal from Python - Connections { - target: uiState - function onResetZoomPanRequested() { - imageRotator.zoomScale = imageRotator.fitScale - panTransform.x = 0 - panTransform.y = 0 - } - function onAbsoluteZoomRequested(scale) { - if (uiState && uiState.debugMode) { - console.log("QML: Absolute zoom requested: " + scale) - } - - imageRotator.zoomScale = scale - - // If we need to switch to high-res, flag this scale as the target - // for the incoming source change so recomputeFitScale doesn't clobber it. - if (uiState && !uiState.isZoomed) { - imageRotator.targetAbsoluteZoom = scale - uiState.setZoomed(true) - } - } - } - - // Container that handles Viewport Clipping and Sizing - Item { - id: imageViewport - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.bottomMargin: footerHeight - clip: true - - // Container that handles Rotation (Straightening) - // This item represents the "Canvas" that expands when rotated. - Item { - id: imageRotator - anchors.centerIn: parent - - // Size matches the AABB of the rotated image - // W' = W*|cos| + H*|sin| - // Geometry is now updated atomically via updateRotatorGeometry() - property real implicitWidth: 0 - property real implicitHeight: 0 - property bool isUpdatingGeometry: false - - // Fix A: Atomic Zoom Scale - property real zoomScale: 1.0 - - // Fix C: Persist requested absolute zoom across source changes - property real targetAbsoluteZoom: -1.0 - - onZoomScaleChanged: { - mainImage.updateZoomState() - if (cropOverlay.visible) cropOverlay.updateCropRect() - } - - // Fix B: Stable Logical Size - property real baseW: 0 - property real baseH: 0 - - function updateRotatorGeometry() { - if (!mainImage || mainImage.sourceSize.width <= 0) return - - isUpdatingGeometry = true - - var rad = mainMouseArea.cropRotation * (Math.PI / 180.0) - - // Use base size if available (stable during zoom), otherwise sourceSize - var w = (baseW > 0) ? baseW : mainImage.sourceSize.width - var h = (baseH > 0) ? baseH : mainImage.sourceSize.height - - var newW = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad)) - var newH = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad)) - - width = newW - height = newH - - // Atomically update mainImage size to prevent aspect ratio distortion - mainImage.width = w - mainImage.height = h - - isUpdatingGeometry = false - recomputeFitScale() - } - - Connections { - target: mainMouseArea - function onCropRotationChanged() { imageRotator.updateRotatorGeometry() } - } - // Trigger initial update (moved to end) - - // NEW: fit-to-window scale (minimum zoom) - property real fitScale: 1.0 - - function recomputeFitScale(force) { - if (force === undefined) force = false; - - if (width <= 0 || height <= 0 || imageViewport.width <= 0 || imageViewport.height <= 0) - return; - - // Prevent jitter: Don't recompute fit scale while dragging (resize, move, or rotate) - // Unless forced (e.g. on release) - if (!force && mainMouseArea.isCropDragging) return; - - // Capture current relative zoom to preserve it during resize/reload - var oldFit = fitScale - var currentScale = imageRotator.zoomScale - var ratio = 1.0 - if (oldFit > 0) { - ratio = currentScale / oldFit - } - - // fit rotated canvas into viewport - var s = Math.min(imageViewport.width / width, imageViewport.height / height); - // Ensure fitScale is finite and positive - // Allow upscaling to fit window (necessary for HiDPI logical sizing) - if (!isFinite(s) || s <= 0) s = 1.0; - // else if (s > 1.0) s = 1.0; // REMOVED: Cap prevented fitting small/logical images - - fitScale = s; - - // Restore zoom level - if (targetAbsoluteZoom > 0) { - // Check if we have a pending absolute zoom request (e.g. from Ctrl+1) - // If so, use it directly (1.0 = 1:1 pixels) and consume the flag. - imageRotator.zoomScale = targetAbsoluteZoom; - targetAbsoluteZoom = -1.0; - } else { - // Otherwise, preserve relative visual size (fit ratio) - imageRotator.zoomScale = fitScale * ratio; - } - // Preserve Pan (don't reset to 0) as pan is in screen pixels (mostly) - } - - onWidthChanged: if (!isUpdatingGeometry) recomputeFitScale() - onHeightChanged: if (!isUpdatingGeometry) recomputeFitScale() - Component.onCompleted: { - updateRotatorGeometry() - recomputeFitScale() - } - - Connections { - target: imageViewport - function onWidthChanged() { imageRotator.recomputeFitScale() } - function onHeightChanged() { imageRotator.recomputeFitScale() } - } - - transform: [ - Scale { - id: scaleTransform - origin.x: imageRotator.width / 2 - origin.y: imageRotator.height / 2 - xScale: imageRotator.zoomScale - yScale: imageRotator.zoomScale - }, - Translate { - id: panTransform - onXChanged: { - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() - } - onYChanged: { - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() - } - } - ] - - // The main image display - Image { - id: mainImage - anchors.centerIn: parent - - // Image size is now updated atomically in updateRotatorGeometry to prevent distortion - // width: sourceSize.width - // height: sourceSize.height - - rotation: mainMouseArea.cropRotation - - // Crop overlay - anchored to mainImage to rotate with it - Item { - id: cropOverlay - property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] - property bool hasActiveCrop: cropBox && cropBox.length === 4 && !(cropBox[0]===0 && cropBox[1]===0 && cropBox[2]===1000 && cropBox[3]===1000) - - visible: uiState && uiState.isCropping && (hasActiveCrop || mainMouseArea.isRotating) - anchors.fill: parent // Fills mainImage - z: 100 - - onCropBoxChanged: { if (parent.source) updateCropRect() } - Component.onCompleted: { if (parent.source) updateCropRect() } - - Connections { - target: uiState - function onCurrentCropBoxChanged() { if (cropOverlay.visible && mainImage.source) cropOverlay.updateCropRect() } - } - - Connections { - target: mainImage - function onWidthChanged() { cropOverlay.updateCropRect() } - function onHeightChanged() { cropOverlay.updateCropRect() } - } - - function updateCropRect() { - if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return - var box = uiState.currentCropBox - - // Local coords in mainImage (Source Space) - var localLeft = (box[0] / 1000) * parent.width - var localTop = (box[1] / 1000) * parent.height - var localRight = (box[2] / 1000) * parent.width - var localBottom = (box[3] / 1000) * parent.height - - cropRect.x = localLeft - cropRect.y = localTop - cropRect.width = localRight - localLeft - cropRect.height = localBottom - localTop - } - - // Dimmer Rectangles - Rectangle { x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 } - Rectangle { x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 } - Rectangle { x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 } - Rectangle { x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 } - - Rectangle { - id: cropRect - color: "transparent" - border.color: "white" - border.width: 3 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - - // Rotation Handle Line - Rectangle { - id: handleLine - visible: mainMouseArea.isRotating - width: 2 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - height: 25 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - color: "white" - anchors.top: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - } - - // Rotation Knob - Rectangle { - id: rotateKnob - visible: mainMouseArea.isRotating - width: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - height: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - radius: width / 2 - color: "white" - border.color: "black" - border.width: 1 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - anchors.verticalCenter: handleLine.bottom - anchors.horizontalCenter: handleLine.horizontalCenter - } - } - } - - source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" - - function _currentDpr() { - // Per-window DPR is the safest (multi-monitor setups) - if (mainImage.window && mainImage.window.devicePixelRatio) - return mainImage.window.devicePixelRatio - return Screen.devicePixelRatio - } - - function handleSourceSizeChange() { - if (mainImage.sourceSize.width <= 0 || mainImage.sourceSize.height <= 0) return - - const dpr = _currentDpr() - - // Treat baseW/baseH as *device-independent pixels* that correspond to 1:1 physical pixels at zoomScale=1 - imageRotator.baseW = mainImage.sourceSize.width / dpr - imageRotator.baseH = mainImage.sourceSize.height / dpr - - // Rebuild rotator + mainImage geometry based on the NEW resolution - imageRotator.updateRotatorGeometry() - - // Force fit recompute so fitScale / zoom logic stabilizes immediately - imageRotator.recomputeFitScale(true) - - if (uiState && uiState.debugMode) { - console.log("sourceSize changed:", mainImage.sourceSize.width, mainImage.sourceSize.height, - "dpr:", dpr, - "base:", imageRotator.baseW, imageRotator.baseH, - "zoomScale:", imageRotator.zoomScale) - } - } - - onSourceSizeChanged: { handleSourceSizeChange() } - - onStatusChanged: { - if (status === Image.Ready) { - // Some backends update sourceSize right as status flips - mainImage.handleSourceSizeChange() - imageRotator.updateRotatorGeometry() - } - } - - // Force reset when source changes (existing logic) - onSourceChanged: { - // Reset base size for new image so we pick up the new sourceSize - imageRotator.baseW = 0 - imageRotator.baseH = 0 - - // Smart Zoom Reset: - // If we intended to keep high-res (isZoomed is true), preserve capabilities. - // If not (isZoomed is false), reset to "fit" state for speed and consistency. - if (uiState && !uiState.isZoomed) { - mainMouseArea.cropRotation = 0 - mainMouseArea.isRotating = false - mainMouseArea.cropDragMode = "none" - - imageRotator.zoomScale = imageRotator.fitScale - panTransform.x = 0 - panTransform.y = 0 - } - } - fillMode: Image.PreserveAspectFit - cache: false // We do our own caching in Python - smooth: false // Crisp rendering for technical accuracy - mipmap: false // Crisp rendering - - property bool isZooming: false - - // IMPORTANT: tell Python the *viewport* size, not the sourceSize size - function reportDisplaySize() { - if (imageViewport.width > 0 && imageViewport.height > 0) { - var dpr = Screen.devicePixelRatio - uiState.onDisplaySizeChanged( - Math.round(imageViewport.width * dpr), - Math.round(imageViewport.height * dpr) - ) - } - } - - Component.onCompleted: reportDisplaySize() - Connections { - target: imageViewport - function onWidthChanged() { mainImage.reportDisplaySize() } - function onHeightChanged() { mainImage.reportDisplaySize() } - } - - // Removed direct onWidth/HeightChanged handlers for resizeDebounceTimer - // because we now drive size reporting via viewport changes. - - Timer { - id: lowResDebounceTimer - interval: 200 // 200ms debounce to prevent thrashing - repeat: false - onTriggered: { - if (uiState && uiState.isZoomed) { - uiState.setZoomed(false) - } - } - } - - function updateZoomState() { - if (!uiState) return; - - // Thresholds for hysteresis - var highResThreshold = imageRotator.fitScale * 1.1 - var lowResThreshold = imageRotator.fitScale * 1.02 - - // Enable High-Res if zoomed in significantly - if (imageRotator.zoomScale > highResThreshold) { - lowResDebounceTimer.stop() - if (!uiState.isZoomed) { - uiState.setZoomed(true); - } - } - // Disable High-Res (return to low-res) if zoomed out to near-fit - // formatting note: added hysteresis check AND debounce - else if (imageRotator.zoomScale <= lowResThreshold) { - if (uiState.isZoomed) { - // Only drop to low-res after delay to handle wheel overshoot/jitter - if (!lowResDebounceTimer.running) lowResDebounceTimer.start() - } - } else { - // In hysteresis band: cancel any pending low-res switch - lowResDebounceTimer.stop() - } - - updateHistogramWithZoom() - } - - function updateHistogramWithZoom() { - if (uiState && uiState.isHistogramVisible && controller) { - var zoom = imageRotator.zoomScale - var panX = panTransform.x - var panY = panTransform.y - var imageScale = imageRotator.zoomScale - controller.update_histogram(zoom, panX, panY, imageScale) - } - } - - - } - - - - - } - } - - // Zoom and Pan logic would go here - // For example, using PinchArea or MouseArea - - - MouseArea { - id: mainMouseArea - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton - hoverEnabled: true - cursorShape: { - if (!uiState || !uiState.isCropping) return Qt.ArrowCursor - // Use a simple cross cursor for crop mode - edge detection would require tracking mouse position - // which is complex in QML. The edge dragging will still work based on click position. - return Qt.CrossCursor - } - - // 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 - property bool isCropDragging: false - property real cropStartX: 0 - property real cropStartY: 0 - - property string cropDragMode: "none" // "none", "new", "move", "left", "right", "top", "bottom", "topleft", "topright", "bottomleft", "bottomright" - property real cropBoxStartLeft: 0 - property real cropBoxStartTop: 0 - property real cropBoxStartRight: 0 - property real cropBoxStartBottom: 0 - property real cropRotation: 0 - property bool isRotating: false - property real cropStartAngle: 0 - property real cropStartRotation: 0 - property real cropStartAspect: -1 - - // Reset rotation when image changes or updates (e.g. after crop save) to avoid persistence - Connections { - target: uiState - function onCurrentIndexChanged() { - mainMouseArea.cropRotation = 0 - } - } - - - onIsRotatingChanged: { - if (uiState) { - if (isRotating) { - uiState.statusMessage = "Press ESC to exit rotate mode" - } else { - if (uiState.statusMessage === "Press ESC to exit rotate mode") { - uiState.statusMessage = "" - } - } - } - } - - property real pendingRotation: 0 - property real pendingAspect: -1 - - Timer { - id: rotationThrottleTimer - interval: 32 // ~30 fps - repeat: false - onTriggered: { - if (controller && uiState && uiState.isCropping) { - controller.set_straighten_angle(mainMouseArea.pendingRotation, mainMouseArea.pendingAspect) - } - } - } - - onPressed: function(mouse) { - lastX = mouse.x - lastY = mouse.y - startX = mouse.x - startY = mouse.y - isDraggingOutside = false - - if (mouse.button === Qt.RightButton) { - if (uiState && uiState.isCropping) { - // Cancel crop mode if already active - if (controller) controller.cancel_crop_mode() - } else if (uiState) { - // Enter crop mode and start new crop - uiState.isCropping = true - - // Set up new crop state - cropDragMode = "new" - cropStartX = mouse.x - cropStartY = mouse.y - - // Initialize anchors - var startCoords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) - // Clamp to [0, 1] and convert to [0, 1000] - var startNormX = Math.max(0, Math.min(1, startCoords.x)) * 1000 - var startNormY = Math.max(0, Math.min(1, startCoords.y)) * 1000 - - cropBoxStartLeft = startNormX - cropBoxStartRight = startNormX - cropBoxStartTop = startNormY - cropBoxStartBottom = startNormY - - isCropDragging = true - } - return - } - - if (uiState && uiState.isCropping) { - // Check if clicking on existing crop box - Using Image Space Hit Testing - var box = uiState.currentCropBox - if (box && box.length === 4) box = box.slice(0) - - var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 - - var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) - var mx = coords.x * 1000 - var my = coords.y * 1000 - - // Calculate threshold in normalized units (approx 10 screen pixels) - var threshX = (10 / (scaleTransform.xScale * mainImage.width)) * 1000 - var threshY = (10 / (scaleTransform.yScale * mainImage.height)) * 1000 - - // Clamp threshold: min 5 normalized units (prevent too small), max 40 (prevent too large) - // This ensures handles remain usable at all zoom levels - var edgeThreshold = Math.max(5, Math.min(40, Math.max(threshX, threshY))) - - var inside = mx >= box[0] && mx <= box[2] && my >= box[1] && my <= box[3] - - // --- Hit test for rotation handle (robust: uses actual knob transform) --- - if (mainMouseArea.isRotating && cropOverlay.visible && rotateKnob.visible) { - // knob center in mainMouseArea coords (includes cropRect rotation) - // Note: rotateKnob is now inside mainImage -> cropOverlay -> cropRect - // But mapFromItem should still work if we target the object properly. - // We need to resolve `rotateKnob` which is inside cropOverlay. - // If cropOverlay moves, we need to ensure this binding works. - // IMPORTANT: cropOverlay is not moved yet in this call. - // Current logic relies on existing structure. I will defer logic update if structure changes. - // But hit testing via mapFromItem(rotateKnob) is robust to hierarchy changes as long as rotateKnob exists. - - var k = mainMouseArea.mapFromItem(rotateKnob, rotateKnob.width/2, rotateKnob.height/2) - var dxk = mouse.x - k.x - var dyk = mouse.y - k.y - var distk = Math.sqrt(dxk*dxk + dyk*dyk) - - if (distk < 22 * Screen.devicePixelRatio) { // a little forgiving - cropDragMode = "rotate" - - // crop center in mainMouseArea coords -> Changed to IMAGE center to avoid feedback loop - var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) - cropStartAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI - cropStartRotation = cropRotation - - // Calculate start aspect ratio (in pixels) - if (mainImage.width > 0) { - if (box && box.length === 4) { - var boxW = (box[2] - box[0]) / 1000 * mainImage.width - var boxH = (box[3] - box[1]) / 1000 * mainImage.height - if (boxH > 0) cropStartAspect = boxW / boxH - } - } - - - // Seed cropBoxStart variables - if (box && box.length === 4) { - cropBoxStartLeft = box[0] - cropBoxStartTop = box[1] - cropBoxStartRight = box[2] - cropBoxStartBottom = box[3] - } - - isCropDragging = true - return - } - } - - // If crop box is full image, always start a new crop - else if (isFullImage) { - cropDragMode = "new" - cropStartX = mouse.x - cropStartY = mouse.y - } else if (inside) { - // Determine which edge/corner is being dragged (Image Space) - var nearLeft = Math.abs(mx - box[0]) < edgeThreshold - var nearRight = Math.abs(mx - box[2]) < edgeThreshold - var nearTop = Math.abs(my - box[1]) < edgeThreshold - var nearBottom = Math.abs(my - box[3]) < edgeThreshold - - if (nearLeft && nearTop) cropDragMode = "topleft" - else if (nearRight && nearTop) cropDragMode = "topright" - else if (nearLeft && nearBottom) cropDragMode = "bottomleft" - else if (nearRight && nearBottom) cropDragMode = "bottomright" - else if (nearLeft) cropDragMode = "left" - else if (nearRight) cropDragMode = "right" - else if (nearTop) cropDragMode = "top" - else if (nearBottom) cropDragMode = "bottom" - else cropDragMode = "move" - - // Store initial crop box - cropBoxStartLeft = box[0] - cropBoxStartTop = box[1] - cropBoxStartRight = box[2] - cropBoxStartBottom = box[3] - } else { - // Start new crop rectangle - cropDragMode = "new" - cropStartX = mouse.x - cropStartY = mouse.y - - // Initialize anchors - cropBoxStartLeft = mx - cropBoxStartRight = mx - cropBoxStartTop = my - cropBoxStartBottom = my - } - isCropDragging = true - } - } - // Legacy getCropRect removed - using Image Space hit testing instead. - // mapToImageCoordinates maps directly to mainImage - function mapToImageCoordinates(screenPoint) { - var p = mainMouseArea.mapToItem(mainImage, screenPoint.x, screenPoint.y) - return {x: p.x / mainImage.width, y: p.y / mainImage.height} - } - onPositionChanged: function(mouse) { - if (uiState && uiState.isCropping && isCropDragging) { - if (cropDragMode === "new") { - // Update crop rectangle while dragging - updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y, true) - } else if (cropDragMode === "rotate") { - var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) - var currentAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI - var delta = currentAngle - cropStartAngle - // Handle wrap-around - if (delta > 180) delta -= 360 - if (delta < -180) delta += 360 - - var newRotation = cropStartRotation + delta - - // Update rotation state - cropRotation = newRotation - - // Update rotation in backend live (throttled) - if (controller) { - pendingRotation = cropRotation - pendingAspect = -1 - - if (!rotationThrottleTimer.running) { - rotationThrottleTimer.start() - } - } - // Return early to prevent overwriting crop box during rotation - return - } else { - // Handle move/resize (edge dragging) - var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) - - // Clamp to image bounds and convert to 0-1000 range - var mouseX = Math.max(0, Math.min(1, coords.x)) * 1000 - var mouseY = Math.max(0, Math.min(1, coords.y)) * 1000 - - var left = cropBoxStartLeft - var top = cropBoxStartTop - var right = cropBoxStartRight - var bottom = cropBoxStartBottom - - // Adjust based on drag mode - if (cropDragMode === "move") { - var startCenterX = (cropBoxStartLeft + cropBoxStartRight) / 2 - var startCenterY = (cropBoxStartTop + cropBoxStartBottom) / 2 - - var dx = mouseX - startCenterX - var dy = mouseY - startCenterY - - var width = cropBoxStartRight - cropBoxStartLeft - var height = cropBoxStartBottom - cropBoxStartTop - - left = Math.max(0, Math.min(1000 - width, cropBoxStartLeft + dx)) - top = Math.max(0, Math.min(1000 - height, cropBoxStartTop + dy)) - right = left + width - bottom = top + height - } else { - if (cropDragMode.includes("left")) left = mouseX; - if (cropDragMode.includes("right")) right = mouseX; - if (cropDragMode.includes("top")) top = mouseY; - if (cropDragMode.includes("bottom")) bottom = mouseY; - - var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, cropDragMode) - left = constrainedBox[0] - top = constrainedBox[1] - right = constrainedBox[2] - bottom = constrainedBox[3] - } - - uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] - } - return - } - - 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 - if (controller) controller.start_drag_current_image() - return - } - } - - // Normal pan behavior (only when not cropping) - if (!uiState || !uiState.isCropping) { - panTransform.x += (mouse.x - lastX) - panTransform.y += (mouse.y - lastY) - lastX = mouse.x - lastY = mouse.y - } - } - } - - onReleased: function(mouse) { - isDraggingOutside = false - if (uiState && uiState.isCropping && isCropDragging) { - // Fix: Prevent accidental tiny crops with Right Click - if (mouse.button === Qt.RightButton && cropDragMode === "new") { - var dx = Math.abs(mouse.x - cropStartX) - var dy = Math.abs(mouse.y - cropStartY) - var maxDim = Math.max(dx, dy) - var minDim = Math.min(dx, dy) - - // "at least 50 pixels in both dimensions" - if (maxDim < 50 || minDim < 50) { - if (controller) controller.cancel_crop_mode() - isCropDragging = false - cropDragMode = "none" - return - } - } - - isCropDragging = false - cropDragMode = "none" - // Settle zoom/pan after rotation ends (Force recompute) - if (mainMouseArea.isRotating) imageRotator.recomputeFitScale(true) - } - } - - // Wheel for zoom - zooms in towards cursor, zooms out towards center - onWheel: function(wheel) { - // Disable smooth rendering during zoom for better performance - mainImage.isZooming = true - - // Use a smaller scale factor for smoother, more responsive zoom - var isZoomingIn = wheel.angleDelta.y > 0 - var scaleFactor = isZoomingIn ? 1.1 : 1 / 1.1; - - // Calculate old and new scale - var oldScale = imageRotator.zoomScale - var newScale = oldScale * scaleFactor - // Allow zooming out past "Fit" to 5%. Cap max at 20x. - newScale = Math.max(0.05, Math.min(20.0, newScale)) - - // Current state - var currentPanX = panTransform.x - var currentPanY = panTransform.y - - // Screen center (Viewport center) - var centerX = imageViewport.width / 2 - var centerY = imageViewport.height / 2 - - // Fix C: Use Viewport Coordinates (account for footer offset etc) - var p = mainMouseArea.mapToItem(imageViewport, wheel.x, wheel.y) - var mouseX = p.x - var mouseY = p.y - - var mouseOffsetFromCenterX = mouseX - centerX - var mouseOffsetFromCenterY = mouseY - centerY - - // Calculate the "image point" currently under the cursor (relative to image center, unscaled) - // ScreenPos = Center + Pan + (ImagePoint * Scale) - // ImagePoint = (ScreenPos - Center - Pan) / Scale - // ImagePoint = (MouseOffsetFromCenter - Pan) / Scale - var imagePointX = (mouseOffsetFromCenterX - currentPanX) / oldScale - var imagePointY = (mouseOffsetFromCenterY - currentPanY) / oldScale - - // We want to keep this ImagePoint under the cursor after scaling: - // MouseOffsetFromCenter = Pan_New + (ImagePoint * Scale_New) - // Pan_New = MouseOffsetFromCenter - (ImagePoint * Scale_New) - - var newPanX = mouseOffsetFromCenterX - (imagePointX * newScale) - var newPanY = mouseOffsetFromCenterY - (imagePointY * newScale) - - // Apply updates - imageRotator.zoomScale = newScale - panTransform.x = newPanX - panTransform.y = newPanY - - // Re-enable smooth rendering after a short delay - zoomSmoothTimer.restart() - } - - Timer { - id: zoomSmoothTimer - interval: 150 // Re-enable smooth rendering 150ms after last zoom - onTriggered: { - mainImage.isZooming = false - } - } - - function updateCropBox(x1, y1, x2, y2, applyAspectRatio = false) { - if (!uiState || !mainImage.source) return - - var imgCoord1 = mapToImageCoordinates(Qt.point(x1, y1)) - var imgCoord2 = mapToImageCoordinates(Qt.point(x2, y2)) - - // Clamp to image bounds (normalized 0-1) - var imgCoordX1 = Math.max(0, Math.min(1, imgCoord1.x)) - var imgCoordY1 = Math.max(0, Math.min(1, imgCoord1.y)) - var imgCoordX2 = Math.max(0, Math.min(1, imgCoord2.x)) - var imgCoordY2 = Math.max(0, Math.min(1, imgCoord2.y)) - - // Calculate raw box in 0-1000 space - var left = Math.min(imgCoordX1, imgCoordX2) * 1000 - var right = Math.max(imgCoordX1, imgCoordX2) * 1000 - var top = Math.min(imgCoordY1, imgCoordY2) * 1000 - var bottom = Math.max(imgCoordY1, imgCoordY2) * 1000 - - // Determine primary drag direction for "new" mode (from anchor x1,y1 to mouse x2,y2) - // We need to know which corner is the anchor to apply aspect ratio correctly - // x1,y1 is anchor. x2,y2 is mouse. - - if (applyAspectRatio && mainImage.sourceSize) { - // We need to pass the specific corner being dragged to applyAspectRatioConstraint - // Since "new" creates a box from x1,y1 to x2,y2, we can infer the mode. - var mode = "new" - if (x2 >= x1 && y2 >= y1) mode = "bottomright" - else if (x2 < x1 && y2 >= y1) mode = "bottomleft" - else if (x2 >= x1 && y2 < y1) mode = "topright" - else if (x2 < x1 && y2 < y1) mode = "topleft" - - // Pass the raw coordinates of the "mouse" corner (x2, y2) and the "anchor" corner (x1, y1) - // But applyAspectRatioConstraint expects left, top, right, bottom. - // It assumes one corner is fixed based on mode. - // So we pass the current box, and it will adjust the moving corner. - - var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, mode) - left = constrainedBox[0] - top = constrainedBox[1] - right = constrainedBox[2] - bottom = constrainedBox[3] - } else { - // Just ensure minimum size - if (right - left < 10) { - if (right < 1000) right = Math.min(1000, left + 10) - else left = Math.max(0, right - 10) - } - if (bottom - top < 10) { - if (bottom < 1000) bottom = Math.min(1000, top + 10) - else top = Math.max(0, bottom - 10) - } - } - - uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] - } - - function getAspectRatio(name) { - // Map aspect ratio names to ratios - if (name === "1:1 (Square)") return [1, 1] - if (name === "4:5 (Portrait)") return [4, 5] - if (name === "1.91:1 (Landscape)") return [191, 100] - if (name === "9:16 (Story)") return [9, 16] - if (name === "16:9 (Wide)") return [16, 9] - return null - } - - function applyAspectRatioConstraint(left, top, right, bottom, dragMode) { - if (uiState.currentAspectRatioIndex <= 0 || !uiState.aspectRatioNames || uiState.aspectRatioNames.length <= uiState.currentAspectRatioIndex) { - // No aspect ratio, just clamp to bounds - return [ - Math.max(0, Math.min(1000, left)), - Math.max(0, Math.min(1000, top)), - Math.max(0, Math.min(1000, right)), - Math.max(0, Math.min(1000, bottom)) - ]; - } - - var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; - var ratioPair = getAspectRatio(ratioName); - if (!ratioPair || !mainImage || !imageRotator.width || !imageRotator.height) { - return [left, top, right, bottom]; - } - - // Calculate effective aspect ratio in 0-1000 normalized space - // targetAspect (pixels) = width_px / height_px - // width_px = width_norm * imgW / 1000 - // height_px = height_norm * imgH / 1000 - // targetAspect = (width_norm * imgW) / (height_norm * imgH) - // width_norm / height_norm = targetAspect * (imgH / imgW) - - var pixelAspect = ratioPair[0] / ratioPair[1]; - // Use mainImage (fixed canvas) for aspect ratio calculation - var imageAspect = mainImage.width / mainImage.height; - var targetAspect = pixelAspect * (1.0 / imageAspect); // Normalized aspect ratio - - var currentWidth = right - left; - var currentHeight = bottom - top; - - // For "new" drag (which we mapped to specific corners in updateCropBox) or corner drags - - if (dragMode.includes("left") || dragMode.includes("right")) { - // Edge drag (Left/Right) or Corner drag (where Width drives Height) - // Standard behavior: Corner drags are driven by the dominant axis or strictly one axis? - // Let's use the explicit corner logic below. - // This block handles pure Edge drags. - - if (!dragMode.includes("top") && !dragMode.includes("bottom")) { - // Pure Left/Right drag: Adjust height symmetrically - var newWidth = right - left; - var newHeight = newWidth / targetAspect; - var vCenter = (cropBoxStartTop + cropBoxStartBottom) / 2; - - top = vCenter - newHeight / 2; - bottom = vCenter + newHeight / 2; - - // Clamp vertical - var clamped = false; - if (top < 0) { - top = 0; - bottom = newHeight; - if (bottom > 1000) { bottom = 1000; clamped = true; } - } - if (bottom > 1000) { - bottom = 1000; - top = 1000 - newHeight; - if (top < 0) { top = 0; clamped = true; } - } - - // If height was clamped, recalculate width - if (clamped) { - var finalHeight = bottom - top; - var finalWidth = finalHeight * targetAspect; - // Adjust left/right to match final width (anchor opposite side) - if (dragMode.includes("left")) { - left = right - finalWidth; - } else { - right = left + finalWidth; - } - } - } - } - - if ((dragMode.includes("top") || dragMode.includes("bottom")) && !dragMode.includes("left") && !dragMode.includes("right")) { - // Pure Top/Bottom drag: Adjust width symmetrically - var newHeight = bottom - top; - var newWidth = newHeight * targetAspect; - var hCenter = (cropBoxStartLeft + cropBoxStartRight) / 2; - - left = hCenter - newWidth / 2; - right = hCenter + newWidth / 2; - - // Clamp horizontal - var clamped = false; - if (left < 0) { - left = 0; - right = newWidth; - if (right > 1000) { right = 1000; clamped = true; } - } - if (right > 1000) { - right = 1000; - left = 1000 - newWidth; - if (left < 0) { left = 0; clamped = true; } - } - - if (clamped) { - var finalWidth = right - left; - var finalHeight = finalWidth / targetAspect; - if (dragMode.includes("top")) { - top = bottom - finalHeight; - } else { - bottom = top + finalHeight; - } - } - } - - // Corner Drags - if (dragMode.includes("topleft")) { // Corner: Top-Left (Anchor: Bottom-Right) - var newW = right - left; - var newH = newW / targetAspect; - - // Check bounds - if (bottom - newH < 0) { // Top < 0 - newH = bottom; - newW = newH * targetAspect; - } - if (right - newW < 0) { // Left < 0 (shouldn't happen if we started inside, but good to check) - // If we are here, it means even with max height, width is too big? - // Just clamp to 0 - } - - left = right - newW; - top = bottom - newH; - - } else if (dragMode.includes("topright")) { // Corner: Top-Right (Anchor: Bottom-Left) - var newW = right - left; - var newH = newW / targetAspect; - - // Check bounds: top >= 0 - if (bottom - newH < 0) { - newH = bottom; - newW = newH * targetAspect; - } - // Check bounds: right <= 1000 - if (left + newW > 1000) { - newW = 1000 - left; - newH = newW / targetAspect; - } - - right = left + newW; - top = bottom - newH; - - } else if (dragMode.includes("bottomleft")) { // Corner: Bottom-Left (Anchor: Top-Right) - var newW = right - left; - var newH = newW / targetAspect; - - // Check bounds: bottom <= 1000 - if (top + newH > 1000) { - newH = 1000 - top; - newW = newH * targetAspect; - } - // Check bounds: left >= 0 - if (right - newW < 0) { - newW = right; - newH = newW / targetAspect; - } - - left = right - newW; - bottom = top + newH; - - } else if (dragMode.includes("bottomright")) { // Corner: Bottom-Right (Anchor: Top-Left) - var newW = right - left; - var newH = newW / targetAspect; - - // Check bounds: bottom <= 1000 - if (top + newH > 1000) { - newH = 1000 - top; - newW = newH * targetAspect; - } - // Check bounds: right <= 1000 - if (left + newW > 1000) { - newW = 1000 - left; - newH = newW / targetAspect; - } - - right = left + newW; - bottom = top + newH; - } - - return [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)]; - } - - function updateCropBoxFromAspectRatio() { - if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return - var box = uiState.currentCropBox - - // Start with center of current box - var cx = (box[0] + box[2]) / 2 - var cy = (box[1] + box[3]) / 2 - - // If current box is basically full image (default), use image center - if (box[0] <= 10 && box[1] <= 10 && box[2] >= 990 && box[3] >= 990) { - cx = 500 - cy = 500 - } - - var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; - var ratioPair = getAspectRatio(ratioName); - - if (!ratioPair) { // Freeform selected - uiState.currentCropBox = [0, 0, 1000, 1000] // Reset to full image - mainMouseArea.cropRotation = 0 // Also reset visual rotation - mainMouseArea.isRotating = false - mainMouseArea.cropDragMode = "none" - return; - } - var targetAspect = ratioPair[0] / ratioPair[1]; - - // Maximize width/height within 0-1000 centered at cx, cy - // Distance to edges - var maxW_half = Math.min(cx, 1000 - cx) - var maxH_half = Math.min(cy, 1000 - cy) - - // Try fitting to width limits first - var width = maxW_half * 2 - var height = width / targetAspect - - // If height exceeds limits, scale down - if (height > maxH_half * 2) { - height = maxH_half * 2 - width = height * targetAspect - } - - // Also ensure we don't make a tiny box if cx,cy is near edge. - // If box is too small (<100), re-center to image center (500,500) - if (width < 100 || height < 100) { - cx = 500; cy = 500; - maxW_half = 500; maxH_half = 500; - width = 1000; - height = width / targetAspect; - if (height > 1000) { - height = 1000; - width = height * targetAspect; - } - } - - var left = cx - width / 2 - var right = cx + width / 2 - var top = cy - height / 2 - var bottom = cy + height / 2 - - uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] - } - } - - // Crop rectangle overlay (Moved to mainImage) - - // Aspect ratio selector window (upper left corner) - Rectangle { - id: aspectRatioWindow - visible: uiState && uiState.isCropping - anchors.top: parent.top - anchors.left: parent.left - anchors.margins: 10 - width: 120 - height: Math.max(150, aspectRatioColumn.implicitHeight + 20) - color: "#333333" - border.color: "#666666" - border.width: 1 - radius: 4 - z: 1000 - - // Try to get root from parent hierarchy - property bool isDark: typeof root !== "undefined" && root ? root.isDarkTheme : true - - Component.onCompleted: { - // Update colors based on theme - color = isDark ? "#333333" : "#f0f0f0" - border.color = isDark ? "#666666" : "#cccccc" - } Column { - id: aspectRatioColumn - anchors.fill: parent - anchors.margins: 10 - spacing: 5 - - Text { - text: "Aspect Ratio" - font.bold: true - color: aspectRatioWindow.isDark ? "white" : "black" - font.pixelSize: 12 - } - - Repeater { - model: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames.length : 0 - - Rectangle { - width: parent.width - height: 30 - color: uiState && uiState.currentAspectRatioIndex === index ? "#555555" : "transparent" - radius: 3 - - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames[index] : "" - color: aspectRatioWindow.isDark ? "white" : "black" - font.pixelSize: 11 - } - - MouseArea { - anchors.fill: parent - onClicked: { - if (uiState) { - uiState.currentAspectRatioIndex = index - // Re-apply aspect ratio to current crop box - if (uiState.currentCropBox && uiState.currentCropBox.length === 4) { - mainMouseArea.updateCropBoxFromAspectRatio() - } - } - } - } - } - } - - Rectangle { - width: parent.width - height: 30 - color: mainMouseArea.isRotating ? "#555555" : "transparent" - radius: 3 - - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: "Rotate" - color: aspectRatioWindow.isDark ? "white" : "black" - font.pixelSize: 11 - font.bold: mainMouseArea.isRotating - } - - MouseArea { - anchors.fill: parent - onClicked: { - mainMouseArea.isRotating = !mainMouseArea.isRotating - mainMouseArea.cropDragMode = "none" - } - } - } - } - } - - -} +import QtQuick +import QtQuick.Window + +// This file is intended to hold QML components like the main image view. +// For simplicity, we'll start with just the main image view. + +Item { + id: loupeView + anchors.fill: parent + focus: true + + // Height of the status bar footer in Main.qml + property int footerHeight: 60 + + Connections { + target: uiState + function onCurrentIndexChanged() { + // Smart High-Res Logic: + // Before the new image loads, decide if we should keep high-res mode. + // Rule: Only keep high-res if we are currently "meaningfully zoomed" (> 1.1x fit). + // This prevents "sticky" high-res where zooming in once keeps it forever. + + if (imageRotator.zoomScale > imageRotator.fitScale * 1.1) { + // Keep high-res (setZoomed true if not already) + if (!uiState.isZoomed) uiState.setZoomed(true) + } else { + // Drop to low-res for the next image + if (uiState.isZoomed) uiState.setZoomed(false) + } + } + } + + Keys.onEscapePressed: (event) => { + if (uiState && uiState.isCropping) { + if (mainMouseArea.isRotating) { + // Revert rotation + mainMouseArea.cropRotation = mainMouseArea.cropStartRotation + if (controller) controller.set_straighten_angle(mainMouseArea.cropRotation, -1) + + mainMouseArea.isRotating = false + mainMouseArea.cropDragMode = "none" + mainMouseArea.isCropDragging = false + event.accepted = true + } else if (controller) { + controller.cancel_crop_mode() + mainMouseArea.cropRotation = 0 // Reset local rotation + event.accepted = true + } + } + } + + + + + Keys.onPressed: (event) => { + // Zoom Shortcuts (Ctrl+1..4) + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_1) { + uiState.request_absolute_zoom(1.0) + event.accepted = true + return + } else if (event.key === Qt.Key_2) { + uiState.request_absolute_zoom(2.0) + event.accepted = true + return + } else if (event.key === Qt.Key_3) { + uiState.request_absolute_zoom(3.0) + event.accepted = true + return + } else if (event.key === Qt.Key_4) { + // 400% zoom + uiState.request_absolute_zoom(4.0) + event.accepted = true + return + } + } + + // Handle Enter for Crop Execution (formerly Keys.onEnterPressed) + // We only accept the event if we actually act on it. + if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && uiState && uiState.isCropping && controller) { + // Force immediate rotation update before executing crop + if (mainMouseArea.cropRotation !== 0) { + controller.set_straighten_angle(mainMouseArea.cropRotation, -1) + } + + uiState.setZoomed(false) // Force unzoom + controller.execute_crop() + event.accepted = true + return + } + + // IMPORTANT: Allow unhandled keys to propagate to Python eventFilter logic + event.accepted = false + } + + + + // Connection to handle zoom/pan reset signal from Python + Connections { + target: uiState + function onResetZoomPanRequested() { + imageRotator.zoomScale = imageRotator.fitScale + panTransform.x = 0 + panTransform.y = 0 + } + function onAbsoluteZoomRequested(scale) { + if (uiState && uiState.debugMode) { + console.log("QML: Absolute zoom requested: " + scale) + } + + imageRotator.zoomScale = scale + + // If we need to switch to high-res, flag this scale as the target + // for the incoming source change so recomputeFitScale doesn't clobber it. + if (uiState && !uiState.isZoomed) { + imageRotator.targetAbsoluteZoom = scale + uiState.setZoomed(true) + } + } + } + + // Container that handles Viewport Clipping and Sizing + Item { + id: imageViewport + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.bottomMargin: footerHeight + clip: true + + // Container that handles Rotation (Straightening) + // This item represents the "Canvas" that expands when rotated. + Item { + id: imageRotator + anchors.centerIn: parent + + // Size matches the AABB of the rotated image + // W' = W*|cos| + H*|sin| + // Geometry is now updated atomically via updateRotatorGeometry() + property real implicitWidth: 0 + property real implicitHeight: 0 + property bool isUpdatingGeometry: false + + // Fix A: Atomic Zoom Scale + property real zoomScale: 1.0 + + // Fix C: Persist requested absolute zoom across source changes + property real targetAbsoluteZoom: -1.0 + + onZoomScaleChanged: { + mainImage.updateZoomState() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + + // Fix B: Stable Logical Size + property real baseW: 0 + property real baseH: 0 + + function updateRotatorGeometry() { + if (!mainImage || mainImage.sourceSize.width <= 0) return + + isUpdatingGeometry = true + + var rad = mainMouseArea.cropRotation * (Math.PI / 180.0) + + // Use base size if available (stable during zoom), otherwise sourceSize + var w = (baseW > 0) ? baseW : mainImage.sourceSize.width + var h = (baseH > 0) ? baseH : mainImage.sourceSize.height + + var newW = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad)) + var newH = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad)) + + width = newW + height = newH + + // Atomically update mainImage size to prevent aspect ratio distortion + mainImage.width = w + mainImage.height = h + + isUpdatingGeometry = false + recomputeFitScale() + } + + Connections { + target: mainMouseArea + function onCropRotationChanged() { imageRotator.updateRotatorGeometry() } + } + // Trigger initial update (moved to end) + + // NEW: fit-to-window scale (minimum zoom) + property real fitScale: 1.0 + + function recomputeFitScale(force) { + if (force === undefined) force = false; + + if (width <= 0 || height <= 0 || imageViewport.width <= 0 || imageViewport.height <= 0) + return; + + // Prevent jitter: Don't recompute fit scale while dragging (resize, move, or rotate) + // Unless forced (e.g. on release) + if (!force && mainMouseArea.isCropDragging) return; + + // Capture current relative zoom to preserve it during resize/reload + var oldFit = fitScale + var currentScale = imageRotator.zoomScale + var ratio = 1.0 + if (oldFit > 0) { + ratio = currentScale / oldFit + } + + // fit rotated canvas into viewport + var s = Math.min(imageViewport.width / width, imageViewport.height / height); + // Ensure fitScale is finite and positive + // Allow upscaling to fit window (necessary for HiDPI logical sizing) + if (!isFinite(s) || s <= 0) s = 1.0; + // else if (s > 1.0) s = 1.0; // REMOVED: Cap prevented fitting small/logical images + + fitScale = s; + + // Restore zoom level + if (targetAbsoluteZoom > 0) { + // Check if we have a pending absolute zoom request (e.g. from Ctrl+1) + // If so, use it directly (1.0 = 1:1 pixels) and consume the flag. + imageRotator.zoomScale = targetAbsoluteZoom; + targetAbsoluteZoom = -1.0; + } else { + // Otherwise, preserve relative visual size (fit ratio) + imageRotator.zoomScale = fitScale * ratio; + } + // Preserve Pan (don't reset to 0) as pan is in screen pixels (mostly) + } + + onWidthChanged: if (!isUpdatingGeometry) recomputeFitScale() + onHeightChanged: if (!isUpdatingGeometry) recomputeFitScale() + Component.onCompleted: { + updateRotatorGeometry() + recomputeFitScale() + } + + Connections { + target: imageViewport + function onWidthChanged() { imageRotator.recomputeFitScale() } + function onHeightChanged() { imageRotator.recomputeFitScale() } + } + + transform: [ + Scale { + id: scaleTransform + origin.x: imageRotator.width / 2 + origin.y: imageRotator.height / 2 + xScale: imageRotator.zoomScale + yScale: imageRotator.zoomScale + }, + Translate { + id: panTransform + onXChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + onYChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + } + ] + + // The main image display + Image { + id: mainImage + anchors.centerIn: parent + + // Image size is now updated atomically in updateRotatorGeometry to prevent distortion + // width: sourceSize.width + // height: sourceSize.height + + rotation: mainMouseArea.cropRotation + + // Crop overlay - anchored to mainImage to rotate with it + Item { + id: cropOverlay + property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] + property bool hasActiveCrop: cropBox && cropBox.length === 4 && !(cropBox[0]===0 && cropBox[1]===0 && cropBox[2]===1000 && cropBox[3]===1000) + + visible: uiState && uiState.isCropping && (hasActiveCrop || mainMouseArea.isRotating) + anchors.fill: parent // Fills mainImage + z: 100 + + onCropBoxChanged: { if (parent.source) updateCropRect() } + Component.onCompleted: { if (parent.source) updateCropRect() } + + Connections { + target: uiState + function onCurrentCropBoxChanged() { if (cropOverlay.visible && mainImage.source) cropOverlay.updateCropRect() } + } + + Connections { + target: mainImage + function onWidthChanged() { cropOverlay.updateCropRect() } + function onHeightChanged() { cropOverlay.updateCropRect() } + } + + function updateCropRect() { + if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return + var box = uiState.currentCropBox + + // Local coords in mainImage (Source Space) + var localLeft = (box[0] / 1000) * parent.width + var localTop = (box[1] / 1000) * parent.height + var localRight = (box[2] / 1000) * parent.width + var localBottom = (box[3] / 1000) * parent.height + + cropRect.x = localLeft + cropRect.y = localTop + cropRect.width = localRight - localLeft + cropRect.height = localBottom - localTop + } + + // Dimmer Rectangles + Rectangle { x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 } + Rectangle { x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 } + Rectangle { x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 } + Rectangle { x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 } + + Rectangle { + id: cropRect + color: "transparent" + border.color: "white" + border.width: 3 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + + // Rotation Handle Line + Rectangle { + id: handleLine + visible: mainMouseArea.isRotating + width: 2 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + height: 25 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + color: "white" + anchors.top: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + } + + // Rotation Knob + Rectangle { + id: rotateKnob + visible: mainMouseArea.isRotating + width: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + height: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + radius: width / 2 + color: "white" + border.color: "black" + border.width: 1 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + anchors.verticalCenter: handleLine.bottom + anchors.horizontalCenter: handleLine.horizontalCenter + } + } + } + + source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" + + function _currentDpr() { + // Per-window DPR is the safest (multi-monitor setups) + if (mainImage.window && mainImage.window.devicePixelRatio) + return mainImage.window.devicePixelRatio + return Screen.devicePixelRatio + } + + function handleSourceSizeChange() { + if (mainImage.sourceSize.width <= 0 || mainImage.sourceSize.height <= 0) return + + const dpr = _currentDpr() + + // Treat baseW/baseH as *device-independent pixels* that correspond to 1:1 physical pixels at zoomScale=1 + imageRotator.baseW = mainImage.sourceSize.width / dpr + imageRotator.baseH = mainImage.sourceSize.height / dpr + + // Rebuild rotator + mainImage geometry based on the NEW resolution + imageRotator.updateRotatorGeometry() + + // Force fit recompute so fitScale / zoom logic stabilizes immediately + imageRotator.recomputeFitScale(true) + + if (uiState && uiState.debugMode) { + console.log("sourceSize changed:", mainImage.sourceSize.width, mainImage.sourceSize.height, + "dpr:", dpr, + "base:", imageRotator.baseW, imageRotator.baseH, + "zoomScale:", imageRotator.zoomScale) + } + } + + onSourceSizeChanged: { handleSourceSizeChange() } + + onStatusChanged: { + if (status === Image.Ready) { + // Some backends update sourceSize right as status flips + mainImage.handleSourceSizeChange() + imageRotator.updateRotatorGeometry() + } + } + + // Force reset when source changes (existing logic) + onSourceChanged: { + // Reset base size for new image so we pick up the new sourceSize + imageRotator.baseW = 0 + imageRotator.baseH = 0 + + // Smart Zoom Reset: + // If we intended to keep high-res (isZoomed is true), preserve capabilities. + // If not (isZoomed is false), reset to "fit" state for speed and consistency. + if (uiState && !uiState.isZoomed) { + mainMouseArea.cropRotation = 0 + mainMouseArea.isRotating = false + mainMouseArea.cropDragMode = "none" + + imageRotator.zoomScale = imageRotator.fitScale + panTransform.x = 0 + panTransform.y = 0 + } + } + fillMode: Image.PreserveAspectFit + cache: false // We do our own caching in Python + smooth: false // Crisp rendering for technical accuracy + mipmap: false // Crisp rendering + + property bool isZooming: false + + // IMPORTANT: tell Python the *viewport* size, not the sourceSize size + function reportDisplaySize() { + if (imageViewport.width > 0 && imageViewport.height > 0) { + var dpr = Screen.devicePixelRatio + uiState.onDisplaySizeChanged( + Math.round(imageViewport.width * dpr), + Math.round(imageViewport.height * dpr) + ) + } + } + + Component.onCompleted: reportDisplaySize() + Connections { + target: imageViewport + function onWidthChanged() { mainImage.reportDisplaySize() } + function onHeightChanged() { mainImage.reportDisplaySize() } + } + + // Removed direct onWidth/HeightChanged handlers for resizeDebounceTimer + // because we now drive size reporting via viewport changes. + + Timer { + id: lowResDebounceTimer + interval: 200 // 200ms debounce to prevent thrashing + repeat: false + onTriggered: { + if (uiState && uiState.isZoomed) { + uiState.setZoomed(false) + } + } + } + + function updateZoomState() { + if (!uiState) return; + + // Thresholds for hysteresis + var highResThreshold = imageRotator.fitScale * 1.1 + var lowResThreshold = imageRotator.fitScale * 1.02 + + // Enable High-Res if zoomed in significantly + if (imageRotator.zoomScale > highResThreshold) { + lowResDebounceTimer.stop() + if (!uiState.isZoomed) { + uiState.setZoomed(true); + } + } + // Disable High-Res (return to low-res) if zoomed out to near-fit + // formatting note: added hysteresis check AND debounce + else if (imageRotator.zoomScale <= lowResThreshold) { + if (uiState.isZoomed) { + // Only drop to low-res after delay to handle wheel overshoot/jitter + if (!lowResDebounceTimer.running) lowResDebounceTimer.start() + } + } else { + // In hysteresis band: cancel any pending low-res switch + lowResDebounceTimer.stop() + } + + updateHistogramWithZoom() + } + + function updateHistogramWithZoom() { + if (uiState && uiState.isHistogramVisible && controller) { + var zoom = imageRotator.zoomScale + var panX = panTransform.x + var panY = panTransform.y + var imageScale = imageRotator.zoomScale + controller.update_histogram(zoom, panX, panY, imageScale) + } + } + + + } + + + + + } + } + + // Zoom and Pan logic would go here + // For example, using PinchArea or MouseArea + + + MouseArea { + id: mainMouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + cursorShape: { + if (!uiState || !uiState.isCropping) return Qt.ArrowCursor + // Use a simple cross cursor for crop mode - edge detection would require tracking mouse position + // which is complex in QML. The edge dragging will still work based on click position. + return Qt.CrossCursor + } + + // 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 + property bool isCropDragging: false + property real cropStartX: 0 + property real cropStartY: 0 + + property string cropDragMode: "none" // "none", "new", "move", "left", "right", "top", "bottom", "topleft", "topright", "bottomleft", "bottomright" + property real cropBoxStartLeft: 0 + property real cropBoxStartTop: 0 + property real cropBoxStartRight: 0 + property real cropBoxStartBottom: 0 + property real cropRotation: 0 + property bool isRotating: false + property real cropStartAngle: 0 + property real cropStartRotation: 0 + property real cropStartAspect: -1 + + // Reset rotation when image changes or updates (e.g. after crop save) to avoid persistence + Connections { + target: uiState + function onCurrentIndexChanged() { + mainMouseArea.cropRotation = 0 + } + } + + + onIsRotatingChanged: { + if (uiState) { + if (isRotating) { + uiState.statusMessage = "Press ESC to exit rotate mode" + } else { + if (uiState.statusMessage === "Press ESC to exit rotate mode") { + uiState.statusMessage = "" + } + } + } + } + + property real pendingRotation: 0 + property real pendingAspect: -1 + + Timer { + id: rotationThrottleTimer + interval: 32 // ~30 fps + repeat: false + onTriggered: { + if (controller && uiState && uiState.isCropping) { + controller.set_straighten_angle(mainMouseArea.pendingRotation, mainMouseArea.pendingAspect) + } + } + } + + onPressed: function(mouse) { + lastX = mouse.x + lastY = mouse.y + startX = mouse.x + startY = mouse.y + isDraggingOutside = false + + if (mouse.button === Qt.RightButton) { + if (uiState && uiState.isCropping) { + // Cancel crop mode if already active + if (controller) controller.cancel_crop_mode() + } else if (uiState) { + // Enter crop mode and start new crop + uiState.isCropping = true + + // Set up new crop state + cropDragMode = "new" + cropStartX = mouse.x + cropStartY = mouse.y + + // Initialize anchors + var startCoords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) + // Clamp to [0, 1] and convert to [0, 1000] + var startNormX = Math.max(0, Math.min(1, startCoords.x)) * 1000 + var startNormY = Math.max(0, Math.min(1, startCoords.y)) * 1000 + + cropBoxStartLeft = startNormX + cropBoxStartRight = startNormX + cropBoxStartTop = startNormY + cropBoxStartBottom = startNormY + + isCropDragging = true + } + return + } + + if (uiState && uiState.isCropping) { + // Check if clicking on existing crop box - Using Image Space Hit Testing + var box = uiState.currentCropBox + if (box && box.length === 4) box = box.slice(0) + + var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 + + var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) + var mx = coords.x * 1000 + var my = coords.y * 1000 + + // Calculate threshold in normalized units (approx 10 screen pixels) + var threshX = (10 / (scaleTransform.xScale * mainImage.width)) * 1000 + var threshY = (10 / (scaleTransform.yScale * mainImage.height)) * 1000 + + // Clamp threshold: min 5 normalized units (prevent too small), max 40 (prevent too large) + // This ensures handles remain usable at all zoom levels + var edgeThreshold = Math.max(5, Math.min(40, Math.max(threshX, threshY))) + + var inside = mx >= box[0] && mx <= box[2] && my >= box[1] && my <= box[3] + + // --- Hit test for rotation handle (robust: uses actual knob transform) --- + if (mainMouseArea.isRotating && cropOverlay.visible && rotateKnob.visible) { + // knob center in mainMouseArea coords (includes cropRect rotation) + // Note: rotateKnob is now inside mainImage -> cropOverlay -> cropRect + // But mapFromItem should still work if we target the object properly. + // We need to resolve `rotateKnob` which is inside cropOverlay. + // If cropOverlay moves, we need to ensure this binding works. + // IMPORTANT: cropOverlay is not moved yet in this call. + // Current logic relies on existing structure. I will defer logic update if structure changes. + // But hit testing via mapFromItem(rotateKnob) is robust to hierarchy changes as long as rotateKnob exists. + + var k = mainMouseArea.mapFromItem(rotateKnob, rotateKnob.width/2, rotateKnob.height/2) + var dxk = mouse.x - k.x + var dyk = mouse.y - k.y + var distk = Math.sqrt(dxk*dxk + dyk*dyk) + + if (distk < 22 * Screen.devicePixelRatio) { // a little forgiving + cropDragMode = "rotate" + + // crop center in mainMouseArea coords -> Changed to IMAGE center to avoid feedback loop + var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) + cropStartAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI + cropStartRotation = cropRotation + + // Calculate start aspect ratio (in pixels) + if (mainImage.width > 0) { + if (box && box.length === 4) { + var boxW = (box[2] - box[0]) / 1000 * mainImage.width + var boxH = (box[3] - box[1]) / 1000 * mainImage.height + if (boxH > 0) cropStartAspect = boxW / boxH + } + } + + + // Seed cropBoxStart variables + if (box && box.length === 4) { + cropBoxStartLeft = box[0] + cropBoxStartTop = box[1] + cropBoxStartRight = box[2] + cropBoxStartBottom = box[3] + } + + isCropDragging = true + return + } + } + + // If crop box is full image, always start a new crop + else if (isFullImage) { + cropDragMode = "new" + cropStartX = mouse.x + cropStartY = mouse.y + } else if (inside) { + // Determine which edge/corner is being dragged (Image Space) + var nearLeft = Math.abs(mx - box[0]) < edgeThreshold + var nearRight = Math.abs(mx - box[2]) < edgeThreshold + var nearTop = Math.abs(my - box[1]) < edgeThreshold + var nearBottom = Math.abs(my - box[3]) < edgeThreshold + + if (nearLeft && nearTop) cropDragMode = "topleft" + else if (nearRight && nearTop) cropDragMode = "topright" + else if (nearLeft && nearBottom) cropDragMode = "bottomleft" + else if (nearRight && nearBottom) cropDragMode = "bottomright" + else if (nearLeft) cropDragMode = "left" + else if (nearRight) cropDragMode = "right" + else if (nearTop) cropDragMode = "top" + else if (nearBottom) cropDragMode = "bottom" + else cropDragMode = "move" + + // Store initial crop box + cropBoxStartLeft = box[0] + cropBoxStartTop = box[1] + cropBoxStartRight = box[2] + cropBoxStartBottom = box[3] + } else { + // Start new crop rectangle + cropDragMode = "new" + cropStartX = mouse.x + cropStartY = mouse.y + + // Initialize anchors + cropBoxStartLeft = mx + cropBoxStartRight = mx + cropBoxStartTop = my + cropBoxStartBottom = my + } + isCropDragging = true + } + } + // Legacy getCropRect removed - using Image Space hit testing instead. + // mapToImageCoordinates maps directly to mainImage + function mapToImageCoordinates(screenPoint) { + var p = mainMouseArea.mapToItem(mainImage, screenPoint.x, screenPoint.y) + return {x: p.x / mainImage.width, y: p.y / mainImage.height} + } + onPositionChanged: function(mouse) { + if (uiState && uiState.isCropping && isCropDragging) { + if (cropDragMode === "new") { + // Update crop rectangle while dragging + updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y, true) + } else if (cropDragMode === "rotate") { + var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) + var currentAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI + var delta = currentAngle - cropStartAngle + // Handle wrap-around + if (delta > 180) delta -= 360 + if (delta < -180) delta += 360 + + var newRotation = cropStartRotation + delta + + // Update rotation state + cropRotation = newRotation + + // Update rotation in backend live (throttled) + if (controller) { + pendingRotation = cropRotation + pendingAspect = -1 + + if (!rotationThrottleTimer.running) { + rotationThrottleTimer.start() + } + } + // Return early to prevent overwriting crop box during rotation + return + } else { + // Handle move/resize (edge dragging) + var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) + + // Clamp to image bounds and convert to 0-1000 range + var mouseX = Math.max(0, Math.min(1, coords.x)) * 1000 + var mouseY = Math.max(0, Math.min(1, coords.y)) * 1000 + + var left = cropBoxStartLeft + var top = cropBoxStartTop + var right = cropBoxStartRight + var bottom = cropBoxStartBottom + + // Adjust based on drag mode + if (cropDragMode === "move") { + var startCenterX = (cropBoxStartLeft + cropBoxStartRight) / 2 + var startCenterY = (cropBoxStartTop + cropBoxStartBottom) / 2 + + var dx = mouseX - startCenterX + var dy = mouseY - startCenterY + + var width = cropBoxStartRight - cropBoxStartLeft + var height = cropBoxStartBottom - cropBoxStartTop + + left = Math.max(0, Math.min(1000 - width, cropBoxStartLeft + dx)) + top = Math.max(0, Math.min(1000 - height, cropBoxStartTop + dy)) + right = left + width + bottom = top + height + } else { + if (cropDragMode.includes("left")) left = mouseX; + if (cropDragMode.includes("right")) right = mouseX; + if (cropDragMode.includes("top")) top = mouseY; + if (cropDragMode.includes("bottom")) bottom = mouseY; + + var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, cropDragMode) + left = constrainedBox[0] + top = constrainedBox[1] + right = constrainedBox[2] + bottom = constrainedBox[3] + } + + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + } + return + } + + 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 + if (controller) controller.start_drag_current_image() + return + } + } + + // Normal pan behavior (only when not cropping) + if (!uiState || !uiState.isCropping) { + panTransform.x += (mouse.x - lastX) + panTransform.y += (mouse.y - lastY) + lastX = mouse.x + lastY = mouse.y + } + } + } + + onReleased: function(mouse) { + isDraggingOutside = false + if (uiState && uiState.isCropping && isCropDragging) { + // Fix: Prevent accidental tiny crops with Right Click + if (mouse.button === Qt.RightButton && cropDragMode === "new") { + var dx = Math.abs(mouse.x - cropStartX) + var dy = Math.abs(mouse.y - cropStartY) + var maxDim = Math.max(dx, dy) + var minDim = Math.min(dx, dy) + + // "at least 50 pixels in both dimensions" + if (maxDim < 50 || minDim < 50) { + if (controller) controller.cancel_crop_mode() + isCropDragging = false + cropDragMode = "none" + return + } + } + + isCropDragging = false + cropDragMode = "none" + // Settle zoom/pan after rotation ends (Force recompute) + if (mainMouseArea.isRotating) imageRotator.recomputeFitScale(true) + } + } + + // Wheel for zoom - zooms in towards cursor, zooms out towards center + onWheel: function(wheel) { + // Disable smooth rendering during zoom for better performance + mainImage.isZooming = true + + // Use a smaller scale factor for smoother, more responsive zoom + var isZoomingIn = wheel.angleDelta.y > 0 + var scaleFactor = isZoomingIn ? 1.1 : 1 / 1.1; + + // Calculate old and new scale + var oldScale = imageRotator.zoomScale + var newScale = oldScale * scaleFactor + // Allow zooming out past "Fit" to 5%. Cap max at 20x. + newScale = Math.max(0.05, Math.min(20.0, newScale)) + + // Current state + var currentPanX = panTransform.x + var currentPanY = panTransform.y + + // Screen center (Viewport center) + var centerX = imageViewport.width / 2 + var centerY = imageViewport.height / 2 + + // Fix C: Use Viewport Coordinates (account for footer offset etc) + var p = mainMouseArea.mapToItem(imageViewport, wheel.x, wheel.y) + var mouseX = p.x + var mouseY = p.y + + var mouseOffsetFromCenterX = mouseX - centerX + var mouseOffsetFromCenterY = mouseY - centerY + + // Calculate the "image point" currently under the cursor (relative to image center, unscaled) + // ScreenPos = Center + Pan + (ImagePoint * Scale) + // ImagePoint = (ScreenPos - Center - Pan) / Scale + // ImagePoint = (MouseOffsetFromCenter - Pan) / Scale + var imagePointX = (mouseOffsetFromCenterX - currentPanX) / oldScale + var imagePointY = (mouseOffsetFromCenterY - currentPanY) / oldScale + + // We want to keep this ImagePoint under the cursor after scaling: + // MouseOffsetFromCenter = Pan_New + (ImagePoint * Scale_New) + // Pan_New = MouseOffsetFromCenter - (ImagePoint * Scale_New) + + var newPanX = mouseOffsetFromCenterX - (imagePointX * newScale) + var newPanY = mouseOffsetFromCenterY - (imagePointY * newScale) + + // Apply updates + imageRotator.zoomScale = newScale + panTransform.x = newPanX + panTransform.y = newPanY + + // Re-enable smooth rendering after a short delay + zoomSmoothTimer.restart() + } + + Timer { + id: zoomSmoothTimer + interval: 150 // Re-enable smooth rendering 150ms after last zoom + onTriggered: { + mainImage.isZooming = false + } + } + + function updateCropBox(x1, y1, x2, y2, applyAspectRatio = false) { + if (!uiState || !mainImage.source) return + + var imgCoord1 = mapToImageCoordinates(Qt.point(x1, y1)) + var imgCoord2 = mapToImageCoordinates(Qt.point(x2, y2)) + + // Clamp to image bounds (normalized 0-1) + var imgCoordX1 = Math.max(0, Math.min(1, imgCoord1.x)) + var imgCoordY1 = Math.max(0, Math.min(1, imgCoord1.y)) + var imgCoordX2 = Math.max(0, Math.min(1, imgCoord2.x)) + var imgCoordY2 = Math.max(0, Math.min(1, imgCoord2.y)) + + // Calculate raw box in 0-1000 space + var left = Math.min(imgCoordX1, imgCoordX2) * 1000 + var right = Math.max(imgCoordX1, imgCoordX2) * 1000 + var top = Math.min(imgCoordY1, imgCoordY2) * 1000 + var bottom = Math.max(imgCoordY1, imgCoordY2) * 1000 + + // Determine primary drag direction for "new" mode (from anchor x1,y1 to mouse x2,y2) + // We need to know which corner is the anchor to apply aspect ratio correctly + // x1,y1 is anchor. x2,y2 is mouse. + + if (applyAspectRatio && mainImage.sourceSize) { + // We need to pass the specific corner being dragged to applyAspectRatioConstraint + // Since "new" creates a box from x1,y1 to x2,y2, we can infer the mode. + var mode = "new" + if (x2 >= x1 && y2 >= y1) mode = "bottomright" + else if (x2 < x1 && y2 >= y1) mode = "bottomleft" + else if (x2 >= x1 && y2 < y1) mode = "topright" + else if (x2 < x1 && y2 < y1) mode = "topleft" + + // Pass the raw coordinates of the "mouse" corner (x2, y2) and the "anchor" corner (x1, y1) + // But applyAspectRatioConstraint expects left, top, right, bottom. + // It assumes one corner is fixed based on mode. + // So we pass the current box, and it will adjust the moving corner. + + var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, mode) + left = constrainedBox[0] + top = constrainedBox[1] + right = constrainedBox[2] + bottom = constrainedBox[3] + } else { + // Just ensure minimum size + if (right - left < 10) { + if (right < 1000) right = Math.min(1000, left + 10) + else left = Math.max(0, right - 10) + } + if (bottom - top < 10) { + if (bottom < 1000) bottom = Math.min(1000, top + 10) + else top = Math.max(0, bottom - 10) + } + } + + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + } + + function getAspectRatio(name) { + // Map aspect ratio names to ratios + if (name === "1:1 (Square)") return [1, 1] + if (name === "4:5 (Portrait)") return [4, 5] + if (name === "1.91:1 (Landscape)") return [191, 100] + if (name === "9:16 (Story)") return [9, 16] + if (name === "16:9 (Wide)") return [16, 9] + return null + } + + function applyAspectRatioConstraint(left, top, right, bottom, dragMode) { + if (uiState.currentAspectRatioIndex <= 0 || !uiState.aspectRatioNames || uiState.aspectRatioNames.length <= uiState.currentAspectRatioIndex) { + // No aspect ratio, just clamp to bounds + return [ + Math.max(0, Math.min(1000, left)), + Math.max(0, Math.min(1000, top)), + Math.max(0, Math.min(1000, right)), + Math.max(0, Math.min(1000, bottom)) + ]; + } + + var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; + var ratioPair = getAspectRatio(ratioName); + if (!ratioPair || !mainImage || !imageRotator.width || !imageRotator.height) { + return [left, top, right, bottom]; + } + + // Calculate effective aspect ratio in 0-1000 normalized space + // targetAspect (pixels) = width_px / height_px + // width_px = width_norm * imgW / 1000 + // height_px = height_norm * imgH / 1000 + // targetAspect = (width_norm * imgW) / (height_norm * imgH) + // width_norm / height_norm = targetAspect * (imgH / imgW) + + var pixelAspect = ratioPair[0] / ratioPair[1]; + // Use mainImage (fixed canvas) for aspect ratio calculation + var imageAspect = mainImage.width / mainImage.height; + var targetAspect = pixelAspect * (1.0 / imageAspect); // Normalized aspect ratio + + var currentWidth = right - left; + var currentHeight = bottom - top; + + // For "new" drag (which we mapped to specific corners in updateCropBox) or corner drags + + if (dragMode.includes("left") || dragMode.includes("right")) { + // Edge drag (Left/Right) or Corner drag (where Width drives Height) + // Standard behavior: Corner drags are driven by the dominant axis or strictly one axis? + // Let's use the explicit corner logic below. + // This block handles pure Edge drags. + + if (!dragMode.includes("top") && !dragMode.includes("bottom")) { + // Pure Left/Right drag: Adjust height symmetrically + var newWidth = right - left; + var newHeight = newWidth / targetAspect; + var vCenter = (cropBoxStartTop + cropBoxStartBottom) / 2; + + top = vCenter - newHeight / 2; + bottom = vCenter + newHeight / 2; + + // Clamp vertical + var clamped = false; + if (top < 0) { + top = 0; + bottom = newHeight; + if (bottom > 1000) { bottom = 1000; clamped = true; } + } + if (bottom > 1000) { + bottom = 1000; + top = 1000 - newHeight; + if (top < 0) { top = 0; clamped = true; } + } + + // If height was clamped, recalculate width + if (clamped) { + var finalHeight = bottom - top; + var finalWidth = finalHeight * targetAspect; + // Adjust left/right to match final width (anchor opposite side) + if (dragMode.includes("left")) { + left = right - finalWidth; + } else { + right = left + finalWidth; + } + } + } + } + + if ((dragMode.includes("top") || dragMode.includes("bottom")) && !dragMode.includes("left") && !dragMode.includes("right")) { + // Pure Top/Bottom drag: Adjust width symmetrically + var newHeight = bottom - top; + var newWidth = newHeight * targetAspect; + var hCenter = (cropBoxStartLeft + cropBoxStartRight) / 2; + + left = hCenter - newWidth / 2; + right = hCenter + newWidth / 2; + + // Clamp horizontal + var clamped = false; + if (left < 0) { + left = 0; + right = newWidth; + if (right > 1000) { right = 1000; clamped = true; } + } + if (right > 1000) { + right = 1000; + left = 1000 - newWidth; + if (left < 0) { left = 0; clamped = true; } + } + + if (clamped) { + var finalWidth = right - left; + var finalHeight = finalWidth / targetAspect; + if (dragMode.includes("top")) { + top = bottom - finalHeight; + } else { + bottom = top + finalHeight; + } + } + } + + // Corner Drags + if (dragMode.includes("topleft")) { // Corner: Top-Left (Anchor: Bottom-Right) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds + if (bottom - newH < 0) { // Top < 0 + newH = bottom; + newW = newH * targetAspect; + } + if (right - newW < 0) { // Left < 0 (shouldn't happen if we started inside, but good to check) + // If we are here, it means even with max height, width is too big? + // Just clamp to 0 + } + + left = right - newW; + top = bottom - newH; + + } else if (dragMode.includes("topright")) { // Corner: Top-Right (Anchor: Bottom-Left) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds: top >= 0 + if (bottom - newH < 0) { + newH = bottom; + newW = newH * targetAspect; + } + // Check bounds: right <= 1000 + if (left + newW > 1000) { + newW = 1000 - left; + newH = newW / targetAspect; + } + + right = left + newW; + top = bottom - newH; + + } else if (dragMode.includes("bottomleft")) { // Corner: Bottom-Left (Anchor: Top-Right) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds: bottom <= 1000 + if (top + newH > 1000) { + newH = 1000 - top; + newW = newH * targetAspect; + } + // Check bounds: left >= 0 + if (right - newW < 0) { + newW = right; + newH = newW / targetAspect; + } + + left = right - newW; + bottom = top + newH; + + } else if (dragMode.includes("bottomright")) { // Corner: Bottom-Right (Anchor: Top-Left) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds: bottom <= 1000 + if (top + newH > 1000) { + newH = 1000 - top; + newW = newH * targetAspect; + } + // Check bounds: right <= 1000 + if (left + newW > 1000) { + newW = 1000 - left; + newH = newW / targetAspect; + } + + right = left + newW; + bottom = top + newH; + } + + return [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)]; + } + + function updateCropBoxFromAspectRatio() { + if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return + var box = uiState.currentCropBox + + // Start with center of current box + var cx = (box[0] + box[2]) / 2 + var cy = (box[1] + box[3]) / 2 + + // If current box is basically full image (default), use image center + if (box[0] <= 10 && box[1] <= 10 && box[2] >= 990 && box[3] >= 990) { + cx = 500 + cy = 500 + } + + var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; + var ratioPair = getAspectRatio(ratioName); + + if (!ratioPair) { // Freeform selected + uiState.currentCropBox = [0, 0, 1000, 1000] // Reset to full image + mainMouseArea.cropRotation = 0 // Also reset visual rotation + mainMouseArea.isRotating = false + mainMouseArea.cropDragMode = "none" + return; + } + var targetAspect = ratioPair[0] / ratioPair[1]; + + // Maximize width/height within 0-1000 centered at cx, cy + // Distance to edges + var maxW_half = Math.min(cx, 1000 - cx) + var maxH_half = Math.min(cy, 1000 - cy) + + // Try fitting to width limits first + var width = maxW_half * 2 + var height = width / targetAspect + + // If height exceeds limits, scale down + if (height > maxH_half * 2) { + height = maxH_half * 2 + width = height * targetAspect + } + + // Also ensure we don't make a tiny box if cx,cy is near edge. + // If box is too small (<100), re-center to image center (500,500) + if (width < 100 || height < 100) { + cx = 500; cy = 500; + maxW_half = 500; maxH_half = 500; + width = 1000; + height = width / targetAspect; + if (height > 1000) { + height = 1000; + width = height * targetAspect; + } + } + + var left = cx - width / 2 + var right = cx + width / 2 + var top = cy - height / 2 + var bottom = cy + height / 2 + + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + } + } + + // Crop rectangle overlay (Moved to mainImage) + + // Aspect ratio selector window (upper left corner) + Rectangle { + id: aspectRatioWindow + visible: uiState && uiState.isCropping + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 10 + width: 120 + height: Math.max(150, aspectRatioColumn.implicitHeight + 20) + color: "#333333" + border.color: "#666666" + border.width: 1 + radius: 4 + z: 1000 + + // Try to get root from parent hierarchy + property bool isDark: typeof root !== "undefined" && root ? root.isDarkTheme : true + + Component.onCompleted: { + // Update colors based on theme + color = isDark ? "#333333" : "#f0f0f0" + border.color = isDark ? "#666666" : "#cccccc" + } Column { + id: aspectRatioColumn + anchors.fill: parent + anchors.margins: 10 + spacing: 5 + + Text { + text: "Aspect Ratio" + font.bold: true + color: aspectRatioWindow.isDark ? "white" : "black" + font.pixelSize: 12 + } + + Repeater { + model: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames.length : 0 + + Rectangle { + width: parent.width + height: 30 + color: uiState && uiState.currentAspectRatioIndex === index ? "#555555" : "transparent" + radius: 3 + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames[index] : "" + color: aspectRatioWindow.isDark ? "white" : "black" + font.pixelSize: 11 + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (uiState) { + uiState.currentAspectRatioIndex = index + // Re-apply aspect ratio to current crop box + if (uiState.currentCropBox && uiState.currentCropBox.length === 4) { + mainMouseArea.updateCropBoxFromAspectRatio() + } + } + } + } + } + } + + Rectangle { + width: parent.width + height: 30 + color: mainMouseArea.isRotating ? "#555555" : "transparent" + radius: 3 + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: "Rotate" + color: aspectRatioWindow.isDark ? "white" : "black" + font.pixelSize: 11 + font.bold: mainMouseArea.isRotating + } + + MouseArea { + anchors.fill: parent + onClicked: { + mainMouseArea.isRotating = !mainMouseArea.isRotating + mainMouseArea.cropDragMode = "none" + } + } + } + } + } + + +} diff --git a/faststack/qml/DeleteBatchDialog.qml b/faststack/qml/DeleteBatchDialog.qml index fe71f28..68ec371 100644 --- a/faststack/qml/DeleteBatchDialog.qml +++ b/faststack/qml/DeleteBatchDialog.qml @@ -1,122 +1,122 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 - -Dialog { - id: deleteBatchDialog - title: "Delete Images" - modal: true - standardButtons: Dialog.NoButton - closePolicy: Popup.CloseOnEscape - width: 450 - height: 250 - - property int batchCount: 0 - property color backgroundColor: "#1e1e1e" - property color textColor: "white" - - background: Rectangle { - color: deleteBatchDialog.backgroundColor - border.color: "#404040" - border.width: 1 - radius: 4 - } - - contentItem: Column { - spacing: 20 - padding: 20 - - Label { - text: `You have ${batchCount} image${batchCount === 1 ? '' : 's'} selected in a batch.` - wrapMode: Text.WordWrap - width: parent.width - parent.padding * 2 - color: deleteBatchDialog.textColor - font.pixelSize: 14 - } - - Label { - text: "What would you like to delete?" - wrapMode: Text.WordWrap - width: parent.width - parent.padding * 2 - color: deleteBatchDialog.textColor - font.pixelSize: 14 - } - - Row { - spacing: 10 - anchors.horizontalCenter: parent.horizontalCenter - - Button { - text: "Delete Current Image" - onClicked: { - deleteBatchDialog.close() - if (controller) { - controller.delete_current_image_only() - } - } - background: Rectangle { - color: parent.pressed ? "#555555" : (parent.hovered ? "#666666" : "#444444") - radius: 4 - } - contentItem: Text { - text: parent.text - color: deleteBatchDialog.textColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - - Button { - text: `Delete All (${batchCount})` - onClicked: { - deleteBatchDialog.close() - if (controller) { - controller.delete_batch_images() - } - } - background: Rectangle { - color: parent.pressed ? "#cc0000" : (parent.hovered ? "#ff0000" : "#aa0000") - radius: 4 - } - contentItem: Text { - text: parent.text - color: deleteBatchDialog.textColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - font.bold: true - } - } - - Button { - text: "Cancel" - onClicked: { - deleteBatchDialog.close() - } - background: Rectangle { - color: parent.pressed ? "#555555" : (parent.hovered ? "#666666" : "#444444") - radius: 4 - } - contentItem: Text { - text: parent.text - color: deleteBatchDialog.textColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - } - } - - onOpened: { - // Notify Python that a dialog is open - if (controller) { - controller.dialog_opened() - } - } - - onClosed: { - // Notify Python that dialog is closed - if (controller) { - controller.dialog_closed() - } - } -} +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 + +Dialog { + id: deleteBatchDialog + title: "Delete Images" + modal: true + standardButtons: Dialog.NoButton + closePolicy: Popup.CloseOnEscape + width: 450 + height: 250 + + property int batchCount: 0 + property color backgroundColor: "#1e1e1e" + property color textColor: "white" + + background: Rectangle { + color: deleteBatchDialog.backgroundColor + border.color: "#404040" + border.width: 1 + radius: 4 + } + + contentItem: Column { + spacing: 20 + padding: 20 + + Label { + text: `You have ${batchCount} image${batchCount === 1 ? '' : 's'} selected in a batch.` + wrapMode: Text.WordWrap + width: parent.width - parent.padding * 2 + color: deleteBatchDialog.textColor + font.pixelSize: 14 + } + + Label { + text: "What would you like to delete?" + wrapMode: Text.WordWrap + width: parent.width - parent.padding * 2 + color: deleteBatchDialog.textColor + font.pixelSize: 14 + } + + Row { + spacing: 10 + anchors.horizontalCenter: parent.horizontalCenter + + Button { + text: "Delete Current Image" + onClicked: { + deleteBatchDialog.close() + if (controller) { + controller.delete_current_image_only() + } + } + background: Rectangle { + color: parent.pressed ? "#555555" : (parent.hovered ? "#666666" : "#444444") + radius: 4 + } + contentItem: Text { + text: parent.text + color: deleteBatchDialog.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + Button { + text: `Delete All (${batchCount})` + onClicked: { + deleteBatchDialog.close() + if (controller) { + controller.delete_batch_images() + } + } + background: Rectangle { + color: parent.pressed ? "#cc0000" : (parent.hovered ? "#ff0000" : "#aa0000") + radius: 4 + } + contentItem: Text { + text: parent.text + color: deleteBatchDialog.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.bold: true + } + } + + Button { + text: "Cancel" + onClicked: { + deleteBatchDialog.close() + } + background: Rectangle { + color: parent.pressed ? "#555555" : (parent.hovered ? "#666666" : "#444444") + radius: 4 + } + contentItem: Text { + text: parent.text + color: deleteBatchDialog.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } + + onOpened: { + // Notify Python that a dialog is open + if (controller) { + controller.dialog_opened() + } + } + + onClosed: { + // Notify Python that dialog is closed + if (controller) { + controller.dialog_closed() + } + } +} diff --git a/faststack/qml/ExifDialog.qml b/faststack/qml/ExifDialog.qml index ec4ab99..7b5424d 100644 --- a/faststack/qml/ExifDialog.qml +++ b/faststack/qml/ExifDialog.qml @@ -1,118 +1,118 @@ -import QtQuick -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -Dialog { - id: exifDialog - title: "EXIF Data" - standardButtons: Dialog.Ok - modal: true - closePolicy: Popup.CloseOnEscape - width: 500 - height: 600 - - property var summaryData: ({}) - property var fullData: ({}) - property bool showFull: false - - // Theme properties (can be bound from Main.qml) - property color backgroundColor: "#333333" - property color textColor: "#ffffff" - - background: Rectangle { - color: exifDialog.backgroundColor - border.color: "#555555" - border.width: 1 - } - - onOpened: { - // Reset to summary view when opened - showFull = false - // Notify Python that a dialog is open - if (controller) { - controller.dialog_opened() - } - } - - onClosed: { - if (controller) { - controller.dialog_closed() - } - } - - contentItem: ColumnLayout { - spacing: 10 - - // Keyboard Handling - Item { - Layout.fillWidth: true - Layout.preferredHeight: 0 - focus: true - Keys.onPressed: (event) => { - if (event.key === Qt.Key_I) { - exifDialog.close() - event.accepted = true - } - } - } - - ScrollView { - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - - TextArea { - id: dataText - text: exifDialog.getDisplayText() - readOnly: true - wrapMode: Text.Wrap - color: exifDialog.textColor - background: null - font.family: "Consolas, monospace" - font.pixelSize: 14 - } - } - - Button { - text: exifDialog.showFull ? "Show Summary" : "Show All" - Layout.alignment: Qt.AlignRight - onClicked: { - exifDialog.showFull = !exifDialog.showFull - } - } - } - - function getDisplayText() { - var data = showFull ? fullData : summaryData - var text = "" - - if (showFull) { - // Sort keys for full view - var keys = Object.keys(data).sort() - for (var i = 0; i < keys.length; i++) { - text += keys[i] + ": " + data[keys[i]] + "\n" - } - } else { - // Specific order for summary - var order = ["Date Taken", "Camera", "Lens", "ISO", "Aperture", "Shutter Speed", "Focal Length", "Flash", "GPS"] - for (var i = 0; i < order.length; i++) { - var key = order[i] - if (data[key]) { - text += key + ": " + data[key] + "\n" - } - } - - // Add any other keys not in the ordered list (if any) - for (var key in data) { - if (order.indexOf(key) === -1) { - text += key + ": " + data[key] + "\n" - } - } - } - - if (text === "") { - return "No EXIF data found." - } - return text - } -} +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Dialog { + id: exifDialog + title: "EXIF Data" + standardButtons: Dialog.Ok + modal: true + closePolicy: Popup.CloseOnEscape + width: 500 + height: 600 + + property var summaryData: ({}) + property var fullData: ({}) + property bool showFull: false + + // Theme properties (can be bound from Main.qml) + property color backgroundColor: "#333333" + property color textColor: "#ffffff" + + background: Rectangle { + color: exifDialog.backgroundColor + border.color: "#555555" + border.width: 1 + } + + onOpened: { + // Reset to summary view when opened + showFull = false + // Notify Python that a dialog is open + if (controller) { + controller.dialog_opened() + } + } + + onClosed: { + if (controller) { + controller.dialog_closed() + } + } + + contentItem: ColumnLayout { + spacing: 10 + + // Keyboard Handling + Item { + Layout.fillWidth: true + Layout.preferredHeight: 0 + focus: true + Keys.onPressed: (event) => { + if (event.key === Qt.Key_I) { + exifDialog.close() + event.accepted = true + } + } + } + + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + TextArea { + id: dataText + text: exifDialog.getDisplayText() + readOnly: true + wrapMode: Text.Wrap + color: exifDialog.textColor + background: null + font.family: "Consolas, monospace" + font.pixelSize: 14 + } + } + + Button { + text: exifDialog.showFull ? "Show Summary" : "Show All" + Layout.alignment: Qt.AlignRight + onClicked: { + exifDialog.showFull = !exifDialog.showFull + } + } + } + + function getDisplayText() { + var data = showFull ? fullData : summaryData + var text = "" + + if (showFull) { + // Sort keys for full view + var keys = Object.keys(data).sort() + for (var i = 0; i < keys.length; i++) { + text += keys[i] + ": " + data[keys[i]] + "\n" + } + } else { + // Specific order for summary + var order = ["Date Taken", "Camera", "Lens", "ISO", "Aperture", "Shutter Speed", "Focal Length", "Flash", "GPS"] + for (var i = 0; i < order.length; i++) { + var key = order[i] + if (data[key]) { + text += key + ": " + data[key] + "\n" + } + } + + // Add any other keys not in the ordered list (if any) + for (var key in data) { + if (order.indexOf(key) === -1) { + text += key + ": " + data[key] + "\n" + } + } + } + + if (text === "") { + return "No EXIF data found." + } + return text + } +} diff --git a/faststack/qml/FilterDialog.qml b/faststack/qml/FilterDialog.qml index a3900af..42218d8 100644 --- a/faststack/qml/FilterDialog.qml +++ b/faststack/qml/FilterDialog.qml @@ -1,93 +1,93 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 - -Dialog { - id: filterDialog - title: "Filter Images" - modal: true - standardButtons: Dialog.Ok | Dialog.Cancel - closePolicy: Popup.CloseOnEscape - width: 500 - height: 250 - - property string filterString: "" - property color backgroundColor: "#1e1e1e" - property color textColor: "white" - - - // Match the app's theme dynamically - // Material.theme: uiState && uiState.theme === 0 ? Material.Dark : Material.Light - - background: Rectangle { - color: filterDialog.backgroundColor - border.color: "#404040" - border.width: 1 - radius: 4 - } - - contentItem: Column { - spacing: 16 - padding: 20 - - Label { - text: "Show only images whose filename contains:" - wrapMode: Text.WordWrap - width: parent.width - parent.padding * 2 - color: filterDialog.textColor - } - - TextField { - id: filterField - placeholderText: "Enter text to filter (e.g., 'stacked', 'IMG_001')..." - width: parent.width - parent.padding * 2 - height: 50 - selectByMouse: true - focus: true - font.pixelSize: 16 - verticalAlignment: TextInput.AlignVCenter - color: filterDialog.textColor - background: Rectangle { - color: Qt.lighter(filterDialog.backgroundColor, 1.2) - border.color: "#505050" - border.width: 1 - radius: 2 - } - - onTextChanged: { - filterDialog.filterString = text - } - - Keys.onReturnPressed: filterDialog.accept() - Keys.onEnterPressed: filterDialog.accept() - } - Label { - text: "Leave empty to show all images." - font.italic: true - opacity: 0.7 - wrapMode: Text.WordWrap - width: parent.width - parent.padding * 2 - color: filterDialog.textColor - } - } - - onOpened: { - // Load current filter string from controller - var current = controller && controller.get_filter_string ? controller.get_filter_string() : "" - filterDialog.filterString = current || "" - filterField.text = filterDialog.filterString - filterField.forceActiveFocus() - filterField.selectAll() - // Notify Python that a dialog is open - if (controller && controller.dialog_opened) { - controller.dialog_opened() - } - } - - onClosed: { - // Notify Python that dialog is closed - if (controller && controller.dialog_closed) { - controller.dialog_closed() - } - } -} +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 + +Dialog { + id: filterDialog + title: "Filter Images" + modal: true + standardButtons: Dialog.Ok | Dialog.Cancel + closePolicy: Popup.CloseOnEscape + width: 500 + height: 250 + + property string filterString: "" + property color backgroundColor: "#1e1e1e" + property color textColor: "white" + + + // Match the app's theme dynamically + // Material.theme: uiState && uiState.theme === 0 ? Material.Dark : Material.Light + + background: Rectangle { + color: filterDialog.backgroundColor + border.color: "#404040" + border.width: 1 + radius: 4 + } + + contentItem: Column { + spacing: 16 + padding: 20 + + Label { + text: "Show only images whose filename contains:" + wrapMode: Text.WordWrap + width: parent.width - parent.padding * 2 + color: filterDialog.textColor + } + + TextField { + id: filterField + placeholderText: "Enter text to filter (e.g., 'stacked', 'IMG_001')..." + width: parent.width - parent.padding * 2 + height: 50 + selectByMouse: true + focus: true + font.pixelSize: 16 + verticalAlignment: TextInput.AlignVCenter + color: filterDialog.textColor + background: Rectangle { + color: Qt.lighter(filterDialog.backgroundColor, 1.2) + border.color: "#505050" + border.width: 1 + radius: 2 + } + + onTextChanged: { + filterDialog.filterString = text + } + + Keys.onReturnPressed: filterDialog.accept() + Keys.onEnterPressed: filterDialog.accept() + } + Label { + text: "Leave empty to show all images." + font.italic: true + opacity: 0.7 + wrapMode: Text.WordWrap + width: parent.width - parent.padding * 2 + color: filterDialog.textColor + } + } + + onOpened: { + // Load current filter string from controller + var current = controller && controller.get_filter_string ? controller.get_filter_string() : "" + filterDialog.filterString = current || "" + filterField.text = filterDialog.filterString + filterField.forceActiveFocus() + filterField.selectAll() + // Notify Python that a dialog is open + if (controller && controller.dialog_opened) { + controller.dialog_opened() + } + } + + onClosed: { + // Notify Python that dialog is closed + if (controller && controller.dialog_closed) { + controller.dialog_closed() + } + } +} diff --git a/faststack/qml/ImageEditorDialog.qml b/faststack/qml/ImageEditorDialog.qml index 92ed2ee..df7c73c 100644 --- a/faststack/qml/ImageEditorDialog.qml +++ b/faststack/qml/ImageEditorDialog.qml @@ -147,7 +147,7 @@ Window { ListElement { name: "Texture"; key: "texture" } ListElement { name: "Sharpness"; key: "sharpness" } } - Repeater { model: effectsModel; delegate: editSlider } + Repeater { model: detailModel; delegate: editSlider } // --- Histogram Group --- RowLayout { @@ -316,7 +316,7 @@ Window { id: effectsModel ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } } - Repeater { model: detailModel; delegate: editSlider } + Repeater { model: effectsModel; delegate: editSlider } Loader { sourceComponent: sectionSeparator } diff --git a/faststack/qml/JumpToImageDialog.qml b/faststack/qml/JumpToImageDialog.qml index 37512fc..b5d5e3d 100644 --- a/faststack/qml/JumpToImageDialog.qml +++ b/faststack/qml/JumpToImageDialog.qml @@ -1,90 +1,90 @@ -import QtQuick -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 -import QtQuick.Layouts 1.15 - -Dialog { - id: jumpDialog - title: "Jump to Image" - standardButtons: Dialog.Ok | Dialog.Cancel - modal: true - closePolicy: Popup.CloseOnEscape - width: 400 - - property int maxImageCount: 0 - property color backgroundColor: "red" // Placeholder, will be set from Main.qml - property color textColor: "white" // Placeholder, will be set from Main.qml - - - // Inherit Material theme from parent - // Material.theme: uiState && uiState.theme === 0 ? Material.Dark : Material.Light - // Material.accent: "#4fb360" - background: Rectangle { - color: jumpDialog.backgroundColor - } - - onOpened: { - imageNumberField.text = "" - imageNumberField.forceActiveFocus() - // Notify Python that a dialog is open - controller.dialog_opened() - } - - onClosed: { - // Notify Python that dialog is closed - controller.dialog_closed() - } - - onAccepted: { - var num = parseInt(imageNumberField.text) - if (!isNaN(num) && num >= 1 && num <= maxImageCount) { - controller.jump_to_image(num - 1) // Convert 1-based to 0-based index - } - } - - contentItem: Item { - implicitWidth: 400 - implicitHeight: 100 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 0 - spacing: 20 - - Label { - text: "Enter image number (1-" + jumpDialog.maxImageCount + "):" - Layout.fillWidth: true - wrapMode: Text.WordWrap - color: jumpDialog.textColor - } - - TextField { - id: imageNumberField - Layout.preferredWidth: 100 - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignLeft - placeholderText: "Number" - font.pixelSize: 16 - horizontalAlignment: TextInput.AlignHCenter - maximumLength: Math.max(1, Math.ceil(Math.log10(jumpDialog.maxImageCount + 1))) - selectByMouse: true - focus: true - validator: IntValidator { - bottom: 1 - top: jumpDialog.maxImageCount - } - color: jumpDialog.textColor - background: Rectangle { - color: jumpDialog.backgroundColor - } - - Keys.onReturnPressed: jumpDialog.accept() - Keys.onEnterPressed: jumpDialog.accept() - } - - Item { - Layout.fillHeight: true - } - } - } -} +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 + +Dialog { + id: jumpDialog + title: "Jump to Image" + standardButtons: Dialog.Ok | Dialog.Cancel + modal: true + closePolicy: Popup.CloseOnEscape + width: 400 + + property int maxImageCount: 0 + property color backgroundColor: "red" // Placeholder, will be set from Main.qml + property color textColor: "white" // Placeholder, will be set from Main.qml + + + // Inherit Material theme from parent + // Material.theme: uiState && uiState.theme === 0 ? Material.Dark : Material.Light + // Material.accent: "#4fb360" + background: Rectangle { + color: jumpDialog.backgroundColor + } + + onOpened: { + imageNumberField.text = "" + imageNumberField.forceActiveFocus() + // Notify Python that a dialog is open + controller.dialog_opened() + } + + onClosed: { + // Notify Python that dialog is closed + controller.dialog_closed() + } + + onAccepted: { + var num = parseInt(imageNumberField.text) + if (!isNaN(num) && num >= 1 && num <= maxImageCount) { + controller.jump_to_image(num - 1) // Convert 1-based to 0-based index + } + } + + contentItem: Item { + implicitWidth: 400 + implicitHeight: 100 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 0 + spacing: 20 + + Label { + text: "Enter image number (1-" + jumpDialog.maxImageCount + "):" + Layout.fillWidth: true + wrapMode: Text.WordWrap + color: jumpDialog.textColor + } + + TextField { + id: imageNumberField + Layout.preferredWidth: 100 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignLeft + placeholderText: "Number" + font.pixelSize: 16 + horizontalAlignment: TextInput.AlignHCenter + maximumLength: Math.max(1, Math.ceil(Math.log10(jumpDialog.maxImageCount + 1))) + selectByMouse: true + focus: true + validator: IntValidator { + bottom: 1 + top: jumpDialog.maxImageCount + } + color: jumpDialog.textColor + background: Rectangle { + color: jumpDialog.backgroundColor + } + + Keys.onReturnPressed: jumpDialog.accept() + Keys.onEnterPressed: jumpDialog.accept() + } + + Item { + Layout.fillHeight: true + } + } + } +} diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index b7684cf..30accdf 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1,1032 +1,1032 @@ -import QtQuick -import QtQuick.Window -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 -import QtQuick.Layouts 1.15 -import "." - -ApplicationWindow { - id: root - visible: true - width: 1200 - height: 800 - minimumWidth: 800 - minimumHeight: 500 - title: "FastStack - " + (uiState ? uiState.currentDirectory : "Loading...") - - Component.onCompleted: { - // Initialization complete - } - - Material.theme: (uiState && uiState.theme === 0) ? Material.Dark : Material.Light - Material.accent: "#4fb360" - - property bool isDarkTheme: uiState ? uiState.theme === 0 : true - property color currentBackgroundColor: isDarkTheme ? "#000000" : "#ffffff" - property color currentTextColor: isDarkTheme ? "white" : "black" - property color hoverColor: isDarkTheme ? Qt.lighter(currentBackgroundColor, 1.5) : Qt.darker(currentBackgroundColor, 1.1) - - - background: Rectangle { color: root.currentBackgroundColor } - - function toggleTheme() { - if (uiState) { - uiState.theme = (uiState.theme === 0 ? 1 : 0) - } - } - - function openExifDialog(data) { - exifDialog.summaryData = data.summary - exifDialog.fullData = data.full - exifDialog.open() - } - - - // -------- FLOATING MENU BAR (overlays content) -------- - Rectangle { - id: floatingMenuBar - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 40 - color: "transparent" - z: 100 // Ensure it's above the content - - // Unified "menu active" flag to avoid flashing - property bool menuActive: menuBarMouseArea.containsMouse - || fileMouseArea.containsMouse - || viewMouseArea.containsMouse - || actionsMouseArea.containsMouse - || helpMouseArea.containsMouse - || fileMenu.visible - || viewMenu.visible - || actionsMenu.visible - || helpMenu.visible - - // Semi-transparent background that appears on hover - Rectangle { - anchors.fill: parent - color: root.isDarkTheme ? "#333333" : "#f0f0f0" - opacity: floatingMenuBar.menuActive ? 0.9 : 0.0 - - Behavior on opacity { - NumberAnimation { duration: 150 } - } - } - - MouseArea { - id: menuBarMouseArea - anchors.fill: parent - hoverEnabled: true - propagateComposedEvents: true - - // Don't block clicks - let them pass through to children - onClicked: function(mouse) { mouse.accepted = false } - onPressed: function(mouse) { mouse.accepted = false } - onReleased: function(mouse) { mouse.accepted = false } - } - - Row { - id: menuButtonRow - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - - // Show whenever any menu is hovered or open - visible: floatingMenuBar.menuActive - - // FILE MENU BUTTON - Rectangle { - id: fileBtn - width: fileLabel.width + 20 - height: 30 - color: fileMouseArea.containsMouse ? hoverColor : "transparent" - radius: 4 - - Text { - id: fileLabel - anchors.centerIn: parent - text: "File" - color: root.currentTextColor - font.pixelSize: 14 - } - - MouseArea { - id: fileMouseArea - anchors.fill: parent - hoverEnabled: true - onClicked: { - var pos = fileBtn.mapToItem(null, 0, fileBtn.height) - fileMenu.popup(pos.x, pos.y) - } - } - } - - // VIEW MENU BUTTON - Rectangle { - id: viewBtn - width: viewLabel.width + 20 - height: 30 - color: viewMouseArea.containsMouse ? hoverColor : "transparent" - radius: 4 - - Text { - id: viewLabel - anchors.centerIn: parent - text: "View" - color: root.currentTextColor - font.pixelSize: 14 - } - - MouseArea { - id: viewMouseArea - anchors.fill: parent - hoverEnabled: true - onClicked: { - var pos = viewBtn.mapToItem(null, 0, viewBtn.height) - viewMenu.popup(pos.x, pos.y) - } - } - } - - // ACTIONS MENU BUTTON - Rectangle { - id: actionsBtn - width: actionsLabel.width + 20 - height: 30 - color: actionsMouseArea.containsMouse ? hoverColor : "transparent" - radius: 4 - - Text { - id: actionsLabel - anchors.centerIn: parent - text: "Actions" - color: root.currentTextColor - font.pixelSize: 14 - } - - MouseArea { - id: actionsMouseArea - anchors.fill: parent - hoverEnabled: true - onClicked: { - var pos = actionsBtn.mapToItem(null, 0, actionsBtn.height) - actionsMenu.popup(pos.x, pos.y) - } - } - } - - // HELP MENU BUTTON - Rectangle { - id: helpBtn - width: helpLabel.width + 20 - height: 30 - color: helpMouseArea.containsMouse ? hoverColor : "transparent" - radius: 4 - - Text { - id: helpLabel - anchors.centerIn: parent - text: "Help" - color: root.currentTextColor - font.pixelSize: 14 - } - - MouseArea { - id: helpMouseArea - anchors.fill: parent - hoverEnabled: true - onClicked: { - var pos = helpBtn.mapToItem(null, 0, helpBtn.height) - helpMenu.popup(pos.x, pos.y) - } - } - } - } - } - - // -------- MENU POPUPS -------- - Menu { - id: fileMenu - parent: Overlay.overlay - implicitWidth: 200 - - background: Rectangle { - implicitWidth: 200 - implicitHeight: fileMenuColumn.implicitHeight - color: root.currentBackgroundColor - border.color: root.isDarkTheme ? "#666666" : "#cccccc" - radius: 4 - } - - contentItem: Column { - id: fileMenuColumn - - ItemDelegate { - width: 200 - height: 36 - text: "Open Folder..." - onClicked: { - if (uiState) { - uiState.open_folder() - } - fileMenu.close() - } - background: Rectangle { - color: parent.hovered ? hoverColor : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - ItemDelegate { - width: 200 - height: 36 - text: "Settings..." - onClicked: { - settingsDialog.open() - fileMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - Rectangle { - width: 200 - height: 1 - color: root.isDarkTheme ? "#666666" : "#cccccc" - } - ItemDelegate { - width: 200 - height: 36 - text: "Exit" - onClicked: Qt.quit() - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - } - } - - Menu { - id: viewMenu - parent: Overlay.overlay - implicitWidth: 220 - - background: Rectangle { - implicitWidth: 220 - implicitHeight: viewMenuColumn.implicitHeight - color: root.currentBackgroundColor - border.color: root.isDarkTheme ? "#666666" : "#cccccc" - radius: 4 - } - - contentItem: Column { - id: viewMenuColumn - - // Toggle theme - ItemDelegate { - width: 220 - height: 36 - text: "Toggle Light/Dark Mode" - onClicked: { - root.toggleTheme() - viewMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - - // Separator - Rectangle { - width: 220 - height: 1 - color: root.isDarkTheme ? "#666666" : "#cccccc" - } - - // Color: None (Original) - ItemDelegate { - width: 220 - height: 36 - text: "Color: None (Original)" - onClicked: { - if (controller) controller.set_color_mode("none") - viewMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.colorMode === "none") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.colorMode === "none" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - - // Color: Saturation Compensation - ItemDelegate { - width: 220 - height: 36 - text: "Color: Saturation Compensation" - onClicked: { - if (controller) controller.set_color_mode("saturation") - viewMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.colorMode === "saturation") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.colorMode === "saturation" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - - // Color: Full ICC Profile - ItemDelegate { - width: 220 - height: 36 - text: "Color: Full ICC Profile" - onClicked: { - if (controller) controller.set_color_mode("icc") - viewMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.colorMode === "icc") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.colorMode === "icc" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - } - } - - Menu { - id: actionsMenu - parent: Overlay.overlay - implicitWidth: 220 - - background: Rectangle { - implicitWidth: 220 - implicitHeight: actionsMenuColumn.implicitHeight - color: root.currentBackgroundColor - border.color: root.isDarkTheme ? "#666666" : "#cccccc" - radius: 4 - } - - contentItem: Column { - id: actionsMenuColumn - - // Develop RAW (True Headroom) - ItemDelegate { - width: 220 - height: 36 - text: (uiState && uiState.hasWorkingTif) ? "Re-develop RAW" : "Develop RAW" - enabled: uiState ? uiState.hasRaw : false - onClicked: { - if (uiState) uiState.developRaw() - actionsMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: enabled ? root.currentTextColor : (root.isDarkTheme ? "#666666" : "#999999") - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - - // Edit Image (from old Main.qml) - ItemDelegate { - width: 220 - height: 36 - text: "Edit Image" - onClicked: { - if (uiState) { - uiState.isEditorOpen = !uiState.isEditorOpen - if (uiState.isEditorOpen && controller) { - controller.load_image_for_editing() - } - } - actionsMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - ItemDelegate { - width: 220 - height: 36 - text: "Crop Image" - onClicked: { - if (controller) { - controller.toggle_crop_mode() - } - actionsMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - - ItemDelegate { - width: 220 - height: 36 - text: "Run Stacks" - onClicked: { if (uiState) uiState.launch_helicon(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - ItemDelegate { - width: 220 - height: 36 - text: "Clear Stacks" - onClicked: { if (uiState) uiState.clear_all_stacks(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - ItemDelegate { - width: 220 - height: 36 - text: "Show Stacks" - onClicked: { showStacksDialog.open(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - ItemDelegate { - width: 220 - height: 36 - text: "Preload All Images" - onClicked: { if (uiState) uiState.preloadAllImages(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - ItemDelegate { - width: 220 - height: 36 - text: "Filter Images..." - onClicked: { filterDialog.open(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - - // Clear Filename Filter (from old Main.qml) - ItemDelegate { - width: 220 - height: 36 - text: "Clear Filename Filter" - onClicked: { - if (controller) controller.clear_filter() - actionsMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - ItemDelegate { - width: 220 - height: 36 - text: "Stack Source RAWs" - enabled: uiState ? uiState.isStackedJpg : false - onClicked: { - if (uiState) uiState.stack_source_raws(); - actionsMenu.close() - } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - } - } - - Menu { - id: helpMenu - parent: Overlay.overlay - implicitWidth: 200 - - background: Rectangle { - implicitWidth: 200 - implicitHeight: helpMenuColumn.implicitHeight - color: root.currentBackgroundColor - border.color: root.isDarkTheme ? "#666666" : "#cccccc" - radius: 4 - } - - contentItem: Column { - id: helpMenuColumn - - ItemDelegate { - width: 200 - height: 36 - text: "Key Bindings" - onClicked: { aboutDialog.open(); helpMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } - } - } - } - - property int footerHeight: 60 - - Shortcut { - sequence: "E" - context: Qt.ApplicationShortcut - enabled: uiState ? !uiState.isDialogOpen : true - onActivated: { - if (!uiState) return - - if (uiState.isEditorOpen) { - uiState.isEditorOpen = false - } else { - uiState.isEditorOpen = true - if (controller) { - controller.load_image_for_editing() - } - } - } - } - - // -------- MAIN VIEW -------- - Item { - id: contentArea - anchors.fill: parent - - Loader { - id: mainViewLoader - anchors.fill: parent - source: "Components.qml" - focus: true - onLoaded: item.footerHeight = Qt.binding(function() { return root.footerHeight }) - - // Key bindings implemented in old Main.qml - Keys.onPressed: function(event) { - if (!uiState || !controller) { - return - } - - // Global Key for saving edited image (Ctrl+S) when editor is open - if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { - if (uiState.isEditorOpen) { - controller.save_edited_image() - event.accepted = true - } - } - } - } - - // -------- STATUS BAR OVERLAY -------- - Rectangle { - z: 100 - anchors.bottom: parent.bottom - id: footerRect - // Keep footer height fixed so the main image area doesn't change size when - // stack/batch labels appear or disappear (prevents cache invalidations). - height: root.footerHeight - implicitHeight: root.footerHeight - anchors.left: parent.left - anchors.right: parent.right - color: Qt.rgba(root.currentBackgroundColor.r, root.currentBackgroundColor.g, root.currentBackgroundColor.b, 0.8) - clip: true - - RowLayout { - id: footerRow - spacing: 10 - anchors.verticalCenter: parent.verticalCenter - - Label { - Layout.leftMargin: 10 - text: uiState ? `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` : "Image: - / -" - color: root.currentTextColor - } - Label { - text: (uiState && uiState.imageCount > 0) - ? ` | File: ${uiState.currentFilename || 'N/A'}` - : " | File: N/A" - color: root.currentTextColor - } - Label { - text: uiState ? ` | Stacked: ${uiState.stackedDate}` : "" - color: "lightgreen" - visible: uiState ? (uiState.imageCount > 0 && uiState.isStacked) : false - } - Label { - text: uiState ? ` | Uploaded on ${uiState.uploadedDate}` : "" - color: "lightgreen" - visible: uiState ? (uiState.imageCount > 0 && uiState.isUploaded) : false - } - Label { - text: uiState ? ` | Edited on ${uiState.editedDate}` : "" - color: "lightgreen" - visible: uiState ? (uiState.imageCount > 0 && uiState.isEdited) : false - } - Label { - text: uiState ? ` | Restacked on ${uiState.restackedDate}` : "" - color: "cyan" - visible: uiState ? (uiState.imageCount > 0 && uiState.isRestacked) : false - } - Label { - text: uiState ? ` | Filter: "${uiState.filterString}"` : "" - color: "yellow" - font.bold: true - visible: uiState ? (uiState.filterString !== "") : false - } - Rectangle { - visible: uiState ? uiState.isPreloading : false - Layout.preferredWidth: 200 - height: 10 // give it some height - color: "gray" - border.color: "red" - border.width: 1 - - Rectangle { - color: "lightblue" - width: parent.width * (uiState ? uiState.preloadProgress / 100 : 0) - height: parent.height - } - } - Rectangle { - color: (uiState && uiState.imageCount > 0 && uiState.stackInfoText) ? "orange" : "transparent" - radius: 3 - implicitWidth: stackInfoLabel.implicitWidth + 10 - implicitHeight: stackInfoLabel.implicitHeight + 5 - visible: uiState ? (uiState.imageCount > 0 && uiState.stackInfoText) : false - - Label { - id: stackInfoLabel - anchors.centerIn: parent - text: uiState ? `Stack: ${uiState.stackInfoText}` : "" - color: "black" - font.bold: true - font.pixelSize: 16 - } - } - Rectangle { - color: (uiState && uiState.imageCount > 0 && uiState.batchInfoText) ? "#4fb360" : "transparent" - radius: 3 - implicitWidth: batchInfoLabel.implicitWidth + 10 - implicitHeight: batchInfoLabel.implicitHeight + 5 - visible: uiState ? (uiState.imageCount > 0 && uiState.batchInfoText) : false - - Label { - id: batchInfoLabel - anchors.centerIn: parent - text: uiState ? `Batch: ${uiState.batchInfoText}` : "" - color: "white" - font.bold: true - font.pixelSize: 16 - } - } - Rectangle { - Layout.fillWidth: true - color: "transparent" - } - - Label { - text: uiState ? uiState.cacheStats : "" - color: "#00FFFF" // Cyan - font.family: "Monospace" - visible: uiState ? uiState.debugCache : false - Layout.rightMargin: 10 - } - - - // Saturation slider (only visible in saturation mode) - Row { - visible: uiState && uiState.colorMode === "saturation" - spacing: 5 - Layout.rightMargin: 10 - - Label { - text: "Saturation:" - color: root.currentTextColor - anchors.verticalCenter: parent.verticalCenter - } - - Slider { - id: saturationSlider - from: 0.0 - to: 1.0 - value: uiState ? uiState.saturationFactor : 1.0 - stepSize: 0.01 - width: 150 - - onMoved: { - if (controller) controller.set_saturation_factor(value) - } - } - - Label { - text: Math.round(saturationSlider.value * 100) + "%" - color: root.currentTextColor - anchors.verticalCenter: parent.verticalCenter - Layout.preferredWidth: 40 - } - } - - Label { - id: statusMessageLabel - text: uiState ? uiState.statusMessage : "" - color: root.currentTextColor - visible: uiState ? (uiState.statusMessage !== "") : false - Layout.rightMargin: 10 - } - } - } - } - - // -------- DIALOGS -------- - - // Old, more robust About dialog - Dialog { - id: aboutDialog - title: "Key Bindings" - standardButtons: Dialog.Ok - modal: true - closePolicy: Popup.CloseOnEscape - focus: true - width: 1000 - height: 750 - - background: Rectangle { - color: root.currentBackgroundColor - } - - contentItem: ScrollView { - clip: true - - Row { - spacing: 20 - - // Column 1 - Text { - width: 450 - text: "FastStack Keyboard and Mouse Commands

" + - "Navigation:
" + - "  J / Right Arrow: Next Image
" + - "  K / Left Arrow: Previous Image
" + - "  G: Jump to Image Number
" + - "  I: Show EXIF Data

" + - "Viewing:
" + - "  Mouse Wheel: Zoom in/out
" + - "  Left-click + Drag: Pan image
" + - "  Ctrl+0: Reset zoom and pan to fit window
" + - "  Ctrl+1: Zoom to 100%
" + - "  Ctrl+2: Zoom to 200%
" + - "  Ctrl+3: Zoom to 300%
" + - "  Ctrl+4: Zoom to 400%

" + - "Stacking:
" + - "  [: Begin new stack
" + - "  ]: End current stack
" + - "  C: Clear all stacks
" + - "  S: Toggle current image in/out of stack
" + - "  X: Remove current image from batch/stack" - padding: 10 - wrapMode: Text.WordWrap - color: root.currentTextColor - } - - // Column 2 - Text { - width: 450 - text: "

" + // Spacer to align with first section under title - "Batch Selection (for drag-and-drop):
" + - "  {: Begin new batch
" + - "  B: Toggle current image in/out of batch
" + - "  }: End current batch
" + - "  \\: Clear all batches

" + - "Flag Toggles:
" + - "  U: Toggle uploaded flag
" + - "  Ctrl+E: Toggle edited flag
" + - "  Ctrl+S: Toggle stacked flag

" + - "File Management:
" + - "  Delete: Move current image to recycle bin
" + - "  Ctrl+Z: Undo last action (delete, auto white balance, or crop)

" + - "Actions:
" + - "  Enter: Launch Helicon Focus
" + - "  P: Edit in Photoshop
" + - "  Backspace/Del: Move current image to recycle bin
" + - "  A: Quick auto white balance (saves automatically)
" + - "  L: Quick auto levels (saves automatically)
" + - "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + - "  O (or right mouse click): Toggle crop mode (Enter to execute, ESC to cancel)
" + - "  H: Toggle histogram window
" + - "  E: Toggle Image Editor (closes without saving if open)
" + - "  Ctrl+C: Copy image path to clipboard
" + - "  Esc: Close active dialog, editor, or cancel crop" - padding: 10 - wrapMode: Text.WordWrap - color: root.currentTextColor - } - } - } - } - - Dialog { - id: showStacksDialog - title: "Stack Information" - standardButtons: Dialog.Ok - modal: true - closePolicy: Popup.CloseOnEscape - focus: true - width: 400 - height: 300 - - background: Rectangle { - color: root.currentBackgroundColor - } - - contentItem: Text { - text: (uiState && uiState.stackSummary) ? uiState.stackSummary : "No stacks defined." - padding: 10 - wrapMode: Text.WordWrap - color: root.currentTextColor - } - } - - SettingsDialog { - id: settingsDialog - } - - FilterDialog { - id: filterDialog - backgroundColor: root.currentBackgroundColor - textColor: root.currentTextColor - onAccepted: { - if (uiState) uiState.applyFilter(filterString) - } - } - - JumpToImageDialog { - id: jumpToImageDialog - backgroundColor: root.currentBackgroundColor - textColor: root.currentTextColor - maxImageCount: uiState ? uiState.imageCount : 0 - } - - DeleteBatchDialog { - id: deleteBatchDialog - backgroundColor: root.currentBackgroundColor - textColor: root.currentTextColor - } - - HistogramWindow { - id: histogramWindow - windowBackgroundColor: root.currentBackgroundColor - primaryTextColor: root.currentTextColor - gridLineColor: root.isDarkTheme ? "#454545" : "#dcdcdc" - } - - ImageEditorDialog { - id: imageEditorDialog - backgroundColor: root.currentBackgroundColor - textColor: root.currentTextColor - onVisibleChanged: { - if (!visible) { - mainViewLoader.forceActiveFocus() - } - } - } - - function show_jump_to_image_dialog() { - jumpToImageDialog.open() - } - - function show_delete_batch_dialog(count) { - deleteBatchDialog.batchCount = count - deleteBatchDialog.open() - } - - ExifDialog { - id: exifDialog - backgroundColor: root.currentBackgroundColor - textColor: root.currentTextColor - } - - // Debug Cache Indicator (Yellow Square) - Rectangle { - id: debugIndicator - width: 30 - height: 30 - color: "yellow" - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.margins: 20 - z: 9999 // Ensure it is on top of everything, including footer - visible: uiState ? (uiState.debugCache && uiState.isDecoding) : false - - Text { - anchors.centerIn: parent - text: "D" - font.bold: true - color: "black" - } - } -} +import QtQuick +import QtQuick.Window +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import "." + +ApplicationWindow { + id: root + visible: true + width: 1200 + height: 800 + minimumWidth: 800 + minimumHeight: 500 + title: "FastStack - " + (uiState ? uiState.currentDirectory : "Loading...") + + Component.onCompleted: { + // Initialization complete + } + + Material.theme: (uiState && uiState.theme === 0) ? Material.Dark : Material.Light + Material.accent: "#4fb360" + + property bool isDarkTheme: uiState ? uiState.theme === 0 : true + property color currentBackgroundColor: isDarkTheme ? "#000000" : "#ffffff" + property color currentTextColor: isDarkTheme ? "white" : "black" + property color hoverColor: isDarkTheme ? Qt.lighter(currentBackgroundColor, 1.5) : Qt.darker(currentBackgroundColor, 1.1) + + + background: Rectangle { color: root.currentBackgroundColor } + + function toggleTheme() { + if (uiState) { + uiState.theme = (uiState.theme === 0 ? 1 : 0) + } + } + + function openExifDialog(data) { + exifDialog.summaryData = data.summary + exifDialog.fullData = data.full + exifDialog.open() + } + + + // -------- FLOATING MENU BAR (overlays content) -------- + Rectangle { + id: floatingMenuBar + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 40 + color: "transparent" + z: 100 // Ensure it's above the content + + // Unified "menu active" flag to avoid flashing + property bool menuActive: menuBarMouseArea.containsMouse + || fileMouseArea.containsMouse + || viewMouseArea.containsMouse + || actionsMouseArea.containsMouse + || helpMouseArea.containsMouse + || fileMenu.visible + || viewMenu.visible + || actionsMenu.visible + || helpMenu.visible + + // Semi-transparent background that appears on hover + Rectangle { + anchors.fill: parent + color: root.isDarkTheme ? "#333333" : "#f0f0f0" + opacity: floatingMenuBar.menuActive ? 0.9 : 0.0 + + Behavior on opacity { + NumberAnimation { duration: 150 } + } + } + + MouseArea { + id: menuBarMouseArea + anchors.fill: parent + hoverEnabled: true + propagateComposedEvents: true + + // Don't block clicks - let them pass through to children + onClicked: function(mouse) { mouse.accepted = false } + onPressed: function(mouse) { mouse.accepted = false } + onReleased: function(mouse) { mouse.accepted = false } + } + + Row { + id: menuButtonRow + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + // Show whenever any menu is hovered or open + visible: floatingMenuBar.menuActive + + // FILE MENU BUTTON + Rectangle { + id: fileBtn + width: fileLabel.width + 20 + height: 30 + color: fileMouseArea.containsMouse ? hoverColor : "transparent" + radius: 4 + + Text { + id: fileLabel + anchors.centerIn: parent + text: "File" + color: root.currentTextColor + font.pixelSize: 14 + } + + MouseArea { + id: fileMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + var pos = fileBtn.mapToItem(null, 0, fileBtn.height) + fileMenu.popup(pos.x, pos.y) + } + } + } + + // VIEW MENU BUTTON + Rectangle { + id: viewBtn + width: viewLabel.width + 20 + height: 30 + color: viewMouseArea.containsMouse ? hoverColor : "transparent" + radius: 4 + + Text { + id: viewLabel + anchors.centerIn: parent + text: "View" + color: root.currentTextColor + font.pixelSize: 14 + } + + MouseArea { + id: viewMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + var pos = viewBtn.mapToItem(null, 0, viewBtn.height) + viewMenu.popup(pos.x, pos.y) + } + } + } + + // ACTIONS MENU BUTTON + Rectangle { + id: actionsBtn + width: actionsLabel.width + 20 + height: 30 + color: actionsMouseArea.containsMouse ? hoverColor : "transparent" + radius: 4 + + Text { + id: actionsLabel + anchors.centerIn: parent + text: "Actions" + color: root.currentTextColor + font.pixelSize: 14 + } + + MouseArea { + id: actionsMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + var pos = actionsBtn.mapToItem(null, 0, actionsBtn.height) + actionsMenu.popup(pos.x, pos.y) + } + } + } + + // HELP MENU BUTTON + Rectangle { + id: helpBtn + width: helpLabel.width + 20 + height: 30 + color: helpMouseArea.containsMouse ? hoverColor : "transparent" + radius: 4 + + Text { + id: helpLabel + anchors.centerIn: parent + text: "Help" + color: root.currentTextColor + font.pixelSize: 14 + } + + MouseArea { + id: helpMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + var pos = helpBtn.mapToItem(null, 0, helpBtn.height) + helpMenu.popup(pos.x, pos.y) + } + } + } + } + } + + // -------- MENU POPUPS -------- + Menu { + id: fileMenu + parent: Overlay.overlay + implicitWidth: 200 + + background: Rectangle { + implicitWidth: 200 + implicitHeight: fileMenuColumn.implicitHeight + color: root.currentBackgroundColor + border.color: root.isDarkTheme ? "#666666" : "#cccccc" + radius: 4 + } + + contentItem: Column { + id: fileMenuColumn + + ItemDelegate { + width: 200 + height: 36 + text: "Open Folder..." + onClicked: { + if (uiState) { + uiState.open_folder() + } + fileMenu.close() + } + background: Rectangle { + color: parent.hovered ? hoverColor : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 200 + height: 36 + text: "Settings..." + onClicked: { + settingsDialog.open() + fileMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + Rectangle { + width: 200 + height: 1 + color: root.isDarkTheme ? "#666666" : "#cccccc" + } + ItemDelegate { + width: 200 + height: 36 + text: "Exit" + onClicked: Qt.quit() + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + } + } + + Menu { + id: viewMenu + parent: Overlay.overlay + implicitWidth: 220 + + background: Rectangle { + implicitWidth: 220 + implicitHeight: viewMenuColumn.implicitHeight + color: root.currentBackgroundColor + border.color: root.isDarkTheme ? "#666666" : "#cccccc" + radius: 4 + } + + contentItem: Column { + id: viewMenuColumn + + // Toggle theme + ItemDelegate { + width: 220 + height: 36 + text: "Toggle Light/Dark Mode" + onClicked: { + root.toggleTheme() + viewMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + + // Separator + Rectangle { + width: 220 + height: 1 + color: root.isDarkTheme ? "#666666" : "#cccccc" + } + + // Color: None (Original) + ItemDelegate { + width: 220 + height: 36 + text: "Color: None (Original)" + onClicked: { + if (controller) controller.set_color_mode("none") + viewMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") + : ((uiState && uiState.colorMode === "none") + ? (root.isDarkTheme ? "#505050" : "#d0ffd0") + : "transparent") + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + font.bold: uiState && uiState.colorMode === "none" + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + + // Color: Saturation Compensation + ItemDelegate { + width: 220 + height: 36 + text: "Color: Saturation Compensation" + onClicked: { + if (controller) controller.set_color_mode("saturation") + viewMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") + : ((uiState && uiState.colorMode === "saturation") + ? (root.isDarkTheme ? "#505050" : "#d0ffd0") + : "transparent") + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + font.bold: uiState && uiState.colorMode === "saturation" + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + + // Color: Full ICC Profile + ItemDelegate { + width: 220 + height: 36 + text: "Color: Full ICC Profile" + onClicked: { + if (controller) controller.set_color_mode("icc") + viewMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") + : ((uiState && uiState.colorMode === "icc") + ? (root.isDarkTheme ? "#505050" : "#d0ffd0") + : "transparent") + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + font.bold: uiState && uiState.colorMode === "icc" + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + } + } + + Menu { + id: actionsMenu + parent: Overlay.overlay + implicitWidth: 220 + + background: Rectangle { + implicitWidth: 220 + implicitHeight: actionsMenuColumn.implicitHeight + color: root.currentBackgroundColor + border.color: root.isDarkTheme ? "#666666" : "#cccccc" + radius: 4 + } + + contentItem: Column { + id: actionsMenuColumn + + // Develop RAW (True Headroom) + ItemDelegate { + width: 220 + height: 36 + text: (uiState && uiState.hasWorkingTif) ? "Re-develop RAW" : "Develop RAW" + enabled: uiState ? uiState.hasRaw : false + onClicked: { + if (uiState) uiState.developRaw() + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: enabled ? root.currentTextColor : (root.isDarkTheme ? "#666666" : "#999999") + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + + // Edit Image (from old Main.qml) + ItemDelegate { + width: 220 + height: 36 + text: "Edit Image" + onClicked: { + if (uiState) { + uiState.isEditorOpen = !uiState.isEditorOpen + if (uiState.isEditorOpen && controller) { + controller.load_image_for_editing() + } + } + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Crop Image" + onClicked: { + if (controller) { + controller.toggle_crop_mode() + } + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + + ItemDelegate { + width: 220 + height: 36 + text: "Run Stacks" + onClicked: { if (uiState) uiState.launch_helicon(); actionsMenu.close() } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Clear Stacks" + onClicked: { if (uiState) uiState.clear_all_stacks(); actionsMenu.close() } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Show Stacks" + onClicked: { showStacksDialog.open(); actionsMenu.close() } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Preload All Images" + onClicked: { if (uiState) uiState.preloadAllImages(); actionsMenu.close() } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Filter Images..." + onClicked: { filterDialog.open(); actionsMenu.close() } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + + // Clear Filename Filter (from old Main.qml) + ItemDelegate { + width: 220 + height: 36 + text: "Clear Filename Filter" + onClicked: { + if (controller) controller.clear_filter() + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + ItemDelegate { + width: 220 + height: 36 + text: "Stack Source RAWs" + enabled: uiState ? uiState.isStackedJpg : false + onClicked: { + if (uiState) uiState.stack_source_raws(); + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + } + } + + Menu { + id: helpMenu + parent: Overlay.overlay + implicitWidth: 200 + + background: Rectangle { + implicitWidth: 200 + implicitHeight: helpMenuColumn.implicitHeight + color: root.currentBackgroundColor + border.color: root.isDarkTheme ? "#666666" : "#cccccc" + radius: 4 + } + + contentItem: Column { + id: helpMenuColumn + + ItemDelegate { + width: 200 + height: 36 + text: "Key Bindings" + onClicked: { aboutDialog.open(); helpMenu.close() } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } + } + } + + property int footerHeight: 60 + + Shortcut { + sequence: "E" + context: Qt.ApplicationShortcut + enabled: uiState ? !uiState.isDialogOpen : true + onActivated: { + if (!uiState) return + + if (uiState.isEditorOpen) { + uiState.isEditorOpen = false + } else { + uiState.isEditorOpen = true + if (controller) { + controller.load_image_for_editing() + } + } + } + } + + // -------- MAIN VIEW -------- + Item { + id: contentArea + anchors.fill: parent + + Loader { + id: mainViewLoader + anchors.fill: parent + source: "Components.qml" + focus: true + onLoaded: item.footerHeight = Qt.binding(function() { return root.footerHeight }) + + // Key bindings implemented in old Main.qml + Keys.onPressed: function(event) { + if (!uiState || !controller) { + return + } + + // Global Key for saving edited image (Ctrl+S) when editor is open + if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { + if (uiState.isEditorOpen) { + controller.save_edited_image() + event.accepted = true + } + } + } + } + + // -------- STATUS BAR OVERLAY -------- + Rectangle { + z: 100 + anchors.bottom: parent.bottom + id: footerRect + // Keep footer height fixed so the main image area doesn't change size when + // stack/batch labels appear or disappear (prevents cache invalidations). + height: root.footerHeight + implicitHeight: root.footerHeight + anchors.left: parent.left + anchors.right: parent.right + color: Qt.rgba(root.currentBackgroundColor.r, root.currentBackgroundColor.g, root.currentBackgroundColor.b, 0.8) + clip: true + + RowLayout { + id: footerRow + spacing: 10 + anchors.verticalCenter: parent.verticalCenter + + Label { + Layout.leftMargin: 10 + text: uiState ? `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` : "Image: - / -" + color: root.currentTextColor + } + Label { + text: (uiState && uiState.imageCount > 0) + ? ` | File: ${uiState.currentFilename || 'N/A'}` + : " | File: N/A" + color: root.currentTextColor + } + Label { + text: uiState ? ` | Stacked: ${uiState.stackedDate}` : "" + color: "lightgreen" + visible: uiState ? (uiState.imageCount > 0 && uiState.isStacked) : false + } + Label { + text: uiState ? ` | Uploaded on ${uiState.uploadedDate}` : "" + color: "lightgreen" + visible: uiState ? (uiState.imageCount > 0 && uiState.isUploaded) : false + } + Label { + text: uiState ? ` | Edited on ${uiState.editedDate}` : "" + color: "lightgreen" + visible: uiState ? (uiState.imageCount > 0 && uiState.isEdited) : false + } + Label { + text: uiState ? ` | Restacked on ${uiState.restackedDate}` : "" + color: "cyan" + visible: uiState ? (uiState.imageCount > 0 && uiState.isRestacked) : false + } + Label { + text: uiState ? ` | Filter: "${uiState.filterString}"` : "" + color: "yellow" + font.bold: true + visible: uiState ? (uiState.filterString !== "") : false + } + Rectangle { + visible: uiState ? uiState.isPreloading : false + Layout.preferredWidth: 200 + height: 10 // give it some height + color: "gray" + border.color: "red" + border.width: 1 + + Rectangle { + color: "lightblue" + width: parent.width * (uiState ? uiState.preloadProgress / 100 : 0) + height: parent.height + } + } + Rectangle { + color: (uiState && uiState.imageCount > 0 && uiState.stackInfoText) ? "orange" : "transparent" + radius: 3 + implicitWidth: stackInfoLabel.implicitWidth + 10 + implicitHeight: stackInfoLabel.implicitHeight + 5 + visible: uiState ? (uiState.imageCount > 0 && uiState.stackInfoText) : false + + Label { + id: stackInfoLabel + anchors.centerIn: parent + text: uiState ? `Stack: ${uiState.stackInfoText}` : "" + color: "black" + font.bold: true + font.pixelSize: 16 + } + } + Rectangle { + color: (uiState && uiState.imageCount > 0 && uiState.batchInfoText) ? "#4fb360" : "transparent" + radius: 3 + implicitWidth: batchInfoLabel.implicitWidth + 10 + implicitHeight: batchInfoLabel.implicitHeight + 5 + visible: uiState ? (uiState.imageCount > 0 && uiState.batchInfoText) : false + + Label { + id: batchInfoLabel + anchors.centerIn: parent + text: uiState ? `Batch: ${uiState.batchInfoText}` : "" + color: "white" + font.bold: true + font.pixelSize: 16 + } + } + Rectangle { + Layout.fillWidth: true + color: "transparent" + } + + Label { + text: uiState ? uiState.cacheStats : "" + color: "#00FFFF" // Cyan + font.family: "Monospace" + visible: uiState ? uiState.debugCache : false + Layout.rightMargin: 10 + } + + + // Saturation slider (only visible in saturation mode) + Row { + visible: uiState && uiState.colorMode === "saturation" + spacing: 5 + Layout.rightMargin: 10 + + Label { + text: "Saturation:" + color: root.currentTextColor + anchors.verticalCenter: parent.verticalCenter + } + + Slider { + id: saturationSlider + from: 0.0 + to: 1.0 + value: uiState ? uiState.saturationFactor : 1.0 + stepSize: 0.01 + width: 150 + + onMoved: { + if (controller) controller.set_saturation_factor(value) + } + } + + Label { + text: Math.round(saturationSlider.value * 100) + "%" + color: root.currentTextColor + anchors.verticalCenter: parent.verticalCenter + Layout.preferredWidth: 40 + } + } + + Label { + id: statusMessageLabel + text: uiState ? uiState.statusMessage : "" + color: root.currentTextColor + visible: uiState ? (uiState.statusMessage !== "") : false + Layout.rightMargin: 10 + } + } + } + } + + // -------- DIALOGS -------- + + // Old, more robust About dialog + Dialog { + id: aboutDialog + title: "Key Bindings" + standardButtons: Dialog.Ok + modal: true + closePolicy: Popup.CloseOnEscape + focus: true + width: 1000 + height: 750 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: ScrollView { + clip: true + + Row { + spacing: 20 + + // Column 1 + Text { + width: 450 + text: "FastStack Keyboard and Mouse Commands

" + + "Navigation:
" + + "  J / Right Arrow: Next Image
" + + "  K / Left Arrow: Previous Image
" + + "  G: Jump to Image Number
" + + "  I: Show EXIF Data

" + + "Viewing:
" + + "  Mouse Wheel: Zoom in/out
" + + "  Left-click + Drag: Pan image
" + + "  Ctrl+0: Reset zoom and pan to fit window
" + + "  Ctrl+1: Zoom to 100%
" + + "  Ctrl+2: Zoom to 200%
" + + "  Ctrl+3: Zoom to 300%
" + + "  Ctrl+4: Zoom to 400%

" + + "Stacking:
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks
" + + "  S: Toggle current image in/out of stack
" + + "  X: Remove current image from batch/stack" + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + + // Column 2 + Text { + width: 450 + text: "

" + // Spacer to align with first section under title + "Batch Selection (for drag-and-drop):
" + + "  {: Begin new batch
" + + "  B: Toggle current image in/out of batch
" + + "  }: End current batch
" + + "  \\: Clear all batches

" + + "Flag Toggles:
" + + "  U: Toggle uploaded flag
" + + "  Ctrl+E: Toggle edited flag
" + + "  Ctrl+S: Toggle stacked flag

" + + "File Management:
" + + "  Delete: Move current image to recycle bin
" + + "  Ctrl+Z: Undo last action (delete, auto white balance, or crop)

" + + "Actions:
" + + "  Enter: Launch Helicon Focus
" + + "  P: Edit in Photoshop
" + + "  Backspace/Del: Move current image to recycle bin
" + + "  A: Quick auto white balance (saves automatically)
" + + "  L: Quick auto levels (saves automatically)
" + + "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + + "  O (or right mouse click): Toggle crop mode (Enter to execute, ESC to cancel)
" + + "  H: Toggle histogram window
" + + "  E: Toggle Image Editor (closes without saving if open)
" + + "  Ctrl+C: Copy image path to clipboard
" + + "  Esc: Close active dialog, editor, or cancel crop" + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + } + } + } + + Dialog { + id: showStacksDialog + title: "Stack Information" + standardButtons: Dialog.Ok + modal: true + closePolicy: Popup.CloseOnEscape + focus: true + width: 400 + height: 300 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: Text { + text: (uiState && uiState.stackSummary) ? uiState.stackSummary : "No stacks defined." + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + } + + SettingsDialog { + id: settingsDialog + } + + FilterDialog { + id: filterDialog + backgroundColor: root.currentBackgroundColor + textColor: root.currentTextColor + onAccepted: { + if (uiState) uiState.applyFilter(filterString) + } + } + + JumpToImageDialog { + id: jumpToImageDialog + backgroundColor: root.currentBackgroundColor + textColor: root.currentTextColor + maxImageCount: uiState ? uiState.imageCount : 0 + } + + DeleteBatchDialog { + id: deleteBatchDialog + backgroundColor: root.currentBackgroundColor + textColor: root.currentTextColor + } + + HistogramWindow { + id: histogramWindow + windowBackgroundColor: root.currentBackgroundColor + primaryTextColor: root.currentTextColor + gridLineColor: root.isDarkTheme ? "#454545" : "#dcdcdc" + } + + ImageEditorDialog { + id: imageEditorDialog + backgroundColor: root.currentBackgroundColor + textColor: root.currentTextColor + onVisibleChanged: { + if (!visible) { + mainViewLoader.forceActiveFocus() + } + } + } + + function show_jump_to_image_dialog() { + jumpToImageDialog.open() + } + + function show_delete_batch_dialog(count) { + deleteBatchDialog.batchCount = count + deleteBatchDialog.open() + } + + ExifDialog { + id: exifDialog + backgroundColor: root.currentBackgroundColor + textColor: root.currentTextColor + } + + // Debug Cache Indicator (Yellow Square) + Rectangle { + id: debugIndicator + width: 30 + height: 30 + color: "yellow" + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 20 + z: 9999 // Ensure it is on top of everything, including footer + visible: uiState ? (uiState.debugCache && uiState.isDecoding) : false + + Text { + anchors.centerIn: parent + text: "D" + font.bold: true + color: "black" + } + } +} diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml index b3ad33b..2f78a1a 100644 --- a/faststack/qml/SettingsDialog.qml +++ b/faststack/qml/SettingsDialog.qml @@ -1,1086 +1,1086 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 -import QtQuick.Layouts 1.15 -import QtQuick.Window 2.15 - -Window { - id: settingsDialog - title: "Settings" - width: 700 - height: 800 - visible: false - flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint - modality: Qt.ApplicationModal - - // Make window close button (X) behave like Cancel - onClosing: function(close) { - close.accepted = false - visible = false - } - - // Properties matching the original dialog - property string heliconPath: "" - property double cacheSize: 1.5 - property double autoLevelClippingThreshold: 0.1 - property double autoLevelStrength: 1.0 - property bool autoLevelStrengthAuto: false - property int prefetchRadius: 4 - property int theme: 0 - property string defaultDirectory: "" - property string photoshopPath: "" - property string rawtherapeePath: "" - property string optimizeFor: "speed" - - property string awbMode: "lab" - property double awbStrength: 0.7 - property int awbWarmBias: 6 - property int awbTintBias: 0 - - property int awbLumaLowerBound: 30 - property int awbLumaUpperBound: 220 - property int awbRgbLowerBound: 5 - property int awbRgbUpperBound: 250 - - // Live cache usage value (updated by timer) - property real cacheUsage: 0.0 - - // Modern Color Palette (copied from ImageEditorDialog) - property color backgroundColor: "#1e1e1e" - property color textColor: "white" - readonly property color accentColor: "#6366f1" // Modern Indigo - readonly property color accentColorHover: "#818cf8" - readonly property color accentColorSubtle: "#306366f1" - readonly property color controlBg: "#10ffffff" - readonly property color controlBorder: "#30ffffff" - readonly property color separatorColor: "#20ffffff" - - Material.theme: Material.Dark - Material.accent: accentColor - color: backgroundColor - - // Helper to open the dialog - function open() { - // Reload all properties from uiState to ensure Cancel discards edits - if (uiState) { - heliconPath = uiState.get_helicon_path() - photoshopPath = uiState.get_photoshop_path() - rawtherapeePath = uiState.get_rawtherapee_path() - cacheSize = uiState.get_cache_size() - prefetchRadius = uiState.get_prefetch_radius() - theme = uiState.theme - defaultDirectory = uiState.get_default_directory() - optimizeFor = uiState.get_optimize_for() - autoLevelClippingThreshold = uiState.autoLevelClippingThreshold - autoLevelStrength = uiState.autoLevelStrength - autoLevelStrengthAuto = uiState.autoLevelStrengthAuto - awbMode = uiState.awbMode - awbStrength = uiState.awbStrength - awbWarmBias = uiState.awbWarmBias - awbTintBias = uiState.awbTintBias - awbLumaLowerBound = uiState.awbLumaLowerBound - awbLumaUpperBound = uiState.awbLumaUpperBound - awbRgbLowerBound = uiState.awbRgbLowerBound - awbRgbUpperBound = uiState.awbRgbUpperBound - } - visible = true - raise() - requestActivate() - } - - Shortcut { - sequence: "Escape" - context: Qt.WindowShortcut - onActivated: visible = false - } - - onVisibleChanged: { - cacheUsageTimer.running = visible - if (visible) { - controller.dialog_opened() - // Reset all text fields from properties - if (heliconField.item) heliconField.item.text = settingsDialog.heliconPath - if (photoshopField.item) photoshopField.item.text = settingsDialog.photoshopPath - if (rawtherapeeField.item) rawtherapeeField.item.text = settingsDialog.rawtherapeePath - if (defaultDirField.item) defaultDirField.item.text = settingsDialog.defaultDirectory - if (cacheSizeField.item) cacheSizeField.item.text = settingsDialog.cacheSize.toFixed(1) - // Note: ComboBoxes and SpinBoxes update automatically via bindings/connections - } else { - controller.dialog_closed() - } - } - - function saveSettings() { - uiState.set_helicon_path(heliconPath) - uiState.set_photoshop_path(photoshopPath) - uiState.set_rawtherapee_path(rawtherapeePath) - uiState.set_cache_size(cacheSize) - uiState.set_prefetch_radius(prefetchRadius) - uiState.set_theme(theme) - uiState.set_default_directory(defaultDirectory) - uiState.set_optimize_for(optimizeFor) - uiState.autoLevelClippingThreshold = autoLevelClippingThreshold - uiState.autoLevelStrength = autoLevelStrength - uiState.autoLevelStrengthAuto = autoLevelStrengthAuto - - uiState.awbMode = awbMode - uiState.awbStrength = awbStrength - uiState.awbWarmBias = awbWarmBias - uiState.awbTintBias = awbTintBias - - uiState.awbLumaLowerBound = awbLumaLowerBound - uiState.awbLumaUpperBound = awbLumaUpperBound - uiState.awbRgbLowerBound = awbRgbLowerBound - uiState.awbRgbUpperBound = awbRgbUpperBound - - visible = false - } - - // Component for Section Separator - Component { - id: sectionSeparator - Rectangle { - Layout.fillWidth: true - Layout.topMargin: 20 - Layout.bottomMargin: 5 - height: 1 - color: settingsDialog.separatorColor - } - } - - // Component for Section Header - Component { - id: sectionHeader - Label { - font.bold: true - font.pixelSize: 15 - font.letterSpacing: 1.0 - color: settingsDialog.accentColorHover - Layout.topMargin: 5 - Layout.bottomMargin: 10 - } - } - - // Custom Styled TextField - Component { - id: styledTextField - TextField { - id: control - color: settingsDialog.textColor - placeholderTextColor: "#80ffffff" - selectionColor: settingsDialog.accentColor - selectedTextColor: "#ffffff" - font.pixelSize: 13 - background: Rectangle { - color: control.enabled ? "transparent" : "#05ffffff" - border.color: control.activeFocus ? settingsDialog.accentColor : settingsDialog.controlBorder - border.width: 1 - radius: 4 - } - } - } - - // Styled Slider Component - Component { - id: styledSlider - Slider { - id: control - - background: Item { - x: control.leftPadding - y: control.topPadding + control.availableHeight / 2 - height / 2 - width: control.availableWidth - height: 6 - - Rectangle { - anchors.fill: parent - radius: 3 - color: settingsDialog.controlBg - border.color: settingsDialog.controlBorder - border.width: 1 - } - - Rectangle { - width: control.visualPosition * parent.width - height: parent.height - radius: 3 - color: settingsDialog.accentColor - opacity: 0.8 - } - } - - handle: Rectangle { - x: control.leftPadding + control.visualPosition * (control.availableWidth - width) - y: control.topPadding + control.availableHeight / 2 - height / 2 - width: 16 - height: 16 - radius: 8 - color: control.pressed ? settingsDialog.accentColor : "white" - border.color: control.pressed ? "white" : settingsDialog.accentColor - border.width: 2 - } - } - } - - // Styled SpinBox Component - Component { - id: styledSpinBox - SpinBox { - id: control - editable: true - - contentItem: TextInput { - z: 2 - text: control.textFromValue(control.value, control.locale) - font.pixelSize: 13 - color: settingsDialog.textColor - selectionColor: settingsDialog.accentColor - selectedTextColor: "#ffffff" - horizontalAlignment: Qt.AlignHCenter - verticalAlignment: Qt.AlignVCenter - readOnly: !control.editable - validator: control.validator - inputMethodHints: Qt.ImhFormattedNumbersOnly - - // Update control.value when user finishes typing - onEditingFinished: { - control.value = control.valueFromText(text, control.locale) - } - } - - up.indicator: Item { - x: parent.width - width - height: parent.height - width: 20 - Rectangle { - anchors.centerIn: parent - width: 16; height: 16 - radius: 2 - color: control.up.pressed ? settingsDialog.accentColor : "transparent" - Text { - text: "+" - anchors.centerIn: parent - color: settingsDialog.textColor - } - } - } - - down.indicator: Item { - x: 0 - height: parent.height - width: 20 - Rectangle { - anchors.centerIn: parent - width: 16; height: 16 - radius: 2 - color: control.down.pressed ? settingsDialog.accentColor : "transparent" - Text { - text: "-" - anchors.centerIn: parent - color: settingsDialog.textColor - } - } - } - - background: Rectangle { - implicitWidth: 100 - color: "transparent" - border.color: settingsDialog.controlBorder - border.width: 1 - radius: 4 - } - } - } - - // State - property int currentTab: 0 - - // Component for Tab Button - Component { - id: tabButton - Rectangle { - property string text - property int index - - anchors.fill: parent - color: "transparent" - - Rectangle { - anchors.bottom: parent.bottom - width: parent.width - height: 2 - color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "transparent" - Behavior on color { ColorAnimation { duration: 200 } } - } - - Text { - anchors.centerIn: parent - text: parent.text - color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "#80ffffff" - font.bold: settingsDialog.currentTab === index - font.pixelSize: 14 - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: settingsDialog.currentTab = index - } - } - } - - // Main Layout container - ColumnLayout { - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: bottomBar.top - spacing: 0 - - // --- Custom Tab Bar --- - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 50 - color: "#1e1e1e" - z: 10 - - RowLayout { - anchors.fill: parent - anchors.margins: 20 - anchors.bottomMargin: 0 - spacing: 20 - - Loader { - Layout.fillWidth: true - Layout.fillHeight: true - sourceComponent: tabButton - onLoaded: { item.text = "General"; item.index = 0 } - } - Loader { - Layout.fillWidth: true - Layout.fillHeight: true - sourceComponent: tabButton - onLoaded: { item.text = "Auto Adjustments"; item.index = 1 } - } - } - - // Bottom border for tab bar - Rectangle { - anchors.bottom: parent.bottom - width: parent.width - height: 1 - color: "#20ffffff" - } - } - - // --- Content Stack --- - StackLayout { - Layout.fillWidth: true - Layout.fillHeight: true - currentIndex: settingsDialog.currentTab - - // --- TAB 1: GENERAL --- - Item { - ScrollView { - anchors.fill: parent - anchors.margins: 20 - clip: true - contentWidth: availableWidth - - ColumnLayout { - width: parent.width - spacing: 15 - - Loader { - sourceComponent: sectionHeader - onLoaded: item.text = "General Settings" - } - - // Helicon Path - Label { text: "Helicon Focus Path"; color: "#aaaaaa"; font.pixelSize: 12 } - RowLayout { - Layout.fillWidth: true - Loader { - id: heliconField - sourceComponent: styledTextField - Layout.fillWidth: true - onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.heliconPath - item.textEdited.connect(function() { settingsDialog.heliconPath = item.text }) - } - } - Button { - text: "Browse" - flat: true - onClicked: { - var path = uiState.open_file_dialog() - if (path) { - settingsDialog.heliconPath = path - if (heliconField.item) heliconField.item.text = path - } - } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - } - Label { - text: "✔" - color: "#4ade80" - visible: uiState && uiState.check_path_exists(settingsDialog.heliconPath) - } - } - - // Photoshop Path - Label { text: "Photoshop Path"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } - RowLayout { - Layout.fillWidth: true - Loader { - id: photoshopField - sourceComponent: styledTextField - Layout.fillWidth: true - onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.photoshopPath - item.textEdited.connect(function() { settingsDialog.photoshopPath = item.text }) - } - } - Button { - text: "Browse" - flat: true - onClicked: { - var path = uiState.open_file_dialog() - if (path) { - settingsDialog.photoshopPath = path - if (photoshopField.item) photoshopField.item.text = path - } - } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - } - Label { - text: "✔" - color: "#4ade80" - visible: uiState && uiState.check_path_exists(settingsDialog.photoshopPath) - } - } - - // RawTherapee Path - Label { text: "RawTherapee Path"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } - RowLayout { - Layout.fillWidth: true - Loader { - id: rawtherapeeField - sourceComponent: styledTextField - Layout.fillWidth: true - onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.rawtherapeePath - item.textEdited.connect(function() { settingsDialog.rawtherapeePath = item.text }) - } - } - Button { - text: "Browse" - flat: true - onClicked: { - var path = uiState.open_file_dialog() - if (path) { - settingsDialog.rawtherapeePath = path - if (rawtherapeeField.item) rawtherapeeField.item.text = path - } - } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - } - Label { - text: "✔" - color: "#4ade80" - visible: uiState && uiState.check_path_exists(settingsDialog.rawtherapeePath) - } - } - - // Default Directory - Label { text: "Default Image Directory"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } - RowLayout { - Layout.fillWidth: true - Loader { - id: defaultDirField - sourceComponent: styledTextField - Layout.fillWidth: true - onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.defaultDirectory - item.textEdited.connect(function() { settingsDialog.defaultDirectory = item.text }) - } - } - Button { - text: "Browse" - flat: true - onClicked: { - var path = uiState.open_directory_dialog() - if (path) { - settingsDialog.defaultDirectory = path - if (defaultDirField.item) defaultDirField.item.text = path - } - } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - } - } - - Loader { sourceComponent: sectionSeparator } - - // Grid for Cache/Theme/Etc - GridLayout { - columns: 2 - columnSpacing: 20 - rowSpacing: 15 - Layout.fillWidth: true - Layout.topMargin: 5 - - // Cache - Label { - text: "Cache Size (GB)" - color: settingsDialog.textColor - - MouseArea { - id: cacheSizeHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: cacheSizeHover.containsMouse - ToolTip.text: "Decoded images are cached in RAM for faster browsing. Higher values allow more images to be kept in memory, reducing re-decode times. Lower values use less RAM. Recommended: 2-8 GB depending on available memory." - } - RowLayout { - Loader { - id: cacheSizeField - sourceComponent: styledTextField - Layout.preferredWidth: 80 - onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.cacheSize.toFixed(1) - item.editingFinished.connect(function() { - var value = parseFloat(item.text) - if (!isNaN(value) && value >= 0.5 && value <= 16) { - settingsDialog.cacheSize = value - // Reformat to show consistent precision - item.text = settingsDialog.cacheSize.toFixed(1) - } else { - // Reset to valid value if invalid input - item.text = settingsDialog.cacheSize.toFixed(1) - } - }) - } - } - Label { - text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB" - color: settingsDialog.accentColorHover - font.pixelSize: 11 - } - } - - // Prefetch - Label { - text: "Prefetch Radius" - color: settingsDialog.textColor - - MouseArea { - id: prefetchHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: prefetchHover.containsMouse - ToolTip.text: "Number of images around the current image to pre-load in the background. Higher values make browsing smoother but use more CPU/RAM. Lower values reduce resource usage. Recommended: 4-8 for smooth navigation." - } - Loader { - sourceComponent: styledSpinBox - onLoaded: { - item.from = 1; item.to = 20 - item.value = settingsDialog.prefetchRadius - item.valueChanged.connect(function() { settingsDialog.prefetchRadius = item.value }) - } - } - - // Optimize For - Label { - text: "Optimize For" - color: settingsDialog.textColor - - MouseArea { - id: optimizeHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: optimizeHover.containsMouse - ToolTip.text: "Speed: Faster JPEG decoding using hardware acceleration (may have slight quality loss). Quality: Slower but pixel-perfect decoding. Choose Speed for general browsing, Quality for critical image inspection." - } - ComboBox { - model: ["speed", "quality"] - currentIndex: Math.max(0, model.indexOf(settingsDialog.optimizeFor)) - onActivated: settingsDialog.optimizeFor = model[currentIndex] - Layout.preferredWidth: 150 - delegate: ItemDelegate { - width: parent.width - contentItem: Text { text: modelData; color: settingsDialog.textColor; font: parent.font; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } - } - contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } - background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } - } - - // Theme - Label { text: "Theme"; color: settingsDialog.textColor } - ComboBox { - model: ["Dark", "Light"] - currentIndex: settingsDialog.theme - onActivated: settingsDialog.theme = currentIndex - Layout.preferredWidth: 150 - delegate: ItemDelegate { - width: parent.width - contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } - } - contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } - background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } - } - } - - Item { Layout.fillHeight: true } // Spacer - } - } - } - - // --- TAB 2: AUTO ADJUSTMENTS --- - Item { - ScrollView { - anchors.fill: parent - anchors.margins: 20 - clip: true - contentWidth: availableWidth - - ColumnLayout { - width: parent.width - spacing: 15 - - // --- Auto Levels --- - Loader { - sourceComponent: sectionHeader - onLoaded: item.text = "Auto Levels" - } - - GridLayout { - columns: 2 - columnSpacing: 20 - rowSpacing: 10 - Layout.fillWidth: true - - Label { - text: "Clip Threshold %" - color: settingsDialog.textColor - - MouseArea { - id: clipThresholdHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: clipThresholdHover.containsMouse - ToolTip.text: "Percentage of pixels to clip at the dark and light ends of the histogram when auto-levels is applied. Higher values (e.g., 5%) increase contrast but risk making highlights appear clipped. Lower values (e.g., 0.1%) preserve more dynamic range. Default: 0.1%" - } - Loader { - id: clipThresholdLoader - sourceComponent: styledTextField - Layout.preferredWidth: 80 - onLoaded: { - item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) - item.editingFinished.connect(function() { - var value = parseFloat(item.text) - if (!isNaN(value) && value >= 0.0 && value <= 10.0) settingsDialog.autoLevelClippingThreshold = value - item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) - }) - } - Binding { - target: clipThresholdLoader.item - property: "text" - value: settingsDialog.autoLevelClippingThreshold.toFixed(4) - when: clipThresholdLoader.item && !clipThresholdLoader.item.activeFocus - } - } - - Label { - text: "Strength" - color: settingsDialog.textColor - - MouseArea { - id: autoLevelStrengthHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: autoLevelStrengthHover.containsMouse - ToolTip.text: "How much of the auto-levels correction to apply. 1.0 applies the full mathematical correction, lower values blend with the original for a subtler effect. The 'Auto' checkbox enables automatic strength reduction to avoid excessive clipping." - } - RowLayout { - Layout.fillWidth: true - Loader { - id: autoLevelStrengthLoader - sourceComponent: styledSlider - Layout.fillWidth: true - onLoaded: { - item.from = 0.0; item.to = 1.0; item.stepSize = 0.05 - item.value = settingsDialog.autoLevelStrength - item.valueChanged.connect(function() { settingsDialog.autoLevelStrength = item.value }) - item.enabled = Qt.binding(function() { return !autoLvlAuto.checked }) - item.opacity = Qt.binding(function() { return (!autoLvlAuto.checked) ? 1.0 : 0.5 }) - } - Binding { - target: autoLevelStrengthLoader.item - property: "value" - value: settingsDialog.autoLevelStrength - when: autoLevelStrengthLoader.item && !autoLevelStrengthLoader.item.pressed - } - } - CheckBox { - id: autoLvlAuto - text: "Auto" - checked: settingsDialog.autoLevelStrengthAuto - onCheckedChanged: settingsDialog.autoLevelStrengthAuto = checked - contentItem: Text { text: parent.text; color: settingsDialog.textColor; leftPadding: parent.indicator.width + parent.spacing; verticalAlignment: Text.AlignVCenter } - indicator: Rectangle { - implicitWidth: 18; implicitHeight: 18 - x: parent.leftPadding; y: parent.height / 2 - height / 2 - radius: 3 - border.color: settingsDialog.accentColor - color: parent.checked ? settingsDialog.accentColor : "transparent" - Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: parent.parent.checked; font.bold: true } - } - } - } - } - - Loader { sourceComponent: sectionSeparator } - - // --- Auto White Balance --- - Loader { - sourceComponent: sectionHeader - onLoaded: item.text = "Auto White Balance" - } - - GridLayout { - columns: 2 - columnSpacing: 20 - rowSpacing: 15 - Layout.fillWidth: true - - // AWB Mode - Label { - text: "Algorithm" - color: settingsDialog.textColor - - MouseArea { - id: awbAlgorithmHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: awbAlgorithmHover.containsMouse - ToolTip.text: "Algorithm for auto white balance. 'lab' analyzes in LAB color space for perceptually uniform results. 'rgb' works directly in RGB space. Most users should use 'lab'." - } - ComboBox { - model: ["lab", "rgb"] - currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) - onActivated: settingsDialog.awbMode = model[currentIndex] - Layout.preferredWidth: 150 - delegate: ItemDelegate { - width: parent.width - contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } - } - contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } - background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } - } - - // Strength - Label { - text: "Strength (" + (awbStrSlider.item ? Math.round(awbStrSlider.item.value * 100) : 0) + "%)" - color: settingsDialog.textColor - - MouseArea { - id: awbStrengthHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: awbStrengthHover.containsMouse - ToolTip.text: "How aggressively to apply the auto white balance correction. 100% applies full correction, lower values blend with original. Range: 30-100%. Recommended: 70%" - } - Loader { - id: awbStrSlider - sourceComponent: styledSlider - Layout.fillWidth: true - onLoaded: { - item.from = 0.3; item.to = 1.0 - item.value = settingsDialog.awbStrength - item.valueChanged.connect(function() { settingsDialog.awbStrength = item.value }) - } - Binding { - target: awbStrSlider.item - property: "value" - value: settingsDialog.awbStrength - when: awbStrSlider.item && !awbStrSlider.item.pressed - } - } - - // Warm Bias - Label { - text: "Warm Bias (Yel/Blu)" - color: settingsDialog.textColor - - MouseArea { - id: warmBiasHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: warmBiasHover.containsMouse - ToolTip.text: "Shifts the white balance warmer (yellow, positive values) or cooler (blue, negative values) after auto correction. Useful to compensate for systematic color casts. Range: -50 to +50. Default: +6" - } - Loader { - id: awbWarmBiasLoader - sourceComponent: styledSpinBox - onLoaded: { - item.from = -50; item.to = 50 - item.value = settingsDialog.awbWarmBias - item.valueChanged.connect(function() { settingsDialog.awbWarmBias = item.value }) - } - Binding { - target: awbWarmBiasLoader.item - property: "value" - value: settingsDialog.awbWarmBias - when: awbWarmBiasLoader.item && !awbWarmBiasLoader.item.activeFocus - } - } - - // Tint Bias - Label { - text: "Tint Bias (Mag/Grn)" - color: settingsDialog.textColor - - MouseArea { - id: tintBiasHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: tintBiasHover.containsMouse - ToolTip.text: "Shifts the color tint toward magenta (positive values) or green (negative values) after auto correction. Compensates for tint issues in the white balance. Range: -50 to +50. Default: 0" - } - Loader { - id: awbTintBiasLoader - sourceComponent: styledSpinBox - onLoaded: { - item.from = -50; item.to = 50 - item.value = settingsDialog.awbTintBias - item.valueChanged.connect(function() { settingsDialog.awbTintBias = item.value }) - } - Binding { - target: awbTintBiasLoader.item - property: "value" - value: settingsDialog.awbTintBias - when: awbTintBiasLoader.item - } - } - } - - Loader { sourceComponent: sectionSeparator } - - Loader { - sourceComponent: sectionHeader - onLoaded: item.text = "Advanced Thresholds" - } - - GridLayout { - columns: 2 - columnSpacing: 20 - rowSpacing: 10 - Layout.fillWidth: true - - Label { - text: "Luma Lower" - color: settingsDialog.textColor - - MouseArea { - id: lumaLowerHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: lumaLowerHover.containsMouse - ToolTip.text: "Minimum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels darker than this are excluded. Range: 0-255. Default: 30. Increase to ignore very dark areas." - } - Loader { - id: awbLumaLowerLoader - sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaLowerBound=item.value})} - Binding { - target: awbLumaLowerLoader.item - property: "value" - value: settingsDialog.awbLumaLowerBound - when: awbLumaLowerLoader.item - } - } - - Label { - text: "Luma Upper" - color: settingsDialog.textColor - - MouseArea { - id: lumaUpperHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: lumaUpperHover.containsMouse - ToolTip.text: "Maximum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels brighter than this are excluded. Range: 0-255. Default: 220. Decrease to ignore very bright areas." - } - Loader { - id: awbLumaUpperLoader - sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaUpperBound=item.value})} - Binding { - target: awbLumaUpperLoader.item - property: "value" - value: settingsDialog.awbLumaUpperBound - when: awbLumaUpperLoader.item - } - } - - Label { - text: "RGB Lower" - color: settingsDialog.textColor - - MouseArea { - id: rgbLowerHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: rgbLowerHover.containsMouse - ToolTip.text: "Minimum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel below this are excluded. Range: 0-255. Default: 5. Increase to ignore very saturated colors." - } - Loader { - id: awbRgbLowerLoader - sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbLowerBound=item.value})} - Binding { - target: awbRgbLowerLoader.item - property: "value" - value: settingsDialog.awbRgbLowerBound - when: awbRgbLowerLoader.item - } - } - - Label { - text: "RGB Upper" - color: settingsDialog.textColor - - MouseArea { - id: rgbUpperHover - anchors.fill: parent - hoverEnabled: true - } - - ToolTip.visible: rgbUpperHover.containsMouse - ToolTip.text: "Maximum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel above this are excluded. Range: 0-255. Default: 250. Decrease to ignore near-white areas." - } - Loader { - id: awbRgbUpperLoader - sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbUpperBound=item.value})} - Binding { - target: awbRgbUpperLoader.item - property: "value" - value: settingsDialog.awbRgbUpperBound - when: awbRgbUpperLoader.item - } - } - } - - Item { Layout.fillHeight: true } // Spacer - } - } - } - } - } - - // Bottom Action Bar - Rectangle { - id: bottomBar - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - height: 70 - color: "#1e1e1e" // matches background - // Gradient separator - Rectangle { width: parent.width; height: 1; color: "#20ffffff"; anchors.top: parent.top } - - RowLayout { - anchors.fill: parent - anchors.margins: 20 - spacing: 15 - - Item { Layout.fillWidth: true } // Spacer left - - Button { - text: "Cancel" - Layout.preferredWidth: 100 - onClicked: settingsDialog.visible = false - - contentItem: Text { - text: parent.text - font: parent.font - color: settingsDialog.textColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: parent.pressed ? "#40ffffff" : "#20ffffff" - radius: 4 - border.color: parent.hovered ? "#60ffffff" : "transparent" - } - } - - Button { - text: "Save" - Layout.preferredWidth: 100 - highlighted: true - onClicked: settingsDialog.saveSettings() - - contentItem: Text { - text: parent.text - font: parent.font - color: "white" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: parent.pressed ? Qt.darker(settingsDialog.accentColor, 1.1) : settingsDialog.accentColor - radius: 4 - } - } - } - } - - Timer { - id: cacheUsageTimer - interval: 1000 - repeat: true - running: false - onTriggered: { - if (uiState) settingsDialog.cacheUsage = uiState.get_cache_usage_gb() - } - } -} +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 + +Window { + id: settingsDialog + title: "Settings" + width: 700 + height: 800 + visible: false + flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint + modality: Qt.ApplicationModal + + // Make window close button (X) behave like Cancel + onClosing: function(close) { + close.accepted = false + visible = false + } + + // Properties matching the original dialog + property string heliconPath: "" + property double cacheSize: 1.5 + property double autoLevelClippingThreshold: 0.1 + property double autoLevelStrength: 1.0 + property bool autoLevelStrengthAuto: false + property int prefetchRadius: 4 + property int theme: 0 + property string defaultDirectory: "" + property string photoshopPath: "" + property string rawtherapeePath: "" + property string optimizeFor: "speed" + + property string awbMode: "lab" + property double awbStrength: 0.7 + property int awbWarmBias: 6 + property int awbTintBias: 0 + + property int awbLumaLowerBound: 30 + property int awbLumaUpperBound: 220 + property int awbRgbLowerBound: 5 + property int awbRgbUpperBound: 250 + + // Live cache usage value (updated by timer) + property real cacheUsage: 0.0 + + // Modern Color Palette (copied from ImageEditorDialog) + property color backgroundColor: "#1e1e1e" + property color textColor: "white" + readonly property color accentColor: "#6366f1" // Modern Indigo + readonly property color accentColorHover: "#818cf8" + readonly property color accentColorSubtle: "#306366f1" + readonly property color controlBg: "#10ffffff" + readonly property color controlBorder: "#30ffffff" + readonly property color separatorColor: "#20ffffff" + + Material.theme: Material.Dark + Material.accent: accentColor + color: backgroundColor + + // Helper to open the dialog + function open() { + // Reload all properties from uiState to ensure Cancel discards edits + if (uiState) { + heliconPath = uiState.get_helicon_path() + photoshopPath = uiState.get_photoshop_path() + rawtherapeePath = uiState.get_rawtherapee_path() + cacheSize = uiState.get_cache_size() + prefetchRadius = uiState.get_prefetch_radius() + theme = uiState.theme + defaultDirectory = uiState.get_default_directory() + optimizeFor = uiState.get_optimize_for() + autoLevelClippingThreshold = uiState.autoLevelClippingThreshold + autoLevelStrength = uiState.autoLevelStrength + autoLevelStrengthAuto = uiState.autoLevelStrengthAuto + awbMode = uiState.awbMode + awbStrength = uiState.awbStrength + awbWarmBias = uiState.awbWarmBias + awbTintBias = uiState.awbTintBias + awbLumaLowerBound = uiState.awbLumaLowerBound + awbLumaUpperBound = uiState.awbLumaUpperBound + awbRgbLowerBound = uiState.awbRgbLowerBound + awbRgbUpperBound = uiState.awbRgbUpperBound + } + visible = true + raise() + requestActivate() + } + + Shortcut { + sequence: "Escape" + context: Qt.WindowShortcut + onActivated: visible = false + } + + onVisibleChanged: { + cacheUsageTimer.running = visible + if (visible) { + controller.dialog_opened() + // Reset all text fields from properties + if (heliconField.item) heliconField.item.text = settingsDialog.heliconPath + if (photoshopField.item) photoshopField.item.text = settingsDialog.photoshopPath + if (rawtherapeeField.item) rawtherapeeField.item.text = settingsDialog.rawtherapeePath + if (defaultDirField.item) defaultDirField.item.text = settingsDialog.defaultDirectory + if (cacheSizeField.item) cacheSizeField.item.text = settingsDialog.cacheSize.toFixed(1) + // Note: ComboBoxes and SpinBoxes update automatically via bindings/connections + } else { + controller.dialog_closed() + } + } + + function saveSettings() { + uiState.set_helicon_path(heliconPath) + uiState.set_photoshop_path(photoshopPath) + uiState.set_rawtherapee_path(rawtherapeePath) + uiState.set_cache_size(cacheSize) + uiState.set_prefetch_radius(prefetchRadius) + uiState.set_theme(theme) + uiState.set_default_directory(defaultDirectory) + uiState.set_optimize_for(optimizeFor) + uiState.autoLevelClippingThreshold = autoLevelClippingThreshold + uiState.autoLevelStrength = autoLevelStrength + uiState.autoLevelStrengthAuto = autoLevelStrengthAuto + + uiState.awbMode = awbMode + uiState.awbStrength = awbStrength + uiState.awbWarmBias = awbWarmBias + uiState.awbTintBias = awbTintBias + + uiState.awbLumaLowerBound = awbLumaLowerBound + uiState.awbLumaUpperBound = awbLumaUpperBound + uiState.awbRgbLowerBound = awbRgbLowerBound + uiState.awbRgbUpperBound = awbRgbUpperBound + + visible = false + } + + // Component for Section Separator + Component { + id: sectionSeparator + Rectangle { + Layout.fillWidth: true + Layout.topMargin: 20 + Layout.bottomMargin: 5 + height: 1 + color: settingsDialog.separatorColor + } + } + + // Component for Section Header + Component { + id: sectionHeader + Label { + font.bold: true + font.pixelSize: 15 + font.letterSpacing: 1.0 + color: settingsDialog.accentColorHover + Layout.topMargin: 5 + Layout.bottomMargin: 10 + } + } + + // Custom Styled TextField + Component { + id: styledTextField + TextField { + id: control + color: settingsDialog.textColor + placeholderTextColor: "#80ffffff" + selectionColor: settingsDialog.accentColor + selectedTextColor: "#ffffff" + font.pixelSize: 13 + background: Rectangle { + color: control.enabled ? "transparent" : "#05ffffff" + border.color: control.activeFocus ? settingsDialog.accentColor : settingsDialog.controlBorder + border.width: 1 + radius: 4 + } + } + } + + // Styled Slider Component + Component { + id: styledSlider + Slider { + id: control + + background: Item { + x: control.leftPadding + y: control.topPadding + control.availableHeight / 2 - height / 2 + width: control.availableWidth + height: 6 + + Rectangle { + anchors.fill: parent + radius: 3 + color: settingsDialog.controlBg + border.color: settingsDialog.controlBorder + border.width: 1 + } + + Rectangle { + width: control.visualPosition * parent.width + height: parent.height + radius: 3 + color: settingsDialog.accentColor + opacity: 0.8 + } + } + + handle: Rectangle { + x: control.leftPadding + control.visualPosition * (control.availableWidth - width) + y: control.topPadding + control.availableHeight / 2 - height / 2 + width: 16 + height: 16 + radius: 8 + color: control.pressed ? settingsDialog.accentColor : "white" + border.color: control.pressed ? "white" : settingsDialog.accentColor + border.width: 2 + } + } + } + + // Styled SpinBox Component + Component { + id: styledSpinBox + SpinBox { + id: control + editable: true + + contentItem: TextInput { + z: 2 + text: control.textFromValue(control.value, control.locale) + font.pixelSize: 13 + color: settingsDialog.textColor + selectionColor: settingsDialog.accentColor + selectedTextColor: "#ffffff" + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + readOnly: !control.editable + validator: control.validator + inputMethodHints: Qt.ImhFormattedNumbersOnly + + // Update control.value when user finishes typing + onEditingFinished: { + control.value = control.valueFromText(text, control.locale) + } + } + + up.indicator: Item { + x: parent.width - width + height: parent.height + width: 20 + Rectangle { + anchors.centerIn: parent + width: 16; height: 16 + radius: 2 + color: control.up.pressed ? settingsDialog.accentColor : "transparent" + Text { + text: "+" + anchors.centerIn: parent + color: settingsDialog.textColor + } + } + } + + down.indicator: Item { + x: 0 + height: parent.height + width: 20 + Rectangle { + anchors.centerIn: parent + width: 16; height: 16 + radius: 2 + color: control.down.pressed ? settingsDialog.accentColor : "transparent" + Text { + text: "-" + anchors.centerIn: parent + color: settingsDialog.textColor + } + } + } + + background: Rectangle { + implicitWidth: 100 + color: "transparent" + border.color: settingsDialog.controlBorder + border.width: 1 + radius: 4 + } + } + } + + // State + property int currentTab: 0 + + // Component for Tab Button + Component { + id: tabButton + Rectangle { + property string text + property int index + + anchors.fill: parent + color: "transparent" + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 2 + color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "transparent" + Behavior on color { ColorAnimation { duration: 200 } } + } + + Text { + anchors.centerIn: parent + text: parent.text + color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "#80ffffff" + font.bold: settingsDialog.currentTab === index + font.pixelSize: 14 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: settingsDialog.currentTab = index + } + } + } + + // Main Layout container + ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: bottomBar.top + spacing: 0 + + // --- Custom Tab Bar --- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 50 + color: "#1e1e1e" + z: 10 + + RowLayout { + anchors.fill: parent + anchors.margins: 20 + anchors.bottomMargin: 0 + spacing: 20 + + Loader { + Layout.fillWidth: true + Layout.fillHeight: true + sourceComponent: tabButton + onLoaded: { item.text = "General"; item.index = 0 } + } + Loader { + Layout.fillWidth: true + Layout.fillHeight: true + sourceComponent: tabButton + onLoaded: { item.text = "Auto Adjustments"; item.index = 1 } + } + } + + // Bottom border for tab bar + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#20ffffff" + } + } + + // --- Content Stack --- + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: settingsDialog.currentTab + + // --- TAB 1: GENERAL --- + Item { + ScrollView { + anchors.fill: parent + anchors.margins: 20 + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: 15 + + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "General Settings" + } + + // Helicon Path + Label { text: "Helicon Focus Path"; color: "#aaaaaa"; font.pixelSize: 12 } + RowLayout { + Layout.fillWidth: true + Loader { + id: heliconField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.heliconPath + item.textEdited.connect(function() { settingsDialog.heliconPath = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_file_dialog() + if (path) { + settingsDialog.heliconPath = path + if (heliconField.item) heliconField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + Label { + text: "✔" + color: "#4ade80" + visible: uiState && uiState.check_path_exists(settingsDialog.heliconPath) + } + } + + // Photoshop Path + Label { text: "Photoshop Path"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } + RowLayout { + Layout.fillWidth: true + Loader { + id: photoshopField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.photoshopPath + item.textEdited.connect(function() { settingsDialog.photoshopPath = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_file_dialog() + if (path) { + settingsDialog.photoshopPath = path + if (photoshopField.item) photoshopField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + Label { + text: "✔" + color: "#4ade80" + visible: uiState && uiState.check_path_exists(settingsDialog.photoshopPath) + } + } + + // RawTherapee Path + Label { text: "RawTherapee Path"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } + RowLayout { + Layout.fillWidth: true + Loader { + id: rawtherapeeField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.rawtherapeePath + item.textEdited.connect(function() { settingsDialog.rawtherapeePath = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_file_dialog() + if (path) { + settingsDialog.rawtherapeePath = path + if (rawtherapeeField.item) rawtherapeeField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + Label { + text: "✔" + color: "#4ade80" + visible: uiState && uiState.check_path_exists(settingsDialog.rawtherapeePath) + } + } + + // Default Directory + Label { text: "Default Image Directory"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } + RowLayout { + Layout.fillWidth: true + Loader { + id: defaultDirField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.defaultDirectory + item.textEdited.connect(function() { settingsDialog.defaultDirectory = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_directory_dialog() + if (path) { + settingsDialog.defaultDirectory = path + if (defaultDirField.item) defaultDirField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + } + + Loader { sourceComponent: sectionSeparator } + + // Grid for Cache/Theme/Etc + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 15 + Layout.fillWidth: true + Layout.topMargin: 5 + + // Cache + Label { + text: "Cache Size (GB)" + color: settingsDialog.textColor + + MouseArea { + id: cacheSizeHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: cacheSizeHover.containsMouse + ToolTip.text: "Decoded images are cached in RAM for faster browsing. Higher values allow more images to be kept in memory, reducing re-decode times. Lower values use less RAM. Recommended: 2-8 GB depending on available memory." + } + RowLayout { + Loader { + id: cacheSizeField + sourceComponent: styledTextField + Layout.preferredWidth: 80 + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.cacheSize.toFixed(1) + item.editingFinished.connect(function() { + var value = parseFloat(item.text) + if (!isNaN(value) && value >= 0.5 && value <= 16) { + settingsDialog.cacheSize = value + // Reformat to show consistent precision + item.text = settingsDialog.cacheSize.toFixed(1) + } else { + // Reset to valid value if invalid input + item.text = settingsDialog.cacheSize.toFixed(1) + } + }) + } + } + Label { + text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB" + color: settingsDialog.accentColorHover + font.pixelSize: 11 + } + } + + // Prefetch + Label { + text: "Prefetch Radius" + color: settingsDialog.textColor + + MouseArea { + id: prefetchHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: prefetchHover.containsMouse + ToolTip.text: "Number of images around the current image to pre-load in the background. Higher values make browsing smoother but use more CPU/RAM. Lower values reduce resource usage. Recommended: 4-8 for smooth navigation." + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { + item.from = 1; item.to = 20 + item.value = settingsDialog.prefetchRadius + item.valueChanged.connect(function() { settingsDialog.prefetchRadius = item.value }) + } + } + + // Optimize For + Label { + text: "Optimize For" + color: settingsDialog.textColor + + MouseArea { + id: optimizeHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: optimizeHover.containsMouse + ToolTip.text: "Speed: Faster JPEG decoding using hardware acceleration (may have slight quality loss). Quality: Slower but pixel-perfect decoding. Choose Speed for general browsing, Quality for critical image inspection." + } + ComboBox { + model: ["speed", "quality"] + currentIndex: Math.max(0, model.indexOf(settingsDialog.optimizeFor)) + onActivated: settingsDialog.optimizeFor = model[currentIndex] + Layout.preferredWidth: 150 + delegate: ItemDelegate { + width: parent.width + contentItem: Text { text: modelData; color: settingsDialog.textColor; font: parent.font; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + } + contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } + } + + // Theme + Label { text: "Theme"; color: settingsDialog.textColor } + ComboBox { + model: ["Dark", "Light"] + currentIndex: settingsDialog.theme + onActivated: settingsDialog.theme = currentIndex + Layout.preferredWidth: 150 + delegate: ItemDelegate { + width: parent.width + contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + } + contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } + } + } + + Item { Layout.fillHeight: true } // Spacer + } + } + } + + // --- TAB 2: AUTO ADJUSTMENTS --- + Item { + ScrollView { + anchors.fill: parent + anchors.margins: 20 + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: 15 + + // --- Auto Levels --- + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "Auto Levels" + } + + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 10 + Layout.fillWidth: true + + Label { + text: "Clip Threshold %" + color: settingsDialog.textColor + + MouseArea { + id: clipThresholdHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: clipThresholdHover.containsMouse + ToolTip.text: "Percentage of pixels to clip at the dark and light ends of the histogram when auto-levels is applied. Higher values (e.g., 5%) increase contrast but risk making highlights appear clipped. Lower values (e.g., 0.1%) preserve more dynamic range. Default: 0.1%" + } + Loader { + id: clipThresholdLoader + sourceComponent: styledTextField + Layout.preferredWidth: 80 + onLoaded: { + item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + item.editingFinished.connect(function() { + var value = parseFloat(item.text) + if (!isNaN(value) && value >= 0.0 && value <= 10.0) settingsDialog.autoLevelClippingThreshold = value + item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + }) + } + Binding { + target: clipThresholdLoader.item + property: "text" + value: settingsDialog.autoLevelClippingThreshold.toFixed(4) + when: clipThresholdLoader.item && !clipThresholdLoader.item.activeFocus + } + } + + Label { + text: "Strength" + color: settingsDialog.textColor + + MouseArea { + id: autoLevelStrengthHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: autoLevelStrengthHover.containsMouse + ToolTip.text: "How much of the auto-levels correction to apply. 1.0 applies the full mathematical correction, lower values blend with the original for a subtler effect. The 'Auto' checkbox enables automatic strength reduction to avoid excessive clipping." + } + RowLayout { + Layout.fillWidth: true + Loader { + id: autoLevelStrengthLoader + sourceComponent: styledSlider + Layout.fillWidth: true + onLoaded: { + item.from = 0.0; item.to = 1.0; item.stepSize = 0.05 + item.value = settingsDialog.autoLevelStrength + item.valueChanged.connect(function() { settingsDialog.autoLevelStrength = item.value }) + item.enabled = Qt.binding(function() { return !autoLvlAuto.checked }) + item.opacity = Qt.binding(function() { return (!autoLvlAuto.checked) ? 1.0 : 0.5 }) + } + Binding { + target: autoLevelStrengthLoader.item + property: "value" + value: settingsDialog.autoLevelStrength + when: autoLevelStrengthLoader.item && !autoLevelStrengthLoader.item.pressed + } + } + CheckBox { + id: autoLvlAuto + text: "Auto" + checked: settingsDialog.autoLevelStrengthAuto + onCheckedChanged: settingsDialog.autoLevelStrengthAuto = checked + contentItem: Text { text: parent.text; color: settingsDialog.textColor; leftPadding: parent.indicator.width + parent.spacing; verticalAlignment: Text.AlignVCenter } + indicator: Rectangle { + implicitWidth: 18; implicitHeight: 18 + x: parent.leftPadding; y: parent.height / 2 - height / 2 + radius: 3 + border.color: settingsDialog.accentColor + color: parent.checked ? settingsDialog.accentColor : "transparent" + Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: parent.parent.checked; font.bold: true } + } + } + } + } + + Loader { sourceComponent: sectionSeparator } + + // --- Auto White Balance --- + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "Auto White Balance" + } + + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 15 + Layout.fillWidth: true + + // AWB Mode + Label { + text: "Algorithm" + color: settingsDialog.textColor + + MouseArea { + id: awbAlgorithmHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: awbAlgorithmHover.containsMouse + ToolTip.text: "Algorithm for auto white balance. 'lab' analyzes in LAB color space for perceptually uniform results. 'rgb' works directly in RGB space. Most users should use 'lab'." + } + ComboBox { + model: ["lab", "rgb"] + currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) + onActivated: settingsDialog.awbMode = model[currentIndex] + Layout.preferredWidth: 150 + delegate: ItemDelegate { + width: parent.width + contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + } + contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } + } + + // Strength + Label { + text: "Strength (" + (awbStrSlider.item ? Math.round(awbStrSlider.item.value * 100) : 0) + "%)" + color: settingsDialog.textColor + + MouseArea { + id: awbStrengthHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: awbStrengthHover.containsMouse + ToolTip.text: "How aggressively to apply the auto white balance correction. 100% applies full correction, lower values blend with original. Range: 30-100%. Recommended: 70%" + } + Loader { + id: awbStrSlider + sourceComponent: styledSlider + Layout.fillWidth: true + onLoaded: { + item.from = 0.3; item.to = 1.0 + item.value = settingsDialog.awbStrength + item.valueChanged.connect(function() { settingsDialog.awbStrength = item.value }) + } + Binding { + target: awbStrSlider.item + property: "value" + value: settingsDialog.awbStrength + when: awbStrSlider.item && !awbStrSlider.item.pressed + } + } + + // Warm Bias + Label { + text: "Warm Bias (Yel/Blu)" + color: settingsDialog.textColor + + MouseArea { + id: warmBiasHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: warmBiasHover.containsMouse + ToolTip.text: "Shifts the white balance warmer (yellow, positive values) or cooler (blue, negative values) after auto correction. Useful to compensate for systematic color casts. Range: -50 to +50. Default: +6" + } + Loader { + id: awbWarmBiasLoader + sourceComponent: styledSpinBox + onLoaded: { + item.from = -50; item.to = 50 + item.value = settingsDialog.awbWarmBias + item.valueChanged.connect(function() { settingsDialog.awbWarmBias = item.value }) + } + Binding { + target: awbWarmBiasLoader.item + property: "value" + value: settingsDialog.awbWarmBias + when: awbWarmBiasLoader.item && !awbWarmBiasLoader.item.activeFocus + } + } + + // Tint Bias + Label { + text: "Tint Bias (Mag/Grn)" + color: settingsDialog.textColor + + MouseArea { + id: tintBiasHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: tintBiasHover.containsMouse + ToolTip.text: "Shifts the color tint toward magenta (positive values) or green (negative values) after auto correction. Compensates for tint issues in the white balance. Range: -50 to +50. Default: 0" + } + Loader { + id: awbTintBiasLoader + sourceComponent: styledSpinBox + onLoaded: { + item.from = -50; item.to = 50 + item.value = settingsDialog.awbTintBias + item.valueChanged.connect(function() { settingsDialog.awbTintBias = item.value }) + } + Binding { + target: awbTintBiasLoader.item + property: "value" + value: settingsDialog.awbTintBias + when: awbTintBiasLoader.item + } + } + } + + Loader { sourceComponent: sectionSeparator } + + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "Advanced Thresholds" + } + + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 10 + Layout.fillWidth: true + + Label { + text: "Luma Lower" + color: settingsDialog.textColor + + MouseArea { + id: lumaLowerHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: lumaLowerHover.containsMouse + ToolTip.text: "Minimum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels darker than this are excluded. Range: 0-255. Default: 30. Increase to ignore very dark areas." + } + Loader { + id: awbLumaLowerLoader + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaLowerBound=item.value})} + Binding { + target: awbLumaLowerLoader.item + property: "value" + value: settingsDialog.awbLumaLowerBound + when: awbLumaLowerLoader.item + } + } + + Label { + text: "Luma Upper" + color: settingsDialog.textColor + + MouseArea { + id: lumaUpperHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: lumaUpperHover.containsMouse + ToolTip.text: "Maximum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels brighter than this are excluded. Range: 0-255. Default: 220. Decrease to ignore very bright areas." + } + Loader { + id: awbLumaUpperLoader + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaUpperBound=item.value})} + Binding { + target: awbLumaUpperLoader.item + property: "value" + value: settingsDialog.awbLumaUpperBound + when: awbLumaUpperLoader.item + } + } + + Label { + text: "RGB Lower" + color: settingsDialog.textColor + + MouseArea { + id: rgbLowerHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: rgbLowerHover.containsMouse + ToolTip.text: "Minimum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel below this are excluded. Range: 0-255. Default: 5. Increase to ignore very saturated colors." + } + Loader { + id: awbRgbLowerLoader + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbLowerBound=item.value})} + Binding { + target: awbRgbLowerLoader.item + property: "value" + value: settingsDialog.awbRgbLowerBound + when: awbRgbLowerLoader.item + } + } + + Label { + text: "RGB Upper" + color: settingsDialog.textColor + + MouseArea { + id: rgbUpperHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: rgbUpperHover.containsMouse + ToolTip.text: "Maximum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel above this are excluded. Range: 0-255. Default: 250. Decrease to ignore near-white areas." + } + Loader { + id: awbRgbUpperLoader + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbUpperBound=item.value})} + Binding { + target: awbRgbUpperLoader.item + property: "value" + value: settingsDialog.awbRgbUpperBound + when: awbRgbUpperLoader.item + } + } + } + + Item { Layout.fillHeight: true } // Spacer + } + } + } + } + } + + // Bottom Action Bar + Rectangle { + id: bottomBar + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 70 + color: "#1e1e1e" // matches background + // Gradient separator + Rectangle { width: parent.width; height: 1; color: "#20ffffff"; anchors.top: parent.top } + + RowLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 15 + + Item { Layout.fillWidth: true } // Spacer left + + Button { + text: "Cancel" + Layout.preferredWidth: 100 + onClicked: settingsDialog.visible = false + + contentItem: Text { + text: parent.text + font: parent.font + color: settingsDialog.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#40ffffff" : "#20ffffff" + radius: 4 + border.color: parent.hovered ? "#60ffffff" : "transparent" + } + } + + Button { + text: "Save" + Layout.preferredWidth: 100 + highlighted: true + onClicked: settingsDialog.saveSettings() + + contentItem: Text { + text: parent.text + font: parent.font + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? Qt.darker(settingsDialog.accentColor, 1.1) : settingsDialog.accentColor + radius: 4 + } + } + } + } + + Timer { + id: cacheUsageTimer + interval: 1000 + repeat: true + running: false + onTriggered: { + if (uiState) settingsDialog.cacheUsage = uiState.get_cache_usage_gb() + } + } +} diff --git a/faststack/test_pil_blur.py b/faststack/test_pil_blur.py index 42d4547..290c501 100644 --- a/faststack/test_pil_blur.py +++ b/faststack/test_pil_blur.py @@ -1,28 +1,28 @@ - -from PIL import Image, ImageFilter -import numpy as np -import time - -def test_blur(): - try: - # Create a dummy float image - data = np.random.rand(100, 100).astype(np.float32) - img = Image.fromarray(data, mode='F') - - print("Attempting blur on mode 'F'...") - start = time.time() - blurred = img.filter(ImageFilter.GaussianBlur(radius=5)) - print(f"Blur took {time.time() - start:.4f}s") - - result = np.array(blurred) - print(f"Result shape: {result.shape}, dtype: {result.dtype}") - - # Check if it actually blurred (simple check: std dev should decrease) - print(f"Original std: {np.std(data):.4f}") - print(f"Blurred std: {np.std(result):.4f}") - - except Exception as e: - print(f"Failed: {e}") - -if __name__ == "__main__": - test_blur() + +from PIL import Image, ImageFilter +import numpy as np +import time + +def test_blur(): + try: + # Create a dummy float image + data = np.random.rand(100, 100).astype(np.float32) + img = Image.fromarray(data, mode='F') + + print("Attempting blur on mode 'F'...") + start = time.time() + blurred = img.filter(ImageFilter.GaussianBlur(radius=5)) + print(f"Blur took {time.time() - start:.4f}s") + + result = np.array(blurred) + print(f"Result shape: {result.shape}, dtype: {result.dtype}") + + # Check if it actually blurred (simple check: std dev should decrease) + print(f"Original std: {np.std(data):.4f}") + print(f"Blurred std: {np.std(result):.4f}") + + except Exception as e: + print(f"Failed: {e}") + +if __name__ == "__main__": + test_blur() diff --git a/faststack/tests/benchmark_decode.py b/faststack/tests/benchmark_decode.py index 9a41156..19ead5a 100644 --- a/faststack/tests/benchmark_decode.py +++ b/faststack/tests/benchmark_decode.py @@ -1,40 +1,40 @@ - -import time -import io -import numpy as np -from PIL import Image -from faststack.imaging.jpeg import decode_jpeg_resized, TURBO_AVAILABLE - -def create_test_jpeg(width=6000, height=4000): - """Creates a large test JPEG in memory.""" - print(f"Creating test JPEG ({width}x{height})...") - # Create a random image - arr = np.random.randint(0, 255, (height, width, 3), dtype=np.uint8) - img = Image.fromarray(arr) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=90) - return buf.getvalue() - -def benchmark(): - jpeg_bytes = create_test_jpeg() - print(f"JPEG size: {len(jpeg_bytes) / 1024 / 1024:.2f} MB") - print(f"TurboJPEG available: {TURBO_AVAILABLE}") - - target_width = 1920 - target_height = 1080 - - # Warmup - decode_jpeg_resized(jpeg_bytes, target_width, target_height) - - iterations = 10 - start = time.perf_counter() - for _ in range(iterations): - decode_jpeg_resized(jpeg_bytes, target_width, target_height) - end = time.perf_counter() - - avg_time = (end - start) / iterations - print(f"Average decode time (Current Implementation): {avg_time:.4f} s") - print(f"FPS: {1/avg_time:.2f}") - -if __name__ == "__main__": - benchmark() + +import time +import io +import numpy as np +from PIL import Image +from faststack.imaging.jpeg import decode_jpeg_resized, TURBO_AVAILABLE + +def create_test_jpeg(width=6000, height=4000): + """Creates a large test JPEG in memory.""" + print(f"Creating test JPEG ({width}x{height})...") + # Create a random image + arr = np.random.randint(0, 255, (height, width, 3), dtype=np.uint8) + img = Image.fromarray(arr) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=90) + return buf.getvalue() + +def benchmark(): + jpeg_bytes = create_test_jpeg() + print(f"JPEG size: {len(jpeg_bytes) / 1024 / 1024:.2f} MB") + print(f"TurboJPEG available: {TURBO_AVAILABLE}") + + target_width = 1920 + target_height = 1080 + + # Warmup + decode_jpeg_resized(jpeg_bytes, target_width, target_height) + + iterations = 10 + start = time.perf_counter() + for _ in range(iterations): + decode_jpeg_resized(jpeg_bytes, target_width, target_height) + end = time.perf_counter() + + avg_time = (end - start) / iterations + print(f"Average decode time (Current Implementation): {avg_time:.4f} s") + print(f"FPS: {1/avg_time:.2f}") + +if __name__ == "__main__": + benchmark() diff --git a/faststack/tests/benchmark_decode_bilinear.py b/faststack/tests/benchmark_decode_bilinear.py index b262770..12cb58a 100644 --- a/faststack/tests/benchmark_decode_bilinear.py +++ b/faststack/tests/benchmark_decode_bilinear.py @@ -1,84 +1,84 @@ - -import time -import io -import numpy as np -from PIL import Image -from faststack.imaging.jpeg import decode_jpeg_rgb, _get_turbojpeg_scaling_factor, TURBO_AVAILABLE, jpeg_decoder, TJPF_RGB - -def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): - """Decodes and resizes a JPEG to fit within the given dimensions using BILINEAR.""" - if width == 0 or height == 0: - return decode_jpeg_rgb(jpeg_bytes) - - if TURBO_AVAILABLE and jpeg_decoder: - try: - # Get image header to determine dimensions - img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) - - # Determine which dimension is the limiting factor - if img_width * height > img_height * width: - max_dim = width - else: - max_dim = height - - scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) - - if scale_factor: - decoded = jpeg_decoder.decode( - jpeg_bytes, - scaling_factor=scale_factor, - pixel_format=TJPF_RGB, - flags=0 - ) - - # Only use Pillow for final resize if needed - if decoded.shape[0] > height or decoded.shape[1] > width: - img = Image.fromarray(decoded) - # CHANGED: Use BILINEAR instead of LANCZOS - img.thumbnail((width, height), Image.Resampling.BILINEAR) - return np.array(img) - return decoded - except Exception as e: - print(f"PyTurboJPEG failed: {e}") - - # Fallback to Pillow - try: - img = Image.open(io.BytesIO(jpeg_bytes)) - img.thumbnail((width, height), Image.Resampling.BILINEAR) - return np.array(img.convert("RGB")) - except Exception as e: - print(f"Pillow failed: {e}") - return None - -def create_test_jpeg(width=6000, height=4000): - """Creates a large test JPEG in memory.""" - print(f"Creating test JPEG ({width}x{height})...") - arr = np.random.randint(0, 255, (height, width, 3), dtype=np.uint8) - img = Image.fromarray(arr) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=90) - return buf.getvalue() - -def benchmark(): - jpeg_bytes = create_test_jpeg() - print(f"JPEG size: {len(jpeg_bytes) / 1024 / 1024:.2f} MB") - print(f"TurboJPEG available: {TURBO_AVAILABLE}") - - target_width = 1920 - target_height = 1080 - - # Warmup - decode_jpeg_resized_bilinear(jpeg_bytes, target_width, target_height) - - iterations = 10 - start = time.perf_counter() - for _ in range(iterations): - decode_jpeg_resized_bilinear(jpeg_bytes, target_width, target_height) - end = time.perf_counter() - - avg_time = (end - start) / iterations - print(f"Average decode time (BILINEAR): {avg_time:.4f} s") - print(f"FPS: {1/avg_time:.2f}") - -if __name__ == "__main__": - benchmark() + +import time +import io +import numpy as np +from PIL import Image +from faststack.imaging.jpeg import decode_jpeg_rgb, _get_turbojpeg_scaling_factor, TURBO_AVAILABLE, jpeg_decoder, TJPF_RGB + +def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): + """Decodes and resizes a JPEG to fit within the given dimensions using BILINEAR.""" + if width == 0 or height == 0: + return decode_jpeg_rgb(jpeg_bytes) + + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Get image header to determine dimensions + img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + + # Determine which dimension is the limiting factor + if img_width * height > img_height * width: + max_dim = width + else: + max_dim = height + + scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) + + if scale_factor: + decoded = jpeg_decoder.decode( + jpeg_bytes, + scaling_factor=scale_factor, + pixel_format=TJPF_RGB, + flags=0 + ) + + # Only use Pillow for final resize if needed + if decoded.shape[0] > height or decoded.shape[1] > width: + img = Image.fromarray(decoded) + # CHANGED: Use BILINEAR instead of LANCZOS + img.thumbnail((width, height), Image.Resampling.BILINEAR) + return np.array(img) + return decoded + except Exception as e: + print(f"PyTurboJPEG failed: {e}") + + # Fallback to Pillow + try: + img = Image.open(io.BytesIO(jpeg_bytes)) + img.thumbnail((width, height), Image.Resampling.BILINEAR) + return np.array(img.convert("RGB")) + except Exception as e: + print(f"Pillow failed: {e}") + return None + +def create_test_jpeg(width=6000, height=4000): + """Creates a large test JPEG in memory.""" + print(f"Creating test JPEG ({width}x{height})...") + arr = np.random.randint(0, 255, (height, width, 3), dtype=np.uint8) + img = Image.fromarray(arr) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=90) + return buf.getvalue() + +def benchmark(): + jpeg_bytes = create_test_jpeg() + print(f"JPEG size: {len(jpeg_bytes) / 1024 / 1024:.2f} MB") + print(f"TurboJPEG available: {TURBO_AVAILABLE}") + + target_width = 1920 + target_height = 1080 + + # Warmup + decode_jpeg_resized_bilinear(jpeg_bytes, target_width, target_height) + + iterations = 10 + start = time.perf_counter() + for _ in range(iterations): + decode_jpeg_resized_bilinear(jpeg_bytes, target_width, target_height) + end = time.perf_counter() + + avg_time = (end - start) / iterations + print(f"Average decode time (BILINEAR): {avg_time:.4f} s") + print(f"FPS: {1/avg_time:.2f}") + +if __name__ == "__main__": + benchmark() diff --git a/faststack/tests/check_imports.py b/faststack/tests/check_imports.py index 20fa53f..84d9842 100644 --- a/faststack/tests/check_imports.py +++ b/faststack/tests/check_imports.py @@ -1,23 +1,23 @@ -import sys -import os - -# Add current directory to path -sys.path.append(os.getcwd()) - -try: - print("Importing faststack.app...") - from faststack.app import AppController - print("Success faststack.app") -except Exception as e: - print(f"Failed faststack.app: {e}") - import traceback - traceback.print_exc() - -try: - print("Importing faststack.tests.test_raw_pipeline...") - import faststack.tests.test_raw_pipeline - print("Success test_raw_pipeline") -except Exception as e: - print(f"Failed test_raw_pipeline: {e}") - import traceback - traceback.print_exc() +import sys +import os + +# Add current directory to path +sys.path.append(os.getcwd()) + +try: + print("Importing faststack.app...") + from faststack.app import AppController + print("Success faststack.app") +except Exception as e: + print(f"Failed faststack.app: {e}") + import traceback + traceback.print_exc() + +try: + print("Importing faststack.tests.test_raw_pipeline...") + import faststack.tests.test_raw_pipeline + print("Success test_raw_pipeline") +except Exception as e: + print(f"Failed test_raw_pipeline: {e}") + import traceback + traceback.print_exc() diff --git a/faststack/tests/check_turbo.py b/faststack/tests/check_turbo.py index 1d62690..1fb0afc 100644 --- a/faststack/tests/check_turbo.py +++ b/faststack/tests/check_turbo.py @@ -1,11 +1,11 @@ - -try: - import turbojpeg - print("turbojpeg module found") - print(f"Dir: {dir(turbojpeg)}") - if hasattr(turbojpeg, 'TJFLAG_FASTDCT'): - print(f"TJFLAG_FASTDCT: {turbojpeg.TJFLAG_FASTDCT}") - else: - print("TJFLAG_FASTDCT not found in module") -except ImportError: - print("turbojpeg module not found") + +try: + import turbojpeg + print("turbojpeg module found") + print(f"Dir: {dir(turbojpeg)}") + if hasattr(turbojpeg, 'TJFLAG_FASTDCT'): + print(f"TJFLAG_FASTDCT: {turbojpeg.TJFLAG_FASTDCT}") + else: + print("TJFLAG_FASTDCT not found in module") +except ImportError: + print("turbojpeg module not found") diff --git a/faststack/tests/debug_metadata.py b/faststack/tests/debug_metadata.py index e05066f..d2c0dea 100644 --- a/faststack/tests/debug_metadata.py +++ b/faststack/tests/debug_metadata.py @@ -1,60 +1,60 @@ - -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch -from PIL import ExifTags -import json - -# Add parent directory to path to import faststack -sys.path.append(str(Path(__file__).parent.parent)) - -from faststack.imaging.metadata import get_exif_data - -def debug_test(): - with open("debug_output.txt", "w") as f: - f.write("Starting debug test...\n") - try: - # Patch PIL.Image.open directly - with patch('PIL.Image.open') as mock_open, \ - patch('pathlib.Path.exists', return_value=True): - # Setup mock image and exif data - mock_img = MagicMock() - - tag_map = {v: k for k, v in ExifTags.TAGS.items()} - - exif_dict = { - tag_map["DateTimeOriginal"]: "2023:01:01 12:00:00", - tag_map["Make"]: "Canon", - tag_map["Model"]: "Canon EOS R5", - tag_map["LensModel"]: "RF 24-70mm F2.8L IS USM", - tag_map["ISOSpeedRatings"]: 100, - tag_map["FNumber"]: (28, 10), - tag_map["ExposureTime"]: (1, 200), - tag_map["FocalLength"]: (50, 1), - } - - mock_img._getexif.return_value = exif_dict - mock_open.return_value = mock_img - - f.write("Calling get_exif_data...\n") - result = get_exif_data(Path("dummy.jpg")) - f.write(f"Result Summary: {json.dumps(result.get('summary', {}), indent=2)}\n") - f.write(f"Result Full Keys: {list(result.get('full', {}).keys())}\n") - - summary = result["summary"] - assert summary["Date Taken"] == "2023:01:01 12:00:00" - assert summary["Camera"] == "Canon EOS R5" - assert summary["Lens"] == "RF 24-70mm F2.8L IS USM" - assert summary["ISO"] == "100" - assert summary["Aperture"] == "f/2.8" - assert summary["Shutter Speed"] == "1/200s" - assert summary["Focal Length"] == "50mm" - - f.write("Test PASSED\n") - except Exception as e: - f.write("Test FAILED\n") - import traceback - traceback.print_exc(file=f) - -if __name__ == "__main__": - debug_test() + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch +from PIL import ExifTags +import json + +# Add parent directory to path to import faststack +sys.path.append(str(Path(__file__).parent.parent)) + +from faststack.imaging.metadata import get_exif_data + +def debug_test(): + with open("debug_output.txt", "w") as f: + f.write("Starting debug test...\n") + try: + # Patch PIL.Image.open directly + with patch('PIL.Image.open') as mock_open, \ + patch('pathlib.Path.exists', return_value=True): + # Setup mock image and exif data + mock_img = MagicMock() + + tag_map = {v: k for k, v in ExifTags.TAGS.items()} + + exif_dict = { + tag_map["DateTimeOriginal"]: "2023:01:01 12:00:00", + tag_map["Make"]: "Canon", + tag_map["Model"]: "Canon EOS R5", + tag_map["LensModel"]: "RF 24-70mm F2.8L IS USM", + tag_map["ISOSpeedRatings"]: 100, + tag_map["FNumber"]: (28, 10), + tag_map["ExposureTime"]: (1, 200), + tag_map["FocalLength"]: (50, 1), + } + + mock_img._getexif.return_value = exif_dict + mock_open.return_value = mock_img + + f.write("Calling get_exif_data...\n") + result = get_exif_data(Path("dummy.jpg")) + f.write(f"Result Summary: {json.dumps(result.get('summary', {}), indent=2)}\n") + f.write(f"Result Full Keys: {list(result.get('full', {}).keys())}\n") + + summary = result["summary"] + assert summary["Date Taken"] == "2023:01:01 12:00:00" + assert summary["Camera"] == "Canon EOS R5" + assert summary["Lens"] == "RF 24-70mm F2.8L IS USM" + assert summary["ISO"] == "100" + assert summary["Aperture"] == "f/2.8" + assert summary["Shutter Speed"] == "1/200s" + assert summary["Focal Length"] == "50mm" + + f.write("Test PASSED\n") + except Exception as e: + f.write("Test FAILED\n") + import traceback + traceback.print_exc(file=f) + +if __name__ == "__main__": + debug_test() diff --git a/faststack/tests/dummy_images/faststack.json b/faststack/tests/dummy_images/faststack.json index fead850..63a91b1 100644 --- a/faststack/tests/dummy_images/faststack.json +++ b/faststack/tests/dummy_images/faststack.json @@ -1,18 +1,18 @@ -{ - "version": 2, - "last_index": 0, - "entries": { - "test": { - "stack_id": null, - "stacked": false, - "stacked_date": null, - "uploaded": false, - "uploaded_date": null, - "edited": false, - "edited_date": null, - "restacked": false, - "restacked_date": null - } - }, - "stacks": [] +{ + "version": 2, + "last_index": 0, + "entries": { + "test": { + "stack_id": null, + "stacked": false, + "stacked_date": null, + "uploaded": false, + "uploaded_date": null, + "edited": false, + "edited_date": null, + "restacked": false, + "restacked_date": null + } + }, + "stacks": [] } \ No newline at end of file diff --git a/faststack/tests/manual_test_error_handling.py b/faststack/tests/manual_test_error_handling.py index 7179e65..3575d57 100644 --- a/faststack/tests/manual_test_error_handling.py +++ b/faststack/tests/manual_test_error_handling.py @@ -1,90 +1,90 @@ - -import sys -import unittest -from unittest.mock import MagicMock, patch -import numpy as np -from pathlib import Path -import os -import logging - -# Configure logging to swallow output -logging.basicConfig(level=logging.CRITICAL) - -def test_load_image_raises(): - print("Running test_load_image_raises...") - try: - from faststack.imaging.editor import ImageEditor - editor = ImageEditor() - - # Patch Image.open to raise an exception - with patch('PIL.Image.open', side_effect=OSError("Mocked file error")): - with patch.dict(sys.modules, {'cv2': MagicMock()}): - sys.modules['cv2'].imread.return_value = None - - try: - editor.load_image("non_existent_file.jpg") - print("FAILURE: load_image did NOT raise exception") - return False - except OSError as e: - if "Mocked file error" in str(e): - print("SUCCESS: load_image raised expected exception") - return True - else: - print(f"FAILURE: load_image raised wrong exception: {e}") - return False - except Exception as e: - print(f"FAILURE: load_image raised unexpected exception type: {type(e)} {e}") - return False - except ImportError as e: - print(f"ImportError in test setup: {e}") - return False - except Exception as e: - print(f"Unexpected error in test setup: {e}") - return False - -def test_save_image_raises(): - print("Running test_save_image_raises...") - try: - from faststack.imaging.editor import ImageEditor - editor = ImageEditor() - editor.float_image = np.zeros((10, 10, 3), dtype=np.float32) - editor.current_filepath = Path("fake_path.jpg") - - with patch('faststack.imaging.editor.create_backup_file', return_value=Path("backup.jpg")): - mock_img = MagicMock() - # fail ANY save call - mock_img.save.side_effect = PermissionError("Mocked save error") - - with patch('PIL.Image.fromarray', return_value=mock_img): - try: - editor.save_image() - print("FAILURE: save_image did NOT raise exception") - return False - except PermissionError as e: - if "Mocked save error" in str(e): - print("SUCCESS: save_image raised expected exception") - return True - else: - print(f"FAILURE: save_image raised wrong exception: {e}") - return False - except Exception as e: - print(f"FAILURE: save_image raised unexpected exception type: {type(e)} {e}") - return False - except Exception as e: - print(f"Unexpected error in test setup: {e}") - return False - -if __name__ == '__main__': - # Ensure parent path in sys.path - root_dir = Path(__file__).parent.parent.parent - if str(root_dir) not in sys.path: - sys.path.insert(0, str(root_dir)) - - success = True - if not test_load_image_raises(): success = False - print("-" * 20) - if not test_save_image_raises(): success = False - - if not success: - sys.exit(1) - print("ALL TESTS PASSED") + +import sys +import unittest +from unittest.mock import MagicMock, patch +import numpy as np +from pathlib import Path +import os +import logging + +# Configure logging to swallow output +logging.basicConfig(level=logging.CRITICAL) + +def test_load_image_raises(): + print("Running test_load_image_raises...") + try: + from faststack.imaging.editor import ImageEditor + editor = ImageEditor() + + # Patch Image.open to raise an exception + with patch('PIL.Image.open', side_effect=OSError("Mocked file error")): + with patch.dict(sys.modules, {'cv2': MagicMock()}): + sys.modules['cv2'].imread.return_value = None + + try: + editor.load_image("non_existent_file.jpg") + print("FAILURE: load_image did NOT raise exception") + return False + except OSError as e: + if "Mocked file error" in str(e): + print("SUCCESS: load_image raised expected exception") + return True + else: + print(f"FAILURE: load_image raised wrong exception: {e}") + return False + except Exception as e: + print(f"FAILURE: load_image raised unexpected exception type: {type(e)} {e}") + return False + except ImportError as e: + print(f"ImportError in test setup: {e}") + return False + except Exception as e: + print(f"Unexpected error in test setup: {e}") + return False + +def test_save_image_raises(): + print("Running test_save_image_raises...") + try: + from faststack.imaging.editor import ImageEditor + editor = ImageEditor() + editor.float_image = np.zeros((10, 10, 3), dtype=np.float32) + editor.current_filepath = Path("fake_path.jpg") + + with patch('faststack.imaging.editor.create_backup_file', return_value=Path("backup.jpg")): + mock_img = MagicMock() + # fail ANY save call + mock_img.save.side_effect = PermissionError("Mocked save error") + + with patch('PIL.Image.fromarray', return_value=mock_img): + try: + editor.save_image() + print("FAILURE: save_image did NOT raise exception") + return False + except PermissionError as e: + if "Mocked save error" in str(e): + print("SUCCESS: save_image raised expected exception") + return True + else: + print(f"FAILURE: save_image raised wrong exception: {e}") + return False + except Exception as e: + print(f"FAILURE: save_image raised unexpected exception type: {type(e)} {e}") + return False + except Exception as e: + print(f"Unexpected error in test setup: {e}") + return False + +if __name__ == '__main__': + # Ensure parent path in sys.path + root_dir = Path(__file__).parent.parent.parent + if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) + + success = True + if not test_load_image_raises(): success = False + print("-" * 20) + if not test_save_image_raises(): success = False + + if not success: + sys.exit(1) + print("ALL TESTS PASSED") diff --git a/faststack/tests/mini_test.py b/faststack/tests/mini_test.py index 56ca8e3..2c42aec 100644 --- a/faststack/tests/mini_test.py +++ b/faststack/tests/mini_test.py @@ -1,47 +1,47 @@ - -import sys -from unittest.mock import MagicMock, patch -import os -import tempfile - -print("START_TEST") -try: - from faststack.imaging.editor import ImageEditor - editor = ImageEditor() - - # Test 1: Missing file raises FileNotFoundError - print("Test 1: Missing file...") - try: - editor.load_image("non_existent_file.jpg") - print("FAIL 1: No exception raised for missing file") - except FileNotFoundError: - print("PASS 1: Caught FileNotFoundError") - except Exception as e: - print(f"FAIL 1: Unexpected exception: {type(e)} {e}") - - # Test 2: Existing file but load fails (OSError) - print("Test 2: Bad file load...") - with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: - tmp_name = tmp.name - - try: - with patch('PIL.Image.open', side_effect=OSError("FAIL_PIL")): - with patch.dict(sys.modules, {'cv2': MagicMock()}): - sys.modules['cv2'].imread.return_value = None - try: - editor.load_image(tmp_name) - print("FAIL 2: No exception raised for bad load") - except OSError as e: - if "FAIL_PIL" in str(e): - print("PASS 2: Caught expected OSError") - else: - print(f"FAIL 2: Wrong error: {e}") - except Exception as e: - print(f"FAIL 2: Unexpected exception: {type(e)} {e}") - finally: - if os.path.exists(tmp_name): - os.remove(tmp_name) - -except Exception as e: - print(f"CRASH: {e}") -print("END_TEST") + +import sys +from unittest.mock import MagicMock, patch +import os +import tempfile + +print("START_TEST") +try: + from faststack.imaging.editor import ImageEditor + editor = ImageEditor() + + # Test 1: Missing file raises FileNotFoundError + print("Test 1: Missing file...") + try: + editor.load_image("non_existent_file.jpg") + print("FAIL 1: No exception raised for missing file") + except FileNotFoundError: + print("PASS 1: Caught FileNotFoundError") + except Exception as e: + print(f"FAIL 1: Unexpected exception: {type(e)} {e}") + + # Test 2: Existing file but load fails (OSError) + print("Test 2: Bad file load...") + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + tmp_name = tmp.name + + try: + with patch('PIL.Image.open', side_effect=OSError("FAIL_PIL")): + with patch.dict(sys.modules, {'cv2': MagicMock()}): + sys.modules['cv2'].imread.return_value = None + try: + editor.load_image(tmp_name) + print("FAIL 2: No exception raised for bad load") + except OSError as e: + if "FAIL_PIL" in str(e): + print("PASS 2: Caught expected OSError") + else: + print(f"FAIL 2: Wrong error: {e}") + except Exception as e: + print(f"FAIL 2: Unexpected exception: {type(e)} {e}") + finally: + if os.path.exists(tmp_name): + os.remove(tmp_name) + +except Exception as e: + print(f"CRASH: {e}") +print("END_TEST") diff --git a/faststack/tests/reproduce_exif_bug.py b/faststack/tests/reproduce_exif_bug.py index f888cc5..828e16d 100644 --- a/faststack/tests/reproduce_exif_bug.py +++ b/faststack/tests/reproduce_exif_bug.py @@ -1,58 +1,58 @@ - -import sys -from unittest.mock import MagicMock - -# Hardcode path to project root -sys.path.insert(0, r"c:\code\faststack") -sys.modules['cv2'] = MagicMock() - -import unittest -from unittest.mock import patch -from PIL import Image - -from faststack.imaging.editor import ImageEditor - -class TestExifReproduction(unittest.TestCase): - def setUp(self): - self.editor = ImageEditor() - # Create a dummy image for testing - self.editor.original_image = Image.new('RGB', (10, 10)) - self.editor._source_exif_bytes = b"original source exif" - - def test_tobytes_failure_drops_exif(self): - """Verify that a failure in tobytes() currently drops EXIF data.""" - mock_exif = MagicMock() - mock_exif.tobytes.side_effect = Exception("failed to serialize") - - # Patch Image.Exif to return our mock - with patch('PIL.Image.Exif', return_value=mock_exif): - res = self.editor._get_sanitized_exif_bytes() - - # DESIRED BEHAVIOR: It returns the original bytes if sanitization fails - self.assertEqual(res, b"original source exif") - - def test_missing_tobytes_drops_exif(self): - """Verify that missing tobytes() currently drops EXIF data.""" - mock_exif = MagicMock(spec=[]) # No tobytes - - with patch('PIL.Image.Exif', return_value=mock_exif): - res = self.editor._get_sanitized_exif_bytes() - # DESIRED BEHAVIOR: It returns the original bytes - self.assertEqual(res, b"original source exif") - -if __name__ == '__main__': - suite = unittest.TestLoader().loadTestsFromTestCase(TestExifReproduction) - result = unittest.TestResult() - suite.run(result) - - if result.wasSuccessful(): - print("Success!") - else: - print(f"FAILED with {len(result.failures)} failures and {len(result.errors)} errors") - for f in result.failures: - print("FAILURE in", f[0]) - print(f[1]) - for e in result.errors: - print("ERROR in", e[0]) - print(e[1]) - sys.exit(1) +import sys +import os +from unittest.mock import MagicMock + +# Add parent directory to path for standalone execution +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +sys.modules['cv2'] = MagicMock() + +import unittest +from unittest.mock import patch +from PIL import Image + +from faststack.imaging.editor import ImageEditor + +class TestExifReproduction(unittest.TestCase): + def setUp(self): + self.editor = ImageEditor() + # Create a dummy image for testing + self.editor.original_image = Image.new('RGB', (10, 10)) + self.editor._source_exif_bytes = b"original source exif" + + def test_tobytes_failure_drops_exif(self): + """Verify that a failure in tobytes() currently drops EXIF data.""" + mock_exif = MagicMock() + mock_exif.tobytes.side_effect = Exception("failed to serialize") + + # Patch Image.Exif to return our mock + with patch('PIL.Image.Exif', return_value=mock_exif): + res = self.editor._get_sanitized_exif_bytes() + + # DESIRED BEHAVIOR: It returns the original bytes if sanitization fails + self.assertEqual(res, b"original source exif") + + def test_missing_tobytes_drops_exif(self): + """Verify that missing tobytes() currently drops EXIF data.""" + mock_exif = MagicMock(spec=[]) # No tobytes + + with patch('PIL.Image.Exif', return_value=mock_exif): + res = self.editor._get_sanitized_exif_bytes() + # DESIRED BEHAVIOR: It returns the original bytes + self.assertEqual(res, b"original source exif") + +if __name__ == '__main__': + suite = unittest.TestLoader().loadTestsFromTestCase(TestExifReproduction) + result = unittest.TestResult() + suite.run(result) + + if result.wasSuccessful(): + print("Success!") + else: + print(f"FAILED with {len(result.failures)} failures and {len(result.errors)} errors") + for f in result.failures: + print("FAILURE in", f[0]) + print(f[1]) + for e in result.errors: + print("ERROR in", e[0]) + print(e[1]) + sys.exit(1) diff --git a/faststack/tests/run_loading_tests.py b/faststack/tests/run_loading_tests.py index cc4d15b..4d37940 100644 --- a/faststack/tests/run_loading_tests.py +++ b/faststack/tests/run_loading_tests.py @@ -1,26 +1,26 @@ -"""Debug script to run tests and capture full output.""" -import sys -import os - -# Change to faststack directory -os.chdir(os.path.dirname(os.path.abspath(__file__))) - -# Run the test -import unittest -loader = unittest.TestLoader() -suite = loader.discover('.', pattern='test_editor_loading.py') -runner = unittest.TextTestRunner(verbosity=2) -result = runner.run(suite) - -# Print summary -print(f"\n\nTests run: {result.testsRun}") -print(f"Failures: {len(result.failures)}") -print(f"Errors: {len(result.errors)}") - -for test, traceback in result.failures: - print(f"\nFAILURE: {test}") - print(traceback) - -for test, traceback in result.errors: - print(f"\nERROR: {test}") - print(traceback) +"""Debug script to run tests and capture full output.""" +import sys +import os + +# Change to faststack directory +os.chdir(os.path.dirname(os.path.abspath(__file__))) + +# Run the test +import unittest +loader = unittest.TestLoader() +suite = loader.discover('.', pattern='test_editor_loading.py') +runner = unittest.TextTestRunner(verbosity=2) +result = runner.run(suite) + +# Print summary +print(f"\n\nTests run: {result.testsRun}") +print(f"Failures: {len(result.failures)}") +print(f"Errors: {len(result.errors)}") + +for test, traceback in result.failures: + print(f"\nFAILURE: {test}") + print(traceback) + +for test, traceback in result.errors: + print(f"\nERROR: {test}") + print(traceback) diff --git a/faststack/tests/test_auto_levels.py b/faststack/tests/test_auto_levels.py index a6ed3b9..75ccf95 100644 --- a/faststack/tests/test_auto_levels.py +++ b/faststack/tests/test_auto_levels.py @@ -1,137 +1,137 @@ - -import pytest -import numpy as np -from PIL import Image -from faststack.imaging.editor import ImageEditor - - -def test_auto_levels_pins_highlights_if_clipped(): - editor = ImageEditor() - # 10x10 image - w, h = 10, 10 - arr = np.zeros((h, w, 3), dtype=np.uint8) - arr[:] = 100 - - # Clip Blue: Set last pixel to 255 - arr[9, 9, 2] = 255 - - img = Image.fromarray(arr, 'RGB') - editor.original_image = img - editor._preview_image = img - - # Use threshold 0.0 to make p_low deterministic (min value) - # This prevents fragility with per-channel percentiles on small arrays - blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) - - # 1 pixel of 255 in 100 is 1%. Eps (from threshold 0.0) would be 0.0. - # Actually logic is eps = min(threshold, 0.01). If threshold 0.0, eps=0.0. - # 1% > 0.0% -> Pins. - - assert p_high == 255.0 - assert whites == 0.0 - - # p_low should be the strict minimum (100) - assert p_low == 100.0 - -def test_auto_levels_pins_shadows_if_clipped(): - editor = ImageEditor() - w, h = 10, 10 - arr = np.zeros((h, w, 3), dtype=np.uint8) - arr[:] = 100 - - # Clip Red shadow: 1 pixel at 0. - arr[0, 0, 0] = 0 - - img = Image.fromarray(arr, 'RGB') - editor.original_image = img - editor._preview_image = img - - # Threshold 0.0 -> eps=0.0. 1% detected > 0.0 -> Pins. - blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) - - assert p_low == 0.0 - assert blacks == 0.0 - - # Whites should be normal (max is 100) - assert p_high == 100.0 - assert whites == (255.0 - 100.0) / 40.0 - -def test_auto_levels_tiny_hot_pixel_ignored(): - """ - Verify that a very small number of clipped pixels (below eps check) - does NOT trigger pinning, and does NOT get picked up by percentile - if strictly below the threshold. - """ - editor = ImageEditor() - # 200x200 = 40,000 pixels - w, h = 200, 200 - arr = np.zeros((h, w, 3), dtype=np.uint8) - - # Base: 150 - arr[:] = 150 - - # Set top ~2.5% pixels to 200 (1000 pixels) - # This ensures the 99.9th percentile lands on 200, not 150. - # Flattening for easier assignment - flat = arr.reshape(-1, 3) - flat[0:1000, :] = 200 - arr = flat.reshape(h, w, 3) - - # Add ONE hot pixel at 255 in Red channel - arr[0, 0, 0] = 255 - - img = Image.fromarray(arr, 'RGB') - editor.original_image = img - editor._preview_image = img - - # Threshold 0.1%. Eps = 0.01%. - # 1 pixel / 40000 = 0.0025%. - # 0.0025% < 0.01%. Should NOT pin. - - blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) - - # p_high should be 200 (from the 200-level plateau), ignoring the 255. - assert p_high == 200.0 - assert p_high != 255.0 # Check explicitly not pinned - assert whites > 0.0 - -def test_auto_levels_degenerate_image(): - editor = ImageEditor() - w, h = 10, 10 - arr = np.zeros((h, w, 3), dtype=np.uint8) - arr[:] = 128 - - img = Image.fromarray(arr, 'RGB') - editor.original_image = img - editor._preview_image = img - - blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) - - assert p_high == 128.0 - assert p_low == 128.0 - assert blacks == 0.0 - assert whites == 0.0 - -def test_auto_levels_normal_range(): - editor = ImageEditor() - w, h = 10, 10 - arr = np.zeros((h, w, 3), dtype=np.uint8) - arr[:] = 128 - arr[0, 0, :] = 50 # Low - arr[9, 9, :] = 200 # High - - img = Image.fromarray(arr, 'RGB') - editor.original_image = img - editor._preview_image = img - - blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) - - assert p_high == 200.0 - assert p_low == 50.0 - - assert p_high != 255.0 # Not pinned - assert p_low != 0.0 # Not pinned - - assert whites == (255.0 - 200.0) / 40.0 - assert blacks == -50.0 / 40.0 - + +import pytest +import numpy as np +from PIL import Image +from faststack.imaging.editor import ImageEditor + + +def test_auto_levels_pins_highlights_if_clipped(): + editor = ImageEditor() + # 10x10 image + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 100 + + # Clip Blue: Set last pixel to 255 + arr[9, 9, 2] = 255 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + # Use threshold 0.0 to make p_low deterministic (min value) + # This prevents fragility with per-channel percentiles on small arrays + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) + + # 1 pixel of 255 in 100 is 1%. Eps (from threshold 0.0) would be 0.0. + # Actually logic is eps = min(threshold, 0.01). If threshold 0.0, eps=0.0. + # 1% > 0.0% -> Pins. + + assert p_high == 255.0 + assert whites == 0.0 + + # p_low should be the strict minimum (100) + assert p_low == 100.0 + +def test_auto_levels_pins_shadows_if_clipped(): + editor = ImageEditor() + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 100 + + # Clip Red shadow: 1 pixel at 0. + arr[0, 0, 0] = 0 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + # Threshold 0.0 -> eps=0.0. 1% detected > 0.0 -> Pins. + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) + + assert p_low == 0.0 + assert blacks == 0.0 + + # Whites should be normal (max is 100) + assert p_high == 100.0 + assert whites == (255.0 - 100.0) / 40.0 + +def test_auto_levels_tiny_hot_pixel_ignored(): + """ + Verify that a very small number of clipped pixels (below eps check) + does NOT trigger pinning, and does NOT get picked up by percentile + if strictly below the threshold. + """ + editor = ImageEditor() + # 200x200 = 40,000 pixels + w, h = 200, 200 + arr = np.zeros((h, w, 3), dtype=np.uint8) + + # Base: 150 + arr[:] = 150 + + # Set top ~2.5% pixels to 200 (1000 pixels) + # This ensures the 99.9th percentile lands on 200, not 150. + # Flattening for easier assignment + flat = arr.reshape(-1, 3) + flat[0:1000, :] = 200 + arr = flat.reshape(h, w, 3) + + # Add ONE hot pixel at 255 in Red channel + arr[0, 0, 0] = 255 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + # Threshold 0.1%. Eps = 0.01%. + # 1 pixel / 40000 = 0.0025%. + # 0.0025% < 0.01%. Should NOT pin. + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) + + # p_high should be 200 (from the 200-level plateau), ignoring the 255. + assert p_high == 200.0 + assert p_high != 255.0 # Check explicitly not pinned + assert whites > 0.0 + +def test_auto_levels_degenerate_image(): + editor = ImageEditor() + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 128 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) + + assert p_high == 128.0 + assert p_low == 128.0 + assert blacks == 0.0 + assert whites == 0.0 + +def test_auto_levels_normal_range(): + editor = ImageEditor() + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 128 + arr[0, 0, :] = 50 # Low + arr[9, 9, :] = 200 # High + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) + + assert p_high == 200.0 + assert p_low == 50.0 + + assert p_high != 255.0 # Not pinned + assert p_low != 0.0 # Not pinned + + assert whites == (255.0 - 200.0) / 40.0 + assert blacks == -50.0 / 40.0 + diff --git a/faststack/tests/test_cache.py b/faststack/tests/test_cache.py index fdc8652..1a871c0 100644 --- a/faststack/tests/test_cache.py +++ b/faststack/tests/test_cache.py @@ -1,61 +1,61 @@ -"""Tests for the byte-aware LRU cache.""" - -import pytest - -from faststack.imaging.cache import ByteLRUCache - -class MockItem: - """A mock object with a settable size.""" - def __init__(self, size: int): - self._size = size - - def __sizeof__(self) -> int: - return self._size - -def test_cache_init(): - """Tests cache initialization.""" - cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) - assert cache.max_bytes == 1000 - assert cache.currsize == 0 - -def test_cache_add_items(): - """Tests adding items and tracking size.""" - cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) - cache["a"] = MockItem(20) - assert cache.currsize == 20 - cache["b"] = MockItem(30) - assert cache.currsize == 50 - assert "a" in cache - assert "b" in cache - -def test_cache_eviction(): - """Tests that the least recently used item is evicted when full.""" - cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) - cache["a"] = MockItem(50) # a is oldest - cache["b"] = MockItem(40) - cache["c"] = MockItem(30) # This should evict 'a' - - assert "a" not in cache - assert "b" in cache - assert "c" in cache - assert cache.currsize == 70 # 40 + 30 - - cache["d"] = MockItem(50) # This should evict 'b' - assert "b" not in cache - assert "c" in cache - assert "d" in cache - assert cache.currsize == 80 # 30 + 50 - -def test_cache_update_item(): - """Tests that updating an item adjusts the cache size.""" - cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) - cache["a"] = MockItem(20) - assert cache.currsize == 20 - - # Replace with a larger item - cache["a"] = MockItem(50) - assert cache.currsize == 50 - - # Replace with a smaller item - cache["a"] = MockItem(10) - assert cache.currsize == 10 +"""Tests for the byte-aware LRU cache.""" + +import pytest + +from faststack.imaging.cache import ByteLRUCache + +class MockItem: + """A mock object with a settable size.""" + def __init__(self, size: int): + self._size = size + + def __sizeof__(self) -> int: + return self._size + +def test_cache_init(): + """Tests cache initialization.""" + cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) + assert cache.max_bytes == 1000 + assert cache.currsize == 0 + +def test_cache_add_items(): + """Tests adding items and tracking size.""" + cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) + cache["a"] = MockItem(20) + assert cache.currsize == 20 + cache["b"] = MockItem(30) + assert cache.currsize == 50 + assert "a" in cache + assert "b" in cache + +def test_cache_eviction(): + """Tests that the least recently used item is evicted when full.""" + cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) + cache["a"] = MockItem(50) # a is oldest + cache["b"] = MockItem(40) + cache["c"] = MockItem(30) # This should evict 'a' + + assert "a" not in cache + assert "b" in cache + assert "c" in cache + assert cache.currsize == 70 # 40 + 30 + + cache["d"] = MockItem(50) # This should evict 'b' + assert "b" not in cache + assert "c" in cache + assert "d" in cache + assert cache.currsize == 80 # 30 + 50 + +def test_cache_update_item(): + """Tests that updating an item adjusts the cache size.""" + cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) + cache["a"] = MockItem(20) + assert cache.currsize == 20 + + # Replace with a larger item + cache["a"] = MockItem(50) + assert cache.currsize == 50 + + # Replace with a smaller item + cache["a"] = MockItem(10) + assert cache.currsize == 10 diff --git a/faststack/tests/test_cache_invalidation.py b/faststack/tests/test_cache_invalidation.py index 8378349..3f0da9f 100644 --- a/faststack/tests/test_cache_invalidation.py +++ b/faststack/tests/test_cache_invalidation.py @@ -1,75 +1,75 @@ - -import sys -import os -import time -import shutil -from pathlib import Path -import numpy as np -from PIL import Image - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from faststack.imaging.editor import ImageEditor - -def test_cache_stability(): - """Verify that cache hash remains stable when reloading the same unmodified file.""" - - # Setup dummy image - test_dir = Path("tests/dummy_images_cache") - test_dir.mkdir(parents=True, exist_ok=True) - - img_path = test_dir / "test_cache.jpg" - - # Create a dummy image - arr = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) - Image.fromarray(arr).save(img_path) - - editor = ImageEditor() - - # 1. Load image and get hash - editor.load_image(str(img_path)) - hash1 = editor._get_upstream_edits_hash(editor.current_edits) - - # 2. Reload same image (simulate switching back and forth) - # Even if we create a new editor or reload, if the file hasn't changed, - # the ideal cache key for *content-dependent* heavy ops should be stable. - # However, the current implementation uses id(self.float_image), so we expect this to change - # if we reload, because float_image will be a new object. - - editor.load_image(str(img_path)) - hash2 = editor._get_upstream_edits_hash(editor.current_edits) - - print(f"Hash 1: {hash1}") - print(f"Hash 2: {hash2}") - - # Current behavior: Hashes DIFFERENT because id() changed - # Desired behavior: Hashes SAME because content/mtime is same - - if hash1 == hash2: - print("PASS: Hash is stable across reloads.") - else: - print("FAIL: Hash changed across reloads (unnecessary invalidation).") - - # 3. Touch file to update mtime - time.sleep(1.1) # Ensure mtime changes (some systems have 1s resolution) - img_path.touch() - - editor.load_image(str(img_path)) - hash3 = editor._get_upstream_edits_hash(editor.current_edits) - - print(f"Hash 3 (after touch): {hash3}") - - if hash3 != hash2: - print("PASS: Hash changed after mtime update.") - else: - print("FAIL: Hash did NOT change after mtime update.") - - # Cleanup - try: - shutil.rmtree(test_dir) - except: - pass - -if __name__ == "__main__": - test_cache_stability() + +import sys +import os +import time +import shutil +from pathlib import Path +import numpy as np +from PIL import Image + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from faststack.imaging.editor import ImageEditor + +def test_cache_stability(): + """Verify that cache hash remains stable when reloading the same unmodified file.""" + + # Setup dummy image + test_dir = Path("tests/dummy_images_cache") + test_dir.mkdir(parents=True, exist_ok=True) + + img_path = test_dir / "test_cache.jpg" + + # Create a dummy image + arr = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) + Image.fromarray(arr).save(img_path) + + editor = ImageEditor() + + # 1. Load image and get hash + editor.load_image(str(img_path)) + hash1 = editor._get_upstream_edits_hash(editor.current_edits) + + # 2. Reload same image (simulate switching back and forth) + # Even if we create a new editor or reload, if the file hasn't changed, + # the ideal cache key for *content-dependent* heavy ops should be stable. + # However, the current implementation uses id(self.float_image), so we expect this to change + # if we reload, because float_image will be a new object. + + editor.load_image(str(img_path)) + hash2 = editor._get_upstream_edits_hash(editor.current_edits) + + print(f"Hash 1: {hash1}") + print(f"Hash 2: {hash2}") + + # Current behavior: Hashes DIFFERENT because id() changed + # Desired behavior: Hashes SAME because content/mtime is same + + if hash1 == hash2: + print("PASS: Hash is stable across reloads.") + else: + print("FAIL: Hash changed across reloads (unnecessary invalidation).") + + # 3. Touch file to update mtime + time.sleep(1.1) # Ensure mtime changes (some systems have 1s resolution) + img_path.touch() + + editor.load_image(str(img_path)) + hash3 = editor._get_upstream_edits_hash(editor.current_edits) + + print(f"Hash 3 (after touch): {hash3}") + + if hash3 != hash2: + print("PASS: Hash changed after mtime update.") + else: + print("FAIL: Hash did NOT change after mtime update.") + + # Cleanup + try: + shutil.rmtree(test_dir) + except: + pass + +if __name__ == "__main__": + test_cache_stability() diff --git a/faststack/tests/test_config_setters.py b/faststack/tests/test_config_setters.py index e165bae..a3351f0 100644 --- a/faststack/tests/test_config_setters.py +++ b/faststack/tests/test_config_setters.py @@ -1,172 +1,172 @@ - -import unittest -import sys -from unittest.mock import MagicMock, patch - -# --- MOCK SETUP --- - -# Mock PySide6 -mock_pyside = MagicMock() -mock_pyside.__path__ = [] -mock_pyside.__spec__ = MagicMock() - -# Define a real class for QObject so inheritance works as expected -class MockQObject: - def __init__(self, parent=None): - pass - def property(self, name): return None - def setProperty(self, name, value): pass -mock_pyside.QObject = MockQObject - -# Mock Slot/Signal decorators to just return the function/dummy -def MockSlot(*args, **kwargs): - def decorator(func): - return func - return decorator -mock_pyside.Slot = MockSlot -mock_pyside.Signal = MagicMock() - -sys.modules['PySide6'] = mock_pyside -sys.modules['PySide6.QtCore'] = mock_pyside -sys.modules['PySide6.QtGui'] = mock_pyside -sys.modules['PySide6.QtQuick'] = mock_pyside -sys.modules['PySide6.QtWidgets'] = mock_pyside -sys.modules['PySide6.QtQml'] = mock_pyside - -# Mock PIL -mock_pil = MagicMock() -mock_pil.__path__ = [] -mock_pil.Image = MagicMock() -sys.modules['PIL'] = mock_pil -sys.modules['PIL.Image'] = mock_pil.Image - -# Mock numpy -sys.modules['numpy'] = MagicMock() - -# Mock faststack.config -mock_config_module = MagicMock() -mock_config_obj = MagicMock() -mock_config_obj.getfloat.return_value = 0.1 -mock_config_obj.getboolean.return_value = False -mock_config_module.config = mock_config_obj -sys.modules['faststack.config'] = mock_config_module - -# Mock faststack modules -sys.modules['faststack.ui.provider'] = MagicMock() -sys.modules['faststack.models'] = MagicMock() -sys.modules['faststack.logging_setup'] = MagicMock() -sys.modules['faststack.io.indexer'] = MagicMock() -sys.modules['faststack.io.sidecar'] = MagicMock() -sys.modules['faststack.io.watcher'] = MagicMock() -sys.modules['faststack.io.helicon'] = MagicMock() -sys.modules['faststack.io.executable_validator'] = MagicMock() -sys.modules['faststack.imaging.cache'] = MagicMock() -sys.modules['faststack.imaging.prefetch'] = MagicMock() -sys.modules['faststack.ui.keystrokes'] = MagicMock() -sys.modules['faststack.imaging.editor'] = MagicMock() -sys.modules['faststack.imaging.metadata'] = MagicMock() - -import faststack -print(f"DEBUG: faststack imported from: {faststack.__file__}") -print(f"DEBUG: sys.path: {sys.path}") - -# Import AppController AFTER mocking -from faststack.app import AppController -from faststack.config import config - -class TestConfigSetters(unittest.TestCase): - def setUp(self): - # Apply patches using start/addCleanup - self.patches = [ - patch('faststack.app.QTimer'), - patch('faststack.app.DecodedImage'), - patch('faststack.app.ImageEditor'), - patch('faststack.app.Prefetcher'), - patch('faststack.app.ByteLRUCache'), - patch('faststack.app.SidecarManager'), - patch('faststack.app.Keybinder'), - patch('faststack.app.Path') - ] - - for p in self.patches: - p.start() - self.addCleanup(p.stop) - - # Initialize controller - # Mock Path for init argument - mock_path_cls = self.patches[-1].target # access the mock object ? NO, p.start returns mock - # Ideally capture the return of start() - - # Simpler: just instantiate. The mocks are active. - # But we need to pass a mock path to __init__ - self.controller = AppController(MagicMock(), MagicMock()) - - def test_set_auto_level_clipping_threshold(self): - config.set.reset_mock() - config.save.reset_mock() - - # Pre-verify default value (set in __init__ using config.getfloat mock) - self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 0.1) - - new_val = 0.5 - self.controller.set_auto_level_clipping_threshold(new_val) - - # Verify - # Verify normal set - self.assertEqual(self.controller.get_auto_level_clipping_threshold(), new_val) - # Should be stringified "0.5" - config.set.assert_called_with('core', 'auto_level_threshold', "0.5") - config.save.assert_called_once() - - # Verify Clamping (High) - config.set.reset_mock() - self.controller.set_auto_level_clipping_threshold(1.5) - self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 1.0) - config.set.assert_called_with('core', 'auto_level_threshold', "1") - - # Verify Clamping (Low) - config.set.reset_mock() - self.controller.set_auto_level_clipping_threshold(-0.1) - self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 0.0) - config.set.assert_called_with('core', 'auto_level_threshold', "0") - - def test_set_auto_level_strength(self): - config.set.reset_mock() - config.save.reset_mock() - - # Default was 1.0 in code, but our mock config.getfloat returns 0.1 - # AppController: self.auto_level_strength = config.getfloat(..., 1.0) - # Mock config.getfloat returns 0.1 always as setup above. - - new_val = 0.8 - self.controller.set_auto_level_strength(new_val) - - self.assertEqual(self.controller.get_auto_level_strength(), new_val) - config.set.assert_called_with('core', 'auto_level_strength', "0.8") - config.save.assert_called_once() - - # Verify Clamping - config.set.reset_mock() - self.controller.set_auto_level_strength(2.0) - self.assertEqual(self.controller.get_auto_level_strength(), 1.0) - config.set.assert_called_with('core', 'auto_level_strength', "1") - - def test_set_auto_level_strength_auto(self): - config.set.reset_mock() - config.save.reset_mock() - - new_val = True - self.controller.set_auto_level_strength_auto(new_val) - - self.assertEqual(self.controller.get_auto_level_strength_auto(), new_val) - # Should be normalized "true" string - config.set.assert_called_with('core', 'auto_level_strength_auto', "true") - config.save.assert_called_once() - - # Test False - config.set.reset_mock() - self.controller.set_auto_level_strength_auto(False) - config.set.assert_called_with('core', 'auto_level_strength_auto', "false") - -if __name__ == '__main__': - unittest.main() + +import unittest +import sys +from unittest.mock import MagicMock, patch + +# --- MOCK SETUP --- + +# Mock PySide6 +mock_pyside = MagicMock() +mock_pyside.__path__ = [] +mock_pyside.__spec__ = MagicMock() + +# Define a real class for QObject so inheritance works as expected +class MockQObject: + def __init__(self, parent=None): + pass + def property(self, name): return None + def setProperty(self, name, value): pass +mock_pyside.QObject = MockQObject + +# Mock Slot/Signal decorators to just return the function/dummy +def MockSlot(*args, **kwargs): + def decorator(func): + return func + return decorator +mock_pyside.Slot = MockSlot +mock_pyside.Signal = MagicMock() + +sys.modules['PySide6'] = mock_pyside +sys.modules['PySide6.QtCore'] = mock_pyside +sys.modules['PySide6.QtGui'] = mock_pyside +sys.modules['PySide6.QtQuick'] = mock_pyside +sys.modules['PySide6.QtWidgets'] = mock_pyside +sys.modules['PySide6.QtQml'] = mock_pyside + +# Mock PIL +mock_pil = MagicMock() +mock_pil.__path__ = [] +mock_pil.Image = MagicMock() +sys.modules['PIL'] = mock_pil +sys.modules['PIL.Image'] = mock_pil.Image + +# Mock numpy +sys.modules['numpy'] = MagicMock() + +# Mock faststack.config +mock_config_module = MagicMock() +mock_config_obj = MagicMock() +mock_config_obj.getfloat.return_value = 0.1 +mock_config_obj.getboolean.return_value = False +mock_config_module.config = mock_config_obj +sys.modules['faststack.config'] = mock_config_module + +# Mock faststack modules +sys.modules['faststack.ui.provider'] = MagicMock() +sys.modules['faststack.models'] = MagicMock() +sys.modules['faststack.logging_setup'] = MagicMock() +sys.modules['faststack.io.indexer'] = MagicMock() +sys.modules['faststack.io.sidecar'] = MagicMock() +sys.modules['faststack.io.watcher'] = MagicMock() +sys.modules['faststack.io.helicon'] = MagicMock() +sys.modules['faststack.io.executable_validator'] = MagicMock() +sys.modules['faststack.imaging.cache'] = MagicMock() +sys.modules['faststack.imaging.prefetch'] = MagicMock() +sys.modules['faststack.ui.keystrokes'] = MagicMock() +sys.modules['faststack.imaging.editor'] = MagicMock() +sys.modules['faststack.imaging.metadata'] = MagicMock() + +import faststack +print(f"DEBUG: faststack imported from: {faststack.__file__}") +print(f"DEBUG: sys.path: {sys.path}") + +# Import AppController AFTER mocking +from faststack.app import AppController +from faststack.config import config + +class TestConfigSetters(unittest.TestCase): + def setUp(self): + # Apply patches using start/addCleanup + self.patches = [ + patch('faststack.app.QTimer'), + patch('faststack.app.DecodedImage'), + patch('faststack.app.ImageEditor'), + patch('faststack.app.Prefetcher'), + patch('faststack.app.ByteLRUCache'), + patch('faststack.app.SidecarManager'), + patch('faststack.app.Keybinder'), + patch('faststack.app.Path') + ] + + for p in self.patches: + p.start() + self.addCleanup(p.stop) + + # Initialize controller + # Mock Path for init argument + mock_path_cls = self.patches[-1].target # access the mock object ? NO, p.start returns mock + # Ideally capture the return of start() + + # Simpler: just instantiate. The mocks are active. + # But we need to pass a mock path to __init__ + self.controller = AppController(MagicMock(), MagicMock()) + + def test_set_auto_level_clipping_threshold(self): + config.set.reset_mock() + config.save.reset_mock() + + # Pre-verify default value (set in __init__ using config.getfloat mock) + self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 0.1) + + new_val = 0.5 + self.controller.set_auto_level_clipping_threshold(new_val) + + # Verify + # Verify normal set + self.assertEqual(self.controller.get_auto_level_clipping_threshold(), new_val) + # Should be stringified "0.5" + config.set.assert_called_with('core', 'auto_level_threshold', "0.5") + config.save.assert_called_once() + + # Verify Clamping (High) + config.set.reset_mock() + self.controller.set_auto_level_clipping_threshold(1.5) + self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 1.0) + config.set.assert_called_with('core', 'auto_level_threshold', "1") + + # Verify Clamping (Low) + config.set.reset_mock() + self.controller.set_auto_level_clipping_threshold(-0.1) + self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 0.0) + config.set.assert_called_with('core', 'auto_level_threshold', "0") + + def test_set_auto_level_strength(self): + config.set.reset_mock() + config.save.reset_mock() + + # Default was 1.0 in code, but our mock config.getfloat returns 0.1 + # AppController: self.auto_level_strength = config.getfloat(..., 1.0) + # Mock config.getfloat returns 0.1 always as setup above. + + new_val = 0.8 + self.controller.set_auto_level_strength(new_val) + + self.assertEqual(self.controller.get_auto_level_strength(), new_val) + config.set.assert_called_with('core', 'auto_level_strength', "0.8") + config.save.assert_called_once() + + # Verify Clamping + config.set.reset_mock() + self.controller.set_auto_level_strength(2.0) + self.assertEqual(self.controller.get_auto_level_strength(), 1.0) + config.set.assert_called_with('core', 'auto_level_strength', "1") + + def test_set_auto_level_strength_auto(self): + config.set.reset_mock() + config.save.reset_mock() + + new_val = True + self.controller.set_auto_level_strength_auto(new_val) + + self.assertEqual(self.controller.get_auto_level_strength_auto(), new_val) + # Should be normalized "true" string + config.set.assert_called_with('core', 'auto_level_strength_auto', "true") + config.save.assert_called_once() + + # Test False + config.set.reset_mock() + self.controller.set_auto_level_strength_auto(False) + config.set.assert_called_with('core', 'auto_level_strength_auto', "false") + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_developed_sorting.py b/faststack/tests/test_developed_sorting.py index a11f71e..0271126 100644 --- a/faststack/tests/test_developed_sorting.py +++ b/faststack/tests/test_developed_sorting.py @@ -1,172 +1,172 @@ - -import os -import shutil -from pathlib import Path -from faststack.io.indexer import find_images - -def test_developed_sorting_adjacency(tmp_path): - """ - Test that developed images appear immediately after their base images, - regardless of their filesystem modification time. - """ - # Setup files: - # A.jpg (old) - # B.jpg (mid) - # A-developed.jpg (new) - - a_path = tmp_path / "A.jpg" - b_path = tmp_path / "B.jpg" - a_dev_path = tmp_path / "A-developed.jpg" - - a_path.touch() - os.utime(a_path, (1000, 1000)) - - b_path.touch() - os.utime(b_path, (2000, 2000)) - - a_dev_path.touch() - os.utime(a_dev_path, (3000, 3000)) - - images = find_images(tmp_path) - - # Expected order: A.jpg, A-developed.jpg, B.jpg - # Because A-developed matches A, and A is older than B. - # Without the fix, A-developed (3000) would be after B (2000). - - names = [im.path.name for im in images] - assert names == ["A.jpg", "A-developed.jpg", "B.jpg"] - -def test_developed_orphan_sorting(tmp_path): - """ - Test that a developed image without a base image is sorted by its own mtime. - """ - # A.jpg (1000) - # B-developed.jpg (2000) - orphan - # C.jpg (3000) - - (tmp_path / "A.jpg").touch() - os.utime(tmp_path / "A.jpg", (1000, 1000)) - - (tmp_path / "B-developed.jpg").touch() - os.utime(tmp_path / "B-developed.jpg", (2000, 2000)) - - (tmp_path / "C.jpg").touch() - os.utime(tmp_path / "C.jpg", (3000, 3000)) - - images = find_images(tmp_path) - names = [im.path.name for im in images] - assert names == ["A.jpg", "B-developed.jpg", "C.jpg"] - -def test_base_resolution_preference(tmp_path): - """ - Test that A-developed.jpg prefers A.jpg over A (1).jpg. - """ - (tmp_path / "A.jpg").touch() - os.utime(tmp_path / "A.jpg", (1000, 1000)) - - (tmp_path / "A (1).jpg").touch() - os.utime(tmp_path / "A (1).jpg", (1100, 1100)) - - (tmp_path / "A-developed.jpg").touch() - os.utime(tmp_path / "A-developed.jpg", (3000, 3000)) - - images = find_images(tmp_path) - names = [im.path.name for im in images] - - # A-developed should match A.jpg and stay at 1000 (after A.jpg) - # Order: A.jpg (1000), A-developed.jpg (1000 rank 1), A (1).jpg (1100) - assert names == ["A.jpg", "A-developed.jpg", "A (1).jpg"] - -def test_raw_pairing_with_developed(tmp_path): - """ - Test that A.orf pairs with A.jpg, not A-developed.jpg. - AND that A-developed still appears adjacent to A.jpg. - """ - a_jpg = tmp_path / "A.jpg" - a_orf = tmp_path / "A.orf" - a_dev = tmp_path / "A-developed.jpg" - - a_jpg.touch() - os.utime(a_jpg, (1000, 1000)) - - a_orf.touch() - os.utime(a_orf, (1000, 1000)) - - a_dev.touch() - os.utime(a_dev, (3000, 3000)) - - images = find_images(tmp_path) - - # Should have 2 images in list: - # 1. A.jpg (paired with A.orf) - # 2. A-developed.jpg (no pair) - - assert len(images) == 2 - - # Check pairing - img_a = next(im for im in images if im.path.name == "A.jpg") - img_dev = next(im for im in images if im.path.name == "A-developed.jpg") - - assert img_a.raw_pair is not None - assert img_a.raw_pair.name == "A.orf" - assert img_dev.raw_pair is None - - # Check ordering - names = [im.path.name for im in images] - assert names == ["A.jpg", "A-developed.jpg"] - -def test_case_insensitivity(tmp_path): - """Test that a-DEVELOPED.JPG matches A.jpg.""" - (tmp_path / "A.jpg").touch() - os.utime(tmp_path / "A.jpg", (1000, 1000)) - - (tmp_path / "a-DEVELOPED.JPG").touch() - os.utime(tmp_path / "a-DEVELOPED.JPG", (3000, 3000)) - - images = find_images(tmp_path) - names = [im.path.name for im in images] - # Note: casefold sorting might affect order if original names differ only in case, - # but here they are grouped by A.jpg's time. - assert names == ["A.jpg", "a-DEVELOPED.JPG"] - -def test_orphan_chain_prevention(tmp_path): - """ - A-developed (1).jpg should be treated as an orphan, - not matched to A-developed.jpg or A.jpg accidentally. - """ - (tmp_path / "A.jpg").touch() - os.utime(tmp_path / "A.jpg", (1000, 1000)) - - (tmp_path / "A-developed.jpg").touch() - os.utime(tmp_path / "A-developed.jpg", (1100, 1100)) - - # This one has -developed (1) suffix. - # Our simple logic should either not match it or match it to A (1).jpg if it existed. - # Without A (1).jpg, it should be an orphan. - (tmp_path / "A-developed (1).jpg").touch() - os.utime(tmp_path / "A-developed (1).jpg", (1200, 1200)) - - images = find_images(tmp_path) - names = [im.path.name for im in images] - assert names == ["A.jpg", "A-developed.jpg", "A-developed (1).jpg"] - -def test_tiebreaker_stability(tmp_path): - """ - Test that the tiebreaker (last element of the sorting key) - provides stable ordering when mtime and casefolded names are identical. - """ - p1 = tmp_path / "100.jpg" - p2 = tmp_path / "200.jpg" - - p1.touch() - os.utime(p1, (1000, 1000)) - - p2.touch() - os.utime(p2, (1000, 1000)) - - images = find_images(tmp_path) - names = [im.path.name for im in images] - - # Both have same mtime (1000) and priority 0. - # Tiebreakers are now name-based, so "100.jpg" comes before "200.jpg". - assert names == ["100.jpg", "200.jpg"] + +import os +import shutil +from pathlib import Path +from faststack.io.indexer import find_images + +def test_developed_sorting_adjacency(tmp_path): + """ + Test that developed images appear immediately after their base images, + regardless of their filesystem modification time. + """ + # Setup files: + # A.jpg (old) + # B.jpg (mid) + # A-developed.jpg (new) + + a_path = tmp_path / "A.jpg" + b_path = tmp_path / "B.jpg" + a_dev_path = tmp_path / "A-developed.jpg" + + a_path.touch() + os.utime(a_path, (1000, 1000)) + + b_path.touch() + os.utime(b_path, (2000, 2000)) + + a_dev_path.touch() + os.utime(a_dev_path, (3000, 3000)) + + images = find_images(tmp_path) + + # Expected order: A.jpg, A-developed.jpg, B.jpg + # Because A-developed matches A, and A is older than B. + # Without the fix, A-developed (3000) would be after B (2000). + + names = [im.path.name for im in images] + assert names == ["A.jpg", "A-developed.jpg", "B.jpg"] + +def test_developed_orphan_sorting(tmp_path): + """ + Test that a developed image without a base image is sorted by its own mtime. + """ + # A.jpg (1000) + # B-developed.jpg (2000) - orphan + # C.jpg (3000) + + (tmp_path / "A.jpg").touch() + os.utime(tmp_path / "A.jpg", (1000, 1000)) + + (tmp_path / "B-developed.jpg").touch() + os.utime(tmp_path / "B-developed.jpg", (2000, 2000)) + + (tmp_path / "C.jpg").touch() + os.utime(tmp_path / "C.jpg", (3000, 3000)) + + images = find_images(tmp_path) + names = [im.path.name for im in images] + assert names == ["A.jpg", "B-developed.jpg", "C.jpg"] + +def test_base_resolution_preference(tmp_path): + """ + Test that A-developed.jpg prefers A.jpg over A (1).jpg. + """ + (tmp_path / "A.jpg").touch() + os.utime(tmp_path / "A.jpg", (1000, 1000)) + + (tmp_path / "A (1).jpg").touch() + os.utime(tmp_path / "A (1).jpg", (1100, 1100)) + + (tmp_path / "A-developed.jpg").touch() + os.utime(tmp_path / "A-developed.jpg", (3000, 3000)) + + images = find_images(tmp_path) + names = [im.path.name for im in images] + + # A-developed should match A.jpg and stay at 1000 (after A.jpg) + # Order: A.jpg (1000), A-developed.jpg (1000 rank 1), A (1).jpg (1100) + assert names == ["A.jpg", "A-developed.jpg", "A (1).jpg"] + +def test_raw_pairing_with_developed(tmp_path): + """ + Test that A.orf pairs with A.jpg, not A-developed.jpg. + AND that A-developed still appears adjacent to A.jpg. + """ + a_jpg = tmp_path / "A.jpg" + a_orf = tmp_path / "A.orf" + a_dev = tmp_path / "A-developed.jpg" + + a_jpg.touch() + os.utime(a_jpg, (1000, 1000)) + + a_orf.touch() + os.utime(a_orf, (1000, 1000)) + + a_dev.touch() + os.utime(a_dev, (3000, 3000)) + + images = find_images(tmp_path) + + # Should have 2 images in list: + # 1. A.jpg (paired with A.orf) + # 2. A-developed.jpg (no pair) + + assert len(images) == 2 + + # Check pairing + img_a = next(im for im in images if im.path.name == "A.jpg") + img_dev = next(im for im in images if im.path.name == "A-developed.jpg") + + assert img_a.raw_pair is not None + assert img_a.raw_pair.name == "A.orf" + assert img_dev.raw_pair is None + + # Check ordering + names = [im.path.name for im in images] + assert names == ["A.jpg", "A-developed.jpg"] + +def test_case_insensitivity(tmp_path): + """Test that a-DEVELOPED.JPG matches A.jpg.""" + (tmp_path / "A.jpg").touch() + os.utime(tmp_path / "A.jpg", (1000, 1000)) + + (tmp_path / "a-DEVELOPED.JPG").touch() + os.utime(tmp_path / "a-DEVELOPED.JPG", (3000, 3000)) + + images = find_images(tmp_path) + names = [im.path.name for im in images] + # Note: casefold sorting might affect order if original names differ only in case, + # but here they are grouped by A.jpg's time. + assert names == ["A.jpg", "a-DEVELOPED.JPG"] + +def test_orphan_chain_prevention(tmp_path): + """ + A-developed (1).jpg should be treated as an orphan, + not matched to A-developed.jpg or A.jpg accidentally. + """ + (tmp_path / "A.jpg").touch() + os.utime(tmp_path / "A.jpg", (1000, 1000)) + + (tmp_path / "A-developed.jpg").touch() + os.utime(tmp_path / "A-developed.jpg", (1100, 1100)) + + # This one has -developed (1) suffix. + # Our simple logic should either not match it or match it to A (1).jpg if it existed. + # Without A (1).jpg, it should be an orphan. + (tmp_path / "A-developed (1).jpg").touch() + os.utime(tmp_path / "A-developed (1).jpg", (1200, 1200)) + + images = find_images(tmp_path) + names = [im.path.name for im in images] + assert names == ["A.jpg", "A-developed.jpg", "A-developed (1).jpg"] + +def test_tiebreaker_stability(tmp_path): + """ + Test that the tiebreaker (last element of the sorting key) + provides stable ordering when mtime and casefolded names are identical. + """ + p1 = tmp_path / "100.jpg" + p2 = tmp_path / "200.jpg" + + p1.touch() + os.utime(p1, (1000, 1000)) + + p2.touch() + os.utime(p2, (1000, 1000)) + + images = find_images(tmp_path) + names = [im.path.name for im in images] + + # Both have same mtime (1000) and priority 0. + # Tiebreakers are now name-based, so "100.jpg" comes before "200.jpg". + assert names == ["100.jpg", "200.jpg"] diff --git a/faststack/tests/test_drag_logic.py b/faststack/tests/test_drag_logic.py index d7acad0..18d396d 100644 --- a/faststack/tests/test_drag_logic.py +++ b/faststack/tests/test_drag_logic.py @@ -1,71 +1,71 @@ - -import pytest -from pathlib import Path -from unittest.mock import MagicMock, patch -from faststack.models import ImageFile - -# We can't easily instantiate AppController without complex mocks for QML engine, etc. -# So we test the logic extracted from start_drag_current_image. - -def get_drag_paths(image_files, current_index, existing_indices, current_edit_source_mode): - file_paths = [] - for idx in existing_indices: - img = image_files[idx] - - # logic from app.py - is_developed_artifact = img.path.stem.lower().endswith("-developed") - in_raw_mode = (current_edit_source_mode == "raw") - - if (in_raw_mode or is_developed_artifact) and img.developed_jpg_path.exists(): - file_paths.append(img.developed_jpg_path) - else: - file_paths.append(img.path) - return file_paths - -def test_drag_logic_jpeg_mode(tmp_path): - """In JPEG mode, prefer the original JPG even if -developed exists.""" - jpg_path = tmp_path / "A.jpg" - dev_path = tmp_path / "A-developed.jpg" - jpg_path.touch() - dev_path.touch() - - img = ImageFile(path=jpg_path) - # Note: developed_jpg_path is a property that calculates the path - - paths = get_drag_paths([img], 0, [0], "jpeg") - assert paths == [jpg_path] - -def test_drag_logic_raw_mode(tmp_path): - """In RAW mode, prefer -developed.jpg if it exists.""" - jpg_path = tmp_path / "A.jpg" - dev_path = tmp_path / "A-developed.jpg" - jpg_path.touch() - dev_path.touch() - - img = ImageFile(path=jpg_path) - - paths = get_drag_paths([img], 0, [0], "raw") - assert paths == [dev_path] - -def test_drag_logic_developed_artifact(tmp_path): - """If the dragged file IS a developed artifact, it should prefer -developed.jpg (itself).""" - # This case might be rare if the indexer handles it, but let's test the logic. - dev_path = tmp_path / "A-developed.jpg" - dev_path.touch() - - # In this case, developed_jpg_path will be "A-developed-developed.jpg" - # which won't exist. So it should fallback to itself. - img = ImageFile(path=dev_path) - - paths = get_drag_paths([img], 0, [0], "jpeg") - assert paths == [dev_path] - -def test_drag_logic_raw_mode_missing_developed(tmp_path): - """In RAW mode, if -developed.jpg is missing, fallback to main path.""" - jpg_path = tmp_path / "A.jpg" - jpg_path.touch() - - img = ImageFile(path=jpg_path) - - paths = get_drag_paths([img], 0, [0], "raw") - assert paths == [jpg_path] + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch +from faststack.models import ImageFile + +# We can't easily instantiate AppController without complex mocks for QML engine, etc. +# So we test the logic extracted from start_drag_current_image. + +def get_drag_paths(image_files, current_index, existing_indices, current_edit_source_mode): + file_paths = [] + for idx in existing_indices: + img = image_files[idx] + + # logic from app.py + is_developed_artifact = img.path.stem.lower().endswith("-developed") + in_raw_mode = (current_edit_source_mode == "raw") + + if (in_raw_mode or is_developed_artifact) and img.developed_jpg_path.exists(): + file_paths.append(img.developed_jpg_path) + else: + file_paths.append(img.path) + return file_paths + +def test_drag_logic_jpeg_mode(tmp_path): + """In JPEG mode, prefer the original JPG even if -developed exists.""" + jpg_path = tmp_path / "A.jpg" + dev_path = tmp_path / "A-developed.jpg" + jpg_path.touch() + dev_path.touch() + + img = ImageFile(path=jpg_path) + # Note: developed_jpg_path is a property that calculates the path + + paths = get_drag_paths([img], 0, [0], "jpeg") + assert paths == [jpg_path] + +def test_drag_logic_raw_mode(tmp_path): + """In RAW mode, prefer -developed.jpg if it exists.""" + jpg_path = tmp_path / "A.jpg" + dev_path = tmp_path / "A-developed.jpg" + jpg_path.touch() + dev_path.touch() + + img = ImageFile(path=jpg_path) + + paths = get_drag_paths([img], 0, [0], "raw") + assert paths == [dev_path] + +def test_drag_logic_developed_artifact(tmp_path): + """If the dragged file IS a developed artifact, it should prefer -developed.jpg (itself).""" + # This case might be rare if the indexer handles it, but let's test the logic. + dev_path = tmp_path / "A-developed.jpg" + dev_path.touch() + + # In this case, developed_jpg_path will be "A-developed-developed.jpg" + # which won't exist. So it should fallback to itself. + img = ImageFile(path=dev_path) + + paths = get_drag_paths([img], 0, [0], "jpeg") + assert paths == [dev_path] + +def test_drag_logic_raw_mode_missing_developed(tmp_path): + """In RAW mode, if -developed.jpg is missing, fallback to main path.""" + jpg_path = tmp_path / "A.jpg" + jpg_path.touch() + + img = ImageFile(path=jpg_path) + + paths = get_drag_paths([img], 0, [0], "raw") + assert paths == [jpg_path] diff --git a/faststack/tests/test_editor_error_handling.py b/faststack/tests/test_editor_error_handling.py index 25a08c8..8792f73 100644 --- a/faststack/tests/test_editor_error_handling.py +++ b/faststack/tests/test_editor_error_handling.py @@ -1,60 +1,59 @@ - -import sys -import unittest -from unittest.mock import MagicMock, patch, mock_open -import numpy as np -from pathlib import Path -import tempfile -import os - -# We need to mock cv2 before importing editor if it's not already imported, -# but since tests run in the same process, we just rely on patching. - -class TestEditorErrorHandling(unittest.TestCase): - """Test that ImageEditor raises exceptions when loading fails completely.""" - - def test_load_image_raises_exception_on_failure(self): - """Ensure load_image raises an exception when file opening fails completely.""" - from faststack.imaging.editor import ImageEditor - - editor = ImageEditor() - - # Patch Image.open to raise an exception - with patch('PIL.Image.open', side_effect=OSError("Mocked file error")): - # We also need to ensure cv2 doesn't rescue it. - # If cv2 exists, it might try to load. - # Let's mock cv2.imread to return None so it falls back to PIL, which fails. - - with patch.dict(sys.modules, {'cv2': MagicMock()}): - sys.modules['cv2'].imread.return_value = None - - with self.assertRaises(OSError) as cm: - editor.load_image("non_existent_file.jpg") - - self.assertIn("Mocked file error", str(cm.exception)) - - def test_save_image_raises_exception_on_failure(self): - """Ensure save_image raises an exception when saving fails.""" - from faststack.imaging.editor import ImageEditor - - editor = ImageEditor() - - # Setup a fake state so save_image attempts to run - editor.float_image = np.zeros((10, 10, 3), dtype=np.float32) - editor.current_filepath = Path("fake_path.jpg") - - # Patch create_backup_file to succeed - with patch('faststack.imaging.editor.create_backup_file', return_value=Path("backup.jpg")): - # Patch Image.fromarray to return a mock that fails to save - mock_img = MagicMock() - mock_img.save.side_effect = PermissionError("Mocked save error") - - with patch('PIL.Image.fromarray', return_value=mock_img): - with self.assertRaises(PermissionError) as cm: - # Attempt to save - editor.save_image() - - self.assertIn("Mocked save error", str(cm.exception)) - -if __name__ == '__main__': - unittest.main() + +import sys +import unittest +from unittest.mock import MagicMock, patch, mock_open +import numpy as np +from pathlib import Path +import tempfile +import os + +# We need to mock cv2 before importing editor if it's not already imported, +# but since tests run in the same process, we just rely on patching. + +class TestEditorErrorHandling(unittest.TestCase): + """Test ImageEditor error handling for load and save operations.""" + + def test_load_image_returns_false_on_failure(self): + """Ensure load_image returns False when file opening fails.""" + from faststack.imaging.editor import ImageEditor + + editor = ImageEditor() + + # Patch Image.open to raise an exception + with patch('PIL.Image.open', side_effect=OSError("Mocked file error")): + # We also need to ensure cv2 doesn't rescue it. + # If cv2 exists, it might try to load. + # Let's mock cv2.imread to return None so it falls back to PIL, which fails. + + with patch.dict(sys.modules, {'cv2': MagicMock()}): + sys.modules['cv2'].imread.return_value = None + + # load_image returns False on failure, not raises + result = editor.load_image("non_existent_file.jpg") + self.assertFalse(result) + + def test_save_image_raises_runtime_error_on_failure(self): + """Ensure save_image raises RuntimeError when saving fails.""" + from faststack.imaging.editor import ImageEditor + + editor = ImageEditor() + + # Setup a fake state so save_image attempts to run + editor.float_image = np.zeros((10, 10, 3), dtype=np.float32) + editor.current_filepath = Path("fake_path.jpg") + + # Patch create_backup_file to succeed + with patch('faststack.imaging.editor.create_backup_file', return_value=Path("backup.jpg")): + # Patch Image.fromarray to return a mock that fails to save + mock_img = MagicMock() + mock_img.save.side_effect = PermissionError("Mocked save error") + + with patch('PIL.Image.fromarray', return_value=mock_img): + # save_image wraps exceptions in RuntimeError + with self.assertRaises(RuntimeError) as cm: + editor.save_image() + + self.assertIn("Mocked save error", str(cm.exception)) + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_editor_integration.py b/faststack/tests/test_editor_integration.py index 0a4ec42..51b3e22 100644 --- a/faststack/tests/test_editor_integration.py +++ b/faststack/tests/test_editor_integration.py @@ -1,121 +1,121 @@ - -import unittest -from unittest.mock import MagicMock, patch -from pathlib import Path -import sys - -# Ensure we can import faststack -sys.path.append(str(Path(__file__).parents[2])) - -from faststack.app import AppController - -class TestEditorIntegration(unittest.TestCase): - def setUp(self): - # Mock dependencies for AppController - self.mock_engine = MagicMock() - self.mock_config = MagicMock() - - # Patch config to avoid file I/O or errors - self.config_patcher = patch('faststack.app.config') - self.mock_config_module = self.config_patcher.start() - - # Instantiate AppController with a dummy path - # We need to mock Watcher and SidecarManager because they start threads/file IO - with patch('faststack.app.Watcher'), \ - patch('faststack.app.SidecarManager'), \ - patch('faststack.app.Prefetcher'), \ - patch('faststack.app.ByteLRUCache'): - self.controller = AppController(Path("."), self.mock_engine) - - # Mock the internal image_editor to verify delegation - self.controller.image_editor = MagicMock() - self.controller.image_editor.current_filepath = Path("test.jpg") - self.controller.image_editor.float_image = MagicMock() - self.controller.image_editor.original_image = MagicMock() - - def tearDown(self): - self.config_patcher.stop() - - def test_missing_methods(self): - """Verify that the methods expected by QML exist and delegate to ImageEditor.""" - - # 1. set_edit_parameter - # Try calling the method. If it doesn't exist, this will raise AttributeError - try: - self.controller.set_edit_parameter("exposure", 0.5) - self.controller.image_editor.set_edit_param.assert_called_with("exposure", 0.5) - except AttributeError: - self.fail("AppController is missing method 'set_edit_parameter'") - - # 2. rotate_image_cw - try: - self.controller.rotate_image_cw() - self.controller.image_editor.rotate_image_cw.assert_called_once() - except AttributeError: - self.fail("AppController is missing method 'rotate_image_cw'") - - # 3. rotate_image_ccw - try: - self.controller.rotate_image_ccw() - self.controller.image_editor.rotate_image_ccw.assert_called_once() - except AttributeError: - self.fail("AppController is missing method 'rotate_image_ccw'") - - # 4. reset_edit_parameters - try: - self.controller.reset_edit_parameters() - self.controller.image_editor.reset_edits.assert_called_once() - except AttributeError: - self.fail("AppController is missing method 'reset_edit_parameters'") - - # 5. save_edited_image - try: - self.controller.save_edited_image() - self.controller.image_editor.save_image.assert_called_once() - except AttributeError: - self.fail("AppController is missing method 'save_edited_image'") - - # 6. auto_levels - try: - self.controller.auto_levels() - self.controller.image_editor.auto_levels.assert_called_once() - except AttributeError: - self.fail("AppController is missing method 'auto_levels'") - - # 7. update_histogram - # This one might be complex to mock fully due to threading, but we check existence - if not hasattr(self.controller, 'update_histogram'): - self.fail("AppController is missing method 'update_histogram'") - - - def test_set_edit_parameter_gating(self): - """Regression test for proper gating of set_edit_parameter.""" - - # Setup mocks - self.controller.image_editor = MagicMock() - - # Case 1: Editor closed (ui_state flag False), but image LOADED. - # Should allow edits (robustness fix). - self.controller.ui_state.isEditorOpen = False - self.controller.image_editor.current_filepath = Path("test.jpg") - self.controller.image_editor.original_image = MagicMock() # Has image - self.controller.image_editor.float_image = None - - self.controller.set_edit_parameter("exposure", 0.5) - self.controller.image_editor.set_edit_param.assert_called_with("exposure", 0.5) - - # Reset mocks - self.controller.image_editor.reset_mock() - - # Case 2: Editor OPEN (flag True), but NO image loaded. - # Should BLOCK edits (safety fix). - self.controller.ui_state.isEditorOpen = True - self.controller.image_editor.current_filepath = None - self.controller.image_editor.original_image = None - self.controller.image_editor.float_image = None - - self.controller.set_edit_parameter("exposure", 0.8) - self.controller.image_editor.set_edit_param.assert_not_called() - -if __name__ == '__main__': - unittest.main() + +import unittest +from unittest.mock import MagicMock, patch +from pathlib import Path +import sys + +# Ensure we can import faststack +sys.path.append(str(Path(__file__).parents[2])) + +from faststack.app import AppController + +class TestEditorIntegration(unittest.TestCase): + def setUp(self): + # Mock dependencies for AppController + self.mock_engine = MagicMock() + self.mock_config = MagicMock() + + # Patch config to avoid file I/O or errors + self.config_patcher = patch('faststack.app.config') + self.mock_config_module = self.config_patcher.start() + + # Instantiate AppController with a dummy path + # We need to mock Watcher and SidecarManager because they start threads/file IO + with patch('faststack.app.Watcher'), \ + patch('faststack.app.SidecarManager'), \ + patch('faststack.app.Prefetcher'), \ + patch('faststack.app.ByteLRUCache'): + self.controller = AppController(Path("."), self.mock_engine) + + # Mock the internal image_editor to verify delegation + self.controller.image_editor = MagicMock() + self.controller.image_editor.current_filepath = Path("test.jpg") + self.controller.image_editor.float_image = MagicMock() + self.controller.image_editor.original_image = MagicMock() + + def tearDown(self): + self.config_patcher.stop() + + def test_missing_methods(self): + """Verify that the methods expected by QML exist and delegate to ImageEditor.""" + + # 1. set_edit_parameter + # Try calling the method. If it doesn't exist, this will raise AttributeError + try: + self.controller.set_edit_parameter("exposure", 0.5) + self.controller.image_editor.set_edit_param.assert_called_with("exposure", 0.5) + except AttributeError: + self.fail("AppController is missing method 'set_edit_parameter'") + + # 2. rotate_image_cw + try: + self.controller.rotate_image_cw() + self.controller.image_editor.rotate_image_cw.assert_called_once() + except AttributeError: + self.fail("AppController is missing method 'rotate_image_cw'") + + # 3. rotate_image_ccw + try: + self.controller.rotate_image_ccw() + self.controller.image_editor.rotate_image_ccw.assert_called_once() + except AttributeError: + self.fail("AppController is missing method 'rotate_image_ccw'") + + # 4. reset_edit_parameters + try: + self.controller.reset_edit_parameters() + self.controller.image_editor.reset_edits.assert_called_once() + except AttributeError: + self.fail("AppController is missing method 'reset_edit_parameters'") + + # 5. save_edited_image + try: + self.controller.save_edited_image() + self.controller.image_editor.save_image.assert_called_once() + except AttributeError: + self.fail("AppController is missing method 'save_edited_image'") + + # 6. auto_levels + try: + self.controller.auto_levels() + self.controller.image_editor.auto_levels.assert_called_once() + except AttributeError: + self.fail("AppController is missing method 'auto_levels'") + + # 7. update_histogram + # This one might be complex to mock fully due to threading, but we check existence + if not hasattr(self.controller, 'update_histogram'): + self.fail("AppController is missing method 'update_histogram'") + + + def test_set_edit_parameter_gating(self): + """Regression test for proper gating of set_edit_parameter.""" + + # Setup mocks + self.controller.image_editor = MagicMock() + + # Case 1: Editor closed (ui_state flag False), but image LOADED. + # Should allow edits (robustness fix). + self.controller.ui_state.isEditorOpen = False + self.controller.image_editor.current_filepath = Path("test.jpg") + self.controller.image_editor.original_image = MagicMock() # Has image + self.controller.image_editor.float_image = None + + self.controller.set_edit_parameter("exposure", 0.5) + self.controller.image_editor.set_edit_param.assert_called_with("exposure", 0.5) + + # Reset mocks + self.controller.image_editor.reset_mock() + + # Case 2: Editor OPEN (flag True), but NO image loaded. + # Should BLOCK edits (safety fix). + self.controller.ui_state.isEditorOpen = True + self.controller.image_editor.current_filepath = None + self.controller.image_editor.original_image = None + self.controller.image_editor.float_image = None + + self.controller.set_edit_parameter("exposure", 0.8) + self.controller.image_editor.set_edit_param.assert_not_called() + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_editor_lifecycle_and_safety.py b/faststack/tests/test_editor_lifecycle_and_safety.py index e612713..17d60ab 100644 --- a/faststack/tests/test_editor_lifecycle_and_safety.py +++ b/faststack/tests/test_editor_lifecycle_and_safety.py @@ -1,105 +1,105 @@ - -import unittest -from unittest.mock import MagicMock, patch -from pathlib import Path -import sys -import threading -import time - -# Ensure we can import faststack -sys.path.append(str(Path(__file__).parents[2])) - -from faststack.app import AppController - -class TestEditorLifecycleAndSafety(unittest.TestCase): - def setUp(self): - # Mock dependencies for AppController - self.mock_engine = MagicMock() - - # Patch dependencies that do I/O or threading - self.watcher_patcher = patch('faststack.app.Watcher') - self.sidecar_patcher = patch('faststack.app.SidecarManager') - self.prefetcher_patcher = patch('faststack.app.Prefetcher') - self.cache_patcher = patch('faststack.app.ByteLRUCache') - self.config_patcher = patch('faststack.app.config') - - self.mock_watcher = self.watcher_patcher.start() - self.mock_sidecar = self.sidecar_patcher.start() - self.mock_prefetcher = self.prefetcher_patcher.start() - self.mock_cache = self.cache_patcher.start() - self.mock_config = self.config_patcher.start() - - # Default config values to allow init - self.mock_config.getfloat.return_value = 1.0 - self.mock_config.getboolean.return_value = False - self.mock_config.getint.return_value = 4 - - # Mock QCoreApplication.instance() to prevent RuntimeError - self.qapp_patcher = patch('faststack.app.QCoreApplication') - self.mock_qapp = self.qapp_patcher.start() - self.mock_qapp.instance.return_value.aboutToQuit.connect = MagicMock() - - # Instantiate AppController - with patch('faststack.app.ImageEditor') as mock_editor_cls: - self.controller = AppController(Path("."), self.mock_engine) - self.mock_editor_instance = self.controller.image_editor - - def tearDown(self): - self.watcher_patcher.stop() - self.sidecar_patcher.stop() - self.prefetcher_patcher.stop() - self.cache_patcher.stop() - self.config_patcher.stop() - self.qapp_patcher.stop() - - # Ensure we shutdown executors to avoid hanging tests - self.controller._shutdown_executors() - - def test_memory_cleanup_on_editor_close(self): - """Verify that memory is cleared when the editor is closed.""" - - # 1. Simulate opening the editor - # (Technically we just care about the transition to closed, but good to be thorough) - self.controller._on_editor_open_changed(True) - self.mock_editor_instance.clear.assert_not_called() - - # 2. Simulate closing the editor - # The signal connection is already tested by Qt usually, we test the handler logic here - self.controller._on_editor_open_changed(False) - - # 3. Verify clear() was called on the editor - self.mock_editor_instance.clear.assert_called_once() - - # 4. Verify preview cache was cleared - with self.controller._preview_lock: - self.assertIsNone(self.controller._last_rendered_preview) - - def test_histogram_worker_submission_safety(self): - """Verify that histogram inflight flag is reset if submission fails.""" - - # Setup: Pending histogram update - self.controller._hist_pending = (1.0, 0, 0, 1.0) # args - self.controller._hist_inflight = False - - # Mock executor to raise an exception on submit - self.controller._hist_executor.submit = MagicMock(side_effect=TypeError("Simulated submission failure")) - - # Mock preview preview data to ensure we try to submit - self.controller._last_rendered_preview = MagicMock() - - # Execute - self.controller._kick_histogram_worker() - - # Verify: - # 1. Flag should be FALSE (reset after error) - self.assertFalse(self.controller._hist_inflight, "Histogram inflight flag should be reset after submission error") - - # 2. _hist_pending was consumed (set to None inside the method before submitting) - # Wait, usually if it fails, we might want to retry? - # The current implementation just logs error and clears inflight. - # It doesn't put args back into pending unless it was an early exit (no preview data). - # This is acceptable behavior: drop the failed frame, wait for next update. - self.assertIsNone(self.controller._hist_pending) - -if __name__ == '__main__': - unittest.main() + +import unittest +from unittest.mock import MagicMock, patch +from pathlib import Path +import sys +import threading +import time + +# Ensure we can import faststack +sys.path.append(str(Path(__file__).parents[2])) + +from faststack.app import AppController + +class TestEditorLifecycleAndSafety(unittest.TestCase): + def setUp(self): + # Mock dependencies for AppController + self.mock_engine = MagicMock() + + # Patch dependencies that do I/O or threading + self.watcher_patcher = patch('faststack.app.Watcher') + self.sidecar_patcher = patch('faststack.app.SidecarManager') + self.prefetcher_patcher = patch('faststack.app.Prefetcher') + self.cache_patcher = patch('faststack.app.ByteLRUCache') + self.config_patcher = patch('faststack.app.config') + + self.mock_watcher = self.watcher_patcher.start() + self.mock_sidecar = self.sidecar_patcher.start() + self.mock_prefetcher = self.prefetcher_patcher.start() + self.mock_cache = self.cache_patcher.start() + self.mock_config = self.config_patcher.start() + + # Default config values to allow init + self.mock_config.getfloat.return_value = 1.0 + self.mock_config.getboolean.return_value = False + self.mock_config.getint.return_value = 4 + + # Mock QCoreApplication.instance() to prevent RuntimeError + self.qapp_patcher = patch('faststack.app.QCoreApplication') + self.mock_qapp = self.qapp_patcher.start() + self.mock_qapp.instance.return_value.aboutToQuit.connect = MagicMock() + + # Instantiate AppController + with patch('faststack.app.ImageEditor') as mock_editor_cls: + self.controller = AppController(Path("."), self.mock_engine) + self.mock_editor_instance = self.controller.image_editor + + def tearDown(self): + self.watcher_patcher.stop() + self.sidecar_patcher.stop() + self.prefetcher_patcher.stop() + self.cache_patcher.stop() + self.config_patcher.stop() + self.qapp_patcher.stop() + + # Ensure we shutdown executors to avoid hanging tests + self.controller._shutdown_executors() + + def test_memory_cleanup_on_editor_close(self): + """Verify that memory is cleared when the editor is closed.""" + + # 1. Simulate opening the editor + # (Technically we just care about the transition to closed, but good to be thorough) + self.controller._on_editor_open_changed(True) + self.mock_editor_instance.clear.assert_not_called() + + # 2. Simulate closing the editor + # The signal connection is already tested by Qt usually, we test the handler logic here + self.controller._on_editor_open_changed(False) + + # 3. Verify clear() was called on the editor + self.mock_editor_instance.clear.assert_called_once() + + # 4. Verify preview cache was cleared + with self.controller._preview_lock: + self.assertIsNone(self.controller._last_rendered_preview) + + def test_histogram_worker_submission_safety(self): + """Verify that histogram inflight flag is reset if submission fails.""" + + # Setup: Pending histogram update + self.controller._hist_pending = (1.0, 0, 0, 1.0) # args + self.controller._hist_inflight = False + + # Mock executor to raise an exception on submit + self.controller._hist_executor.submit = MagicMock(side_effect=TypeError("Simulated submission failure")) + + # Mock preview preview data to ensure we try to submit + self.controller._last_rendered_preview = MagicMock() + + # Execute + self.controller._kick_histogram_worker() + + # Verify: + # 1. Flag should be FALSE (reset after error) + self.assertFalse(self.controller._hist_inflight, "Histogram inflight flag should be reset after submission error") + + # 2. _hist_pending was consumed (set to None inside the method before submitting) + # Wait, usually if it fails, we might want to retry? + # The current implementation just logs error and clears inflight. + # It doesn't put args back into pending unless it was an early exit (no preview data). + # This is acceptable behavior: drop the failed frame, wait for next update. + self.assertIsNone(self.controller._hist_pending) + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_editor_loading.py b/faststack/tests/test_editor_loading.py index 79984e8..37b995d 100644 --- a/faststack/tests/test_editor_loading.py +++ b/faststack/tests/test_editor_loading.py @@ -1,88 +1,88 @@ -""" -Tests for hardened image loading logic in ImageEditor. -Specifically tests cv2.imread returning None, empty arrays, or invalid objects. - -Note: cv2 is imported INSIDE the load_image() function, so we need to -patch sys.modules['cv2'] before the import happens. -""" -import sys -import unittest -from unittest.mock import MagicMock, patch -import numpy as np -from pathlib import Path -import tempfile -import os - - -class TestImageLoadingFallback(unittest.TestCase): - """Test that ImageEditor gracefully falls back to PIL when cv2.imread fails.""" - - def setUp(self): - """Set up a fresh ImageEditor for each test.""" - self.temp_files = [] - - def tearDown(self): - """Clean up temp files.""" - for f in self.temp_files: - try: - os.unlink(f) - except (OSError, PermissionError): - pass - - def _create_temp_image(self, color='red'): - """Create a temporary image file and return its path.""" - from PIL import Image - fd, temp_path = tempfile.mkstemp(suffix='.jpg') - os.close(fd) # Close the file descriptor so PIL can write to it - img = Image.new('RGB', (10, 10), color=color) - img.save(temp_path) - self.temp_files.append(temp_path) - return temp_path - - def _run_with_mocked_cv2(self, imread_return_value, temp_path): - """Run load_image with a mocked cv2 module.""" - # Create a mock cv2 module - mock_cv2 = MagicMock() - mock_cv2.imread.return_value = imread_return_value - mock_cv2.IMREAD_UNCHANGED = -1 - - # Patch cv2 in sys.modules before importing editor - with patch.dict(sys.modules, {'cv2': mock_cv2}): - # Force reimport of editor to pick up the mocked cv2 - if 'faststack.imaging.editor' in sys.modules: - del sys.modules['faststack.imaging.editor'] - from faststack.imaging.editor import ImageEditor - - editor = ImageEditor() - result = editor.load_image(temp_path) - return editor, result - - def test_imread_returns_none(self): - """cv2.imread returning None should fall back to PIL.""" - temp_path = self._create_temp_image('red') - editor, result = self._run_with_mocked_cv2(None, temp_path) - - self.assertTrue(result, "load_image should succeed with PIL fallback") - self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") - self.assertIsNotNone(editor.float_image, "float_image should be set") - - def test_imread_returns_empty_array(self): - """cv2.imread returning an empty array should fall back to PIL.""" - temp_path = self._create_temp_image('blue') - editor, result = self._run_with_mocked_cv2(np.array([]), temp_path) - - self.assertTrue(result, "load_image should succeed with PIL fallback") - self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") - - def test_imread_returns_non_array(self): - """cv2.imread returning a non-array object should fall back to PIL.""" - temp_path = self._create_temp_image('green') - editor, result = self._run_with_mocked_cv2("not an array", temp_path) - - self.assertTrue(result, "load_image should succeed with PIL fallback") - self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") - - -if __name__ == '__main__': - unittest.main() - +""" +Tests for hardened image loading logic in ImageEditor. +Specifically tests cv2.imread returning None, empty arrays, or invalid objects. + +Note: cv2 is imported INSIDE the load_image() function, so we need to +patch sys.modules['cv2'] before the import happens. +""" +import sys +import unittest +from unittest.mock import MagicMock, patch +import numpy as np +from pathlib import Path +import tempfile +import os + + +class TestImageLoadingFallback(unittest.TestCase): + """Test that ImageEditor gracefully falls back to PIL when cv2.imread fails.""" + + def setUp(self): + """Set up a fresh ImageEditor for each test.""" + self.temp_files = [] + + def tearDown(self): + """Clean up temp files.""" + for f in self.temp_files: + try: + os.unlink(f) + except (OSError, PermissionError): + pass + + def _create_temp_image(self, color='red'): + """Create a temporary image file and return its path.""" + from PIL import Image + fd, temp_path = tempfile.mkstemp(suffix='.jpg') + os.close(fd) # Close the file descriptor so PIL can write to it + img = Image.new('RGB', (10, 10), color=color) + img.save(temp_path) + self.temp_files.append(temp_path) + return temp_path + + def _run_with_mocked_cv2(self, imread_return_value, temp_path): + """Run load_image with a mocked cv2 module.""" + # Create a mock cv2 module + mock_cv2 = MagicMock() + mock_cv2.imread.return_value = imread_return_value + mock_cv2.IMREAD_UNCHANGED = -1 + + # Patch cv2 in sys.modules before importing editor + with patch.dict(sys.modules, {'cv2': mock_cv2}): + # Force reimport of editor to pick up the mocked cv2 + if 'faststack.imaging.editor' in sys.modules: + del sys.modules['faststack.imaging.editor'] + from faststack.imaging.editor import ImageEditor + + editor = ImageEditor() + result = editor.load_image(temp_path) + return editor, result + + def test_imread_returns_none(self): + """cv2.imread returning None should fall back to PIL.""" + temp_path = self._create_temp_image('red') + editor, result = self._run_with_mocked_cv2(None, temp_path) + + self.assertTrue(result, "load_image should succeed with PIL fallback") + self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") + self.assertIsNotNone(editor.float_image, "float_image should be set") + + def test_imread_returns_empty_array(self): + """cv2.imread returning an empty array should fall back to PIL.""" + temp_path = self._create_temp_image('blue') + editor, result = self._run_with_mocked_cv2(np.array([]), temp_path) + + self.assertTrue(result, "load_image should succeed with PIL fallback") + self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") + + def test_imread_returns_non_array(self): + """cv2.imread returning a non-array object should fall back to PIL.""" + temp_path = self._create_temp_image('green') + editor, result = self._run_with_mocked_cv2("not an array", temp_path) + + self.assertTrue(result, "load_image should succeed with PIL fallback") + self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") + + +if __name__ == '__main__': + unittest.main() + diff --git a/faststack/tests/test_editor_rotation.py b/faststack/tests/test_editor_rotation.py index 3ab26f7..d1abe5f 100644 --- a/faststack/tests/test_editor_rotation.py +++ b/faststack/tests/test_editor_rotation.py @@ -1,298 +1,298 @@ - -import pytest -import math -from PIL import Image -import numpy as np -from faststack.imaging.editor import _rotated_rect_with_max_area, rotate_autocrop_rgb, ImageEditor - -def test_rotated_rect_edge_cases(): - """Test fundamental edge cases for the rectangle calculation.""" - # Zero dimensions - assert _rotated_rect_with_max_area(0, 100, 0.5) == (0, 0) - assert _rotated_rect_with_max_area(100, 0, 0.5) == (0, 0) - assert _rotated_rect_with_max_area(-10, 100, 0.5) == (0, 0) - - # Near zero angle (should be close to original dimensions) - w, h = 100, 50 - cw, ch = _rotated_rect_with_max_area(w, h, 0.0000001) - assert cw == w - assert ch == h - - # Near 90 degree angle (should swap Dimensions roughly) - # The function expects radians. pi/2 is 90 degrees. - # Note: The function folds angle into [0, pi/2) - # If we pass exactly pi/2, math.sin(pi/2) = 1. - # However, our function folds: angle_rad = abs(angle_rad) % (math.pi / 2). - # So 90 deg becomes 0 deg effectively for rect calculation purposes in this specific helper - # because a 90 deg rotated rect inscribed in a 90 deg rotated image is the same rect. - # Let's test 89.9 degrees converted to radians - angle_rad = math.radians(89.9) - # Logic in function: if angle > pi/4, it subtracts from pi/2. - # So 89.9 becomes 0.1 deg. - cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) - # Should be very close to swapping w and h if we were inscribing, but wait - - # The function finds largest axis-aligned rect *within* the rotated w x h. - # If we rotate 100x50 by 90deg, we have a 50x100 bounding box. - # The largest axis aligned rect in a 50x100 box is 50x100. - # But let's stick to the simpler assertion: it returns something valid [1, w] x [1, h] - # (The function clamps to original w/h, which might be a bit counter-intuitive for 90deg - # if we wanted the swapped dims, but for small-angle straightening it's fine). - assert 1 <= cw <= w - assert 1 <= ch <= h - -@pytest.mark.parametrize("w,h,angle_deg", [ - (100, 100, 0), # Unrotated - (200, 100, 45), # Diagonal Square (Fully constrained case often) - (1000, 500, 15), # Half constrained case likely - (500, 1000, 15), # Tall half constrained -]) -def test_rotated_rect_calculation_branches(w, h, angle_deg): - """Exercise different geometric branches of the calculation.""" - angle_rad = math.radians(angle_deg) - cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) - - assert cw > 0 - assert ch > 0 - assert cw <= w - assert ch <= h - - if angle_deg == 0: - assert cw == w - assert ch == h - else: - # Non-zero rotation always reduces the inscribed axis-aligned box - assert cw * ch < w * h - -def test_rotate_autocrop_rgb_behavior(): - """Test actual image formatting and cropping.""" - # Create valid RGB image - w, h = 100, 100 - img = Image.new("RGB", (w, h), color=(255, 0, 0)) # Red - - # 1. Test no rotation - res = rotate_autocrop_rgb(img, 0.0) - assert res.size == (100, 100) - - # 2. Test rotation with inset - angle = 45.0 - inset = 2 - res = rotate_autocrop_rgb(img, angle, inset=inset) - - # At 45 deg, a square becomes a diamond. The max inscribed rect is w/(sqrt(2)) ~ 0.707*w - # 100 * 0.707 = 70. - # We expect roughly 70x70 minus inset. - # expected_approx = 70.0 - assert 60 < res.width < 80 - assert 60 < res.height < 80 - - # Verify no black wedges (since original was all red) - # Center pixel should definitely be red - cx, cy = res.width // 2, res.height // 2 - assert res.getpixel((cx, cy)) == (255, 0, 0) - - # Corner pixels should also be red if cropped correctly - assert res.getpixel((0, 0)) == (255, 0, 0) - assert res.getpixel((res.width-1, res.height-1)) == (255, 0, 0) - - -def test_boundary_clamping(): - """Test internal clamping logic.""" - img = Image.new("RGB", (10, 10), (255, 255, 255)) - - # Very small image, 45 deg rotation - # Inscribed rect will be small. - # high inset could theoretically reduce it to < 0. - res = rotate_autocrop_rgb(img, 45, inset=50) # Huge inset - - # It should clamp to at least 1x1 or similar valid image, not crash - assert res.width > 0 - assert res.height > 0 - -def test_integration_straighten_modes(): - """ - Integration test comparing Scenario A (Manual Crop) vs Scenario B (Straighten Only). - - Scenario A: User rotates + manually crops. The rotation expands canvas, user picks crop. - Scenario B: User rotates only. We autocrop to remove wedges. - """ - # Create image with specific pattern to verify content - w, h = 200, 100 - img = Image.new("RGB", (w, h), (0, 255, 0)) # Green - - editor = ImageEditor() - editor.original_image = img - editor.current_filepath = "dummy.jpg" # Needed for save, but not here - - angle = 10.0 - - # --- Scenario B: Straighten Only --- - editor.current_edits['straighten_angle'] = angle - editor.current_edits['crop_box'] = None - - res_b = editor._apply_edits(img.copy(), for_export=True) - - # Should define a specific size based on autocrop - w_b, h_b = res_b.size - - # --- Scenario A: Manual Crop --- - # We want to simulate the logic where we replicate what autocrop would have done, - # but manually via crop_box. - # 1. Calculate what the autocrop rect would be relative to the *rotated* canvas. - # Note: _rotated_rect yields dims in *original* pixel space generally, - # but let's look at how app.py handles normalization or how editor applies it. - - # Actually, let's just assert that if we manually crop to the SAME pixels - # that autocrop found, we get the same result. - - # Re-use the helper to find the crop box - angle_rad = math.radians(angle) - cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) - - # rotate_autocrop_rgb logic: - # It rotates with expand=True. The new center is center of rotated image. - # It crops centered rect of size (cw, ch). - - # So if we emulate this in editor: - editor.current_edits['straighten_angle'] = angle - - # We need to compute the 'crop_box' (normalized 0-1000) that corresponds - # to that center crop on the ROTATED image. - - # Get rotated size - rot_temp = img.rotate(-angle, expand=True) - rw, rh = rot_temp.size - - cx, cy = rw / 2.0, rh / 2.0 - left = cx - cw / 2.0 - top = cy - ch / 2.0 - right = left + cw - bottom = top + ch - - # Normalize to 0-1000 relative to rotated size - # (Editor applies crop_box relative to the current (rotated) image size) - n_left = int(left / rw * 1000) - n_top = int(top / rh * 1000) - n_right = int(right / rw * 1000) - n_bottom = int(bottom / rh * 1000) - - editor.current_edits['crop_box'] = (n_left, n_top, n_right, n_bottom) - - res_a = editor._apply_edits(img.copy(), for_export=True) - - # Allow for 1-2 pixel differences due to int/round conversions in normalization - assert abs(res_a.width - w_b) < 5 - assert abs(res_a.height - h_b) < 5 - - # Verify both are Green (center pixel) - assert res_a.getpixel((res_a.width//2, res_a.height//2)) == (0, 255, 0) - - -# ------------------------------------------------------------------------- -# Regression Tests for Rotation Direction (CW/CCW) -# ------------------------------------------------------------------------- - -def create_quadrant_image(w=100, h=100): - """ - Creates an image with 4 distinct colored quadrants. - TL: Red (255, 0, 0) - TR: Green (0, 255, 0) - BL: Blue (0, 0, 255) - BR: White (255, 255, 255) - """ - img = Image.new("RGB", (w, h)) - pixels = img.load() - - cx, cy = w // 2, h // 2 - - for y in range(h): - for x in range(w): - if x < cx and y < cy: - pixels[x, y] = (255, 0, 0) # TL Red - elif x >= cx and y < cy: - pixels[x, y] = (0, 255, 0) # TR Green - elif x < cx and y >= cy: - pixels[x, y] = (0, 0, 255) # BL Blue - else: - pixels[x, y] = (255, 255, 255) # BR White - return img - -def test_rotate_cw(): - """Test that rotate_cw rotates 90 degrees Clockwise.""" - editor = ImageEditor() - editor.original_image = create_quadrant_image(100, 100) - editor.current_filepath = "dummy.jpg" - - # Initial state: 0 rotation - assert editor.current_edits['rotation'] == 0 - - # Rotate CW (Logic in app.py subtracts 90, so local state becomes 270) - # editor.rotate_image_cw() implementation: (current - 90) % 360 - editor.rotate_image_cw() - - assert editor.current_edits['rotation'] == 270 - - # Apply edits - # PIL Transpose constants: - # ROTATE_90: 90 CCW (Left) - # ROTATE_270: 270 CCW (Right/CW) - # Expected for CW: ROTATE_270 (which maps to 270 degrees CCW) - - res = editor._apply_edits(editor.original_image.copy()) - - # Check pixels - # Original TL (Red) -> New TR - # Original TR (Green) -> New BR - # Original BL (Blue) -> New TL - # Original BR (White) -> New BL - - w, h = res.size - - # Sample center of quadrants - q_w, q_h = w // 4, h // 4 - - # New TL (Should be Blue) - assert res.getpixel((q_w, q_h)) == (0, 0, 255), "TL should be Blue (was Red)" - - # New TR (Should be Red) - assert res.getpixel((w - q_w, q_h)) == (255, 0, 0), "TR should be Red" - - # New BL (Should be White) - assert res.getpixel((q_w, h - q_h)) == (255, 255, 255), "BL should be White" - - # New BR (Should be Green) - assert res.getpixel((w - q_w, h - q_h)) == (0, 255, 0), "BR should be Green" - -def test_rotate_ccw(): - """Test that rotate_ccw rotates 90 degrees Counter-Clockwise.""" - editor = ImageEditor() - editor.original_image = create_quadrant_image(100, 100) - editor.current_filepath = "dummy.jpg" - - # Rotate CCW (Logic: current + 90) -> 90 - editor.rotate_image_ccw() - - assert editor.current_edits['rotation'] == 90 - - res = editor._apply_edits(editor.original_image.copy()) - - w, h = res.size - q_w, q_h = w // 4, h // 4 - - # CCW Rotation: - # TL (Red) -> BL - # TR (Green) -> TL - # BL (Blue) -> BR - # BR (White) -> TR - - # New TL (Should be Green) - assert res.getpixel((q_w, q_h)) == (0, 255, 0), "TL should be Green" - - # New TR (Should be White) - assert res.getpixel((w - q_w, q_h)) == (255, 255, 255), "TR should be White" - - # New BL (Should be Red) - assert res.getpixel((q_w, h - q_h)) == (255, 0, 0), "BL should be Red" - - # New BR (Should be Blue) - assert res.getpixel((w - q_w, h - q_h)) == (0, 0, 255), "BR should be Blue" - + +import pytest +import math +from PIL import Image +import numpy as np +from faststack.imaging.editor import _rotated_rect_with_max_area, rotate_autocrop_rgb, ImageEditor + +def test_rotated_rect_edge_cases(): + """Test fundamental edge cases for the rectangle calculation.""" + # Zero dimensions + assert _rotated_rect_with_max_area(0, 100, 0.5) == (0, 0) + assert _rotated_rect_with_max_area(100, 0, 0.5) == (0, 0) + assert _rotated_rect_with_max_area(-10, 100, 0.5) == (0, 0) + + # Near zero angle (should be close to original dimensions) + w, h = 100, 50 + cw, ch = _rotated_rect_with_max_area(w, h, 0.0000001) + assert cw == w + assert ch == h + + # Near 90 degree angle (should swap Dimensions roughly) + # The function expects radians. pi/2 is 90 degrees. + # Note: The function folds angle into [0, pi/2) + # If we pass exactly pi/2, math.sin(pi/2) = 1. + # However, our function folds: angle_rad = abs(angle_rad) % (math.pi / 2). + # So 90 deg becomes 0 deg effectively for rect calculation purposes in this specific helper + # because a 90 deg rotated rect inscribed in a 90 deg rotated image is the same rect. + # Let's test 89.9 degrees converted to radians + angle_rad = math.radians(89.9) + # Logic in function: if angle > pi/4, it subtracts from pi/2. + # So 89.9 becomes 0.1 deg. + cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) + # Should be very close to swapping w and h if we were inscribing, but wait - + # The function finds largest axis-aligned rect *within* the rotated w x h. + # If we rotate 100x50 by 90deg, we have a 50x100 bounding box. + # The largest axis aligned rect in a 50x100 box is 50x100. + # But let's stick to the simpler assertion: it returns something valid [1, w] x [1, h] + # (The function clamps to original w/h, which might be a bit counter-intuitive for 90deg + # if we wanted the swapped dims, but for small-angle straightening it's fine). + assert 1 <= cw <= w + assert 1 <= ch <= h + +@pytest.mark.parametrize("w,h,angle_deg", [ + (100, 100, 0), # Unrotated + (200, 100, 45), # Diagonal Square (Fully constrained case often) + (1000, 500, 15), # Half constrained case likely + (500, 1000, 15), # Tall half constrained +]) +def test_rotated_rect_calculation_branches(w, h, angle_deg): + """Exercise different geometric branches of the calculation.""" + angle_rad = math.radians(angle_deg) + cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) + + assert cw > 0 + assert ch > 0 + assert cw <= w + assert ch <= h + + if angle_deg == 0: + assert cw == w + assert ch == h + else: + # Non-zero rotation always reduces the inscribed axis-aligned box + assert cw * ch < w * h + +def test_rotate_autocrop_rgb_behavior(): + """Test actual image formatting and cropping.""" + # Create valid RGB image + w, h = 100, 100 + img = Image.new("RGB", (w, h), color=(255, 0, 0)) # Red + + # 1. Test no rotation + res = rotate_autocrop_rgb(img, 0.0) + assert res.size == (100, 100) + + # 2. Test rotation with inset + angle = 45.0 + inset = 2 + res = rotate_autocrop_rgb(img, angle, inset=inset) + + # At 45 deg, a square becomes a diamond. The max inscribed rect is w/(sqrt(2)) ~ 0.707*w + # 100 * 0.707 = 70. + # We expect roughly 70x70 minus inset. + # expected_approx = 70.0 + assert 60 < res.width < 80 + assert 60 < res.height < 80 + + # Verify no black wedges (since original was all red) + # Center pixel should definitely be red + cx, cy = res.width // 2, res.height // 2 + assert res.getpixel((cx, cy)) == (255, 0, 0) + + # Corner pixels should also be red if cropped correctly + assert res.getpixel((0, 0)) == (255, 0, 0) + assert res.getpixel((res.width-1, res.height-1)) == (255, 0, 0) + + +def test_boundary_clamping(): + """Test internal clamping logic.""" + img = Image.new("RGB", (10, 10), (255, 255, 255)) + + # Very small image, 45 deg rotation + # Inscribed rect will be small. + # high inset could theoretically reduce it to < 0. + res = rotate_autocrop_rgb(img, 45, inset=50) # Huge inset + + # It should clamp to at least 1x1 or similar valid image, not crash + assert res.width > 0 + assert res.height > 0 + +def test_integration_straighten_modes(): + """ + Integration test comparing Scenario A (Manual Crop) vs Scenario B (Straighten Only). + + Scenario A: User rotates + manually crops. The rotation expands canvas, user picks crop. + Scenario B: User rotates only. We autocrop to remove wedges. + """ + # Create image with specific pattern to verify content + w, h = 200, 100 + img = Image.new("RGB", (w, h), (0, 255, 0)) # Green + + editor = ImageEditor() + editor.original_image = img + editor.current_filepath = "dummy.jpg" # Needed for save, but not here + + angle = 10.0 + + # --- Scenario B: Straighten Only --- + editor.current_edits['straighten_angle'] = angle + editor.current_edits['crop_box'] = None + + res_b = editor._apply_edits(img.copy(), for_export=True) + + # Should define a specific size based on autocrop + w_b, h_b = res_b.size + + # --- Scenario A: Manual Crop --- + # We want to simulate the logic where we replicate what autocrop would have done, + # but manually via crop_box. + # 1. Calculate what the autocrop rect would be relative to the *rotated* canvas. + # Note: _rotated_rect yields dims in *original* pixel space generally, + # but let's look at how app.py handles normalization or how editor applies it. + + # Actually, let's just assert that if we manually crop to the SAME pixels + # that autocrop found, we get the same result. + + # Re-use the helper to find the crop box + angle_rad = math.radians(angle) + cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) + + # rotate_autocrop_rgb logic: + # It rotates with expand=True. The new center is center of rotated image. + # It crops centered rect of size (cw, ch). + + # So if we emulate this in editor: + editor.current_edits['straighten_angle'] = angle + + # We need to compute the 'crop_box' (normalized 0-1000) that corresponds + # to that center crop on the ROTATED image. + + # Get rotated size + rot_temp = img.rotate(-angle, expand=True) + rw, rh = rot_temp.size + + cx, cy = rw / 2.0, rh / 2.0 + left = cx - cw / 2.0 + top = cy - ch / 2.0 + right = left + cw + bottom = top + ch + + # Normalize to 0-1000 relative to rotated size + # (Editor applies crop_box relative to the current (rotated) image size) + n_left = int(left / rw * 1000) + n_top = int(top / rh * 1000) + n_right = int(right / rw * 1000) + n_bottom = int(bottom / rh * 1000) + + editor.current_edits['crop_box'] = (n_left, n_top, n_right, n_bottom) + + res_a = editor._apply_edits(img.copy(), for_export=True) + + # Allow for 1-2 pixel differences due to int/round conversions in normalization + assert abs(res_a.width - w_b) < 5 + assert abs(res_a.height - h_b) < 5 + + # Verify both are Green (center pixel) + assert res_a.getpixel((res_a.width//2, res_a.height//2)) == (0, 255, 0) + + +# ------------------------------------------------------------------------- +# Regression Tests for Rotation Direction (CW/CCW) +# ------------------------------------------------------------------------- + +def create_quadrant_image(w=100, h=100): + """ + Creates an image with 4 distinct colored quadrants. + TL: Red (255, 0, 0) + TR: Green (0, 255, 0) + BL: Blue (0, 0, 255) + BR: White (255, 255, 255) + """ + img = Image.new("RGB", (w, h)) + pixels = img.load() + + cx, cy = w // 2, h // 2 + + for y in range(h): + for x in range(w): + if x < cx and y < cy: + pixels[x, y] = (255, 0, 0) # TL Red + elif x >= cx and y < cy: + pixels[x, y] = (0, 255, 0) # TR Green + elif x < cx and y >= cy: + pixels[x, y] = (0, 0, 255) # BL Blue + else: + pixels[x, y] = (255, 255, 255) # BR White + return img + +def test_rotate_cw(): + """Test that rotate_cw rotates 90 degrees Clockwise.""" + editor = ImageEditor() + editor.original_image = create_quadrant_image(100, 100) + editor.current_filepath = "dummy.jpg" + + # Initial state: 0 rotation + assert editor.current_edits['rotation'] == 0 + + # Rotate CW (Logic in app.py subtracts 90, so local state becomes 270) + # editor.rotate_image_cw() implementation: (current - 90) % 360 + editor.rotate_image_cw() + + assert editor.current_edits['rotation'] == 270 + + # Apply edits + # PIL Transpose constants: + # ROTATE_90: 90 CCW (Left) + # ROTATE_270: 270 CCW (Right/CW) + # Expected for CW: ROTATE_270 (which maps to 270 degrees CCW) + + res = editor._apply_edits(editor.original_image.copy()) + + # Check pixels + # Original TL (Red) -> New TR + # Original TR (Green) -> New BR + # Original BL (Blue) -> New TL + # Original BR (White) -> New BL + + w, h = res.size + + # Sample center of quadrants + q_w, q_h = w // 4, h // 4 + + # New TL (Should be Blue) + assert res.getpixel((q_w, q_h)) == (0, 0, 255), "TL should be Blue (was Red)" + + # New TR (Should be Red) + assert res.getpixel((w - q_w, q_h)) == (255, 0, 0), "TR should be Red" + + # New BL (Should be White) + assert res.getpixel((q_w, h - q_h)) == (255, 255, 255), "BL should be White" + + # New BR (Should be Green) + assert res.getpixel((w - q_w, h - q_h)) == (0, 255, 0), "BR should be Green" + +def test_rotate_ccw(): + """Test that rotate_ccw rotates 90 degrees Counter-Clockwise.""" + editor = ImageEditor() + editor.original_image = create_quadrant_image(100, 100) + editor.current_filepath = "dummy.jpg" + + # Rotate CCW (Logic: current + 90) -> 90 + editor.rotate_image_ccw() + + assert editor.current_edits['rotation'] == 90 + + res = editor._apply_edits(editor.original_image.copy()) + + w, h = res.size + q_w, q_h = w // 4, h // 4 + + # CCW Rotation: + # TL (Red) -> BL + # TR (Green) -> TL + # BL (Blue) -> BR + # BR (White) -> TR + + # New TL (Should be Green) + assert res.getpixel((q_w, q_h)) == (0, 255, 0), "TL should be Green" + + # New TR (Should be White) + assert res.getpixel((w - q_w, q_h)) == (255, 255, 255), "TR should be White" + + # New BL (Should be Red) + assert res.getpixel((q_w, h - q_h)) == (255, 0, 0), "BL should be Red" + + # New BR (Should be Blue) + assert res.getpixel((w - q_w, h - q_h)) == (0, 0, 255), "BR should be Blue" + diff --git a/faststack/tests/test_executable_validator.py b/faststack/tests/test_executable_validator.py index 6aa0c07..d69507c 100644 --- a/faststack/tests/test_executable_validator.py +++ b/faststack/tests/test_executable_validator.py @@ -1,130 +1,130 @@ -"""Tests for executable path validation.""" - -import pytest -from pathlib import Path -from unittest.mock import patch, MagicMock - -from faststack.io.executable_validator import ( - validate_executable_path, - _is_executable, - _is_subpath, -) - - -def test_empty_path(): - """Test that empty path is rejected.""" - is_valid, error = validate_executable_path("") - assert not is_valid - assert "empty" in error.lower() - - -def test_nonexistent_file(): - """Test that nonexistent file is rejected.""" - is_valid, error = validate_executable_path("C:\\nonexistent\\fake.exe") - assert not is_valid - assert "not found" in error.lower() - - -def test_valid_photoshop_path(): - """Test validation of a valid Photoshop path.""" - photoshop_path = r"C:\Program Files\Adobe\Adobe Photoshop 2026\Photoshop.exe" - - # Mock the path checks - with patch('faststack.io.executable_validator.Path') as mock_path: - mock_path_instance = MagicMock() - mock_path.return_value.resolve.return_value = mock_path_instance - mock_path_instance.exists.return_value = True - mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.exe' - mock_path_instance.name = "Photoshop.exe" - mock_path_instance.__str__ = lambda self: photoshop_path - - with patch('faststack.io.executable_validator._is_subpath', return_value=True): - is_valid, error = validate_executable_path( - photoshop_path, - app_type="photoshop" - ) - assert is_valid - assert error is None - - -def test_suspicious_path_with_traversal(): - """Test that paths with directory traversal are flagged.""" - suspicious_path = r"C:\Program Files\..\Windows\System32\malware.exe" - - with patch('faststack.io.executable_validator.Path') as mock_path: - mock_path_instance = MagicMock() - mock_path.return_value.resolve.return_value = mock_path_instance - mock_path_instance.exists.return_value = True - mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.exe' - mock_path_instance.name = "malware.exe" - mock_path_instance.__str__ = lambda self: r"C:\Windows\System32\malware.exe" - - # The normalized path will differ from input, triggering warning - 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(): - """Test that non-executable files are rejected on Windows.""" - txt_file = r"C:\Program Files\test.txt" - - with patch('faststack.io.executable_validator.Path') as mock_path: - mock_path_instance = MagicMock() - mock_path.return_value.resolve.return_value = mock_path_instance - mock_path_instance.exists.return_value = True - mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.txt' - - is_valid, error = validate_executable_path(txt_file) - assert not is_valid - assert "not executable" in error.lower() - - -def test_is_executable_windows(): - """Test _is_executable on Windows.""" - with patch('os.name', new='nt'): - exe_path = MagicMock() - exe_path.suffix.lower.return_value = '.exe' - assert _is_executable(exe_path) - - txt_path = MagicMock() - txt_path.suffix.lower.return_value = '.txt' - assert not _is_executable(txt_path) - -def test_is_subpath(): - """Test _is_subpath logic.""" - # This is hard to test without real paths, so we'll test the logic - parent = Path(r"C:\Program Files") - child = Path(r"C:\Program Files\Adobe\Photoshop.exe") - - # Mock the relative_to to simulate success - with patch.object(Path, 'resolve') as mock_resolve: - mock_resolve.return_value.relative_to = MagicMock() - result = _is_subpath(child, parent) - assert result - - -def test_wrong_executable_name_for_type(): - """Test that wrong executable names generate warnings but don't fail.""" - wrong_exe = r"C:\Program Files\Adobe\NotPhotoshop.exe" - - with patch('faststack.io.executable_validator.Path') as mock_path: - mock_path_instance = MagicMock() - mock_path.return_value.resolve.return_value = mock_path_instance - mock_path_instance.exists.return_value = True - mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.exe' - mock_path_instance.name = "NotPhotoshop.exe" - mock_path_instance.__str__ = lambda self: wrong_exe - - with patch('faststack.io.executable_validator._is_subpath', return_value=True): - # Should still pass, but with a warning logged - is_valid, error = validate_executable_path( - wrong_exe, - app_type="photoshop" - ) - assert is_valid # Name mismatch is warning, not failure +"""Tests for executable path validation.""" + +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from faststack.io.executable_validator import ( + validate_executable_path, + _is_executable, + _is_subpath, +) + + +def test_empty_path(): + """Test that empty path is rejected.""" + is_valid, error = validate_executable_path("") + assert not is_valid + assert "empty" in error.lower() + + +def test_nonexistent_file(): + """Test that nonexistent file is rejected.""" + is_valid, error = validate_executable_path("C:\\nonexistent\\fake.exe") + assert not is_valid + assert "not found" in error.lower() + + +def test_valid_photoshop_path(): + """Test validation of a valid Photoshop path.""" + photoshop_path = r"C:\Program Files\Adobe\Adobe Photoshop 2026\Photoshop.exe" + + # Mock the path checks + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.name = "Photoshop.exe" + mock_path_instance.__str__ = lambda self: photoshop_path + + with patch('faststack.io.executable_validator._is_subpath', return_value=True): + is_valid, error = validate_executable_path( + photoshop_path, + app_type="photoshop" + ) + assert is_valid + assert error is None + + +def test_suspicious_path_with_traversal(): + """Test that paths with directory traversal are flagged.""" + suspicious_path = r"C:\Program Files\..\Windows\System32\malware.exe" + + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.name = "malware.exe" + mock_path_instance.__str__ = lambda self: r"C:\Windows\System32\malware.exe" + + # The normalized path will differ from input, triggering warning + 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(): + """Test that non-executable files are rejected on Windows.""" + txt_file = r"C:\Program Files\test.txt" + + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.txt' + + is_valid, error = validate_executable_path(txt_file) + assert not is_valid + assert "not executable" in error.lower() + + +def test_is_executable_windows(): + """Test _is_executable on Windows.""" + with patch('os.name', new='nt'): + exe_path = MagicMock() + exe_path.suffix.lower.return_value = '.exe' + assert _is_executable(exe_path) + + txt_path = MagicMock() + txt_path.suffix.lower.return_value = '.txt' + assert not _is_executable(txt_path) + +def test_is_subpath(): + """Test _is_subpath logic.""" + # This is hard to test without real paths, so we'll test the logic + parent = Path(r"C:\Program Files") + child = Path(r"C:\Program Files\Adobe\Photoshop.exe") + + # Mock the relative_to to simulate success + with patch.object(Path, 'resolve') as mock_resolve: + mock_resolve.return_value.relative_to = MagicMock() + result = _is_subpath(child, parent) + assert result + + +def test_wrong_executable_name_for_type(): + """Test that wrong executable names generate warnings but don't fail.""" + wrong_exe = r"C:\Program Files\Adobe\NotPhotoshop.exe" + + with patch('faststack.io.executable_validator.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path.return_value.resolve.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + mock_path_instance.is_file.return_value = True + mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.name = "NotPhotoshop.exe" + mock_path_instance.__str__ = lambda self: wrong_exe + + with patch('faststack.io.executable_validator._is_subpath', return_value=True): + # Should still pass, but with a warning logged + is_valid, error = validate_executable_path( + wrong_exe, + app_type="photoshop" + ) + assert is_valid # Name mismatch is warning, not failure diff --git a/faststack/tests/test_exif_display_rotation.py b/faststack/tests/test_exif_display_rotation.py index 85e9d1c..c2dd055 100644 --- a/faststack/tests/test_exif_display_rotation.py +++ b/faststack/tests/test_exif_display_rotation.py @@ -1,173 +1,173 @@ -"""Tests for EXIF orientation correction during display.""" - -import os -import sys -import shutil -import tempfile -import unittest -from pathlib import Path - -import numpy as np -from PIL import Image, ExifTags - -# Adjust path to import faststack -sys.path.insert(0, str(Path(__file__).parents[1])) - -from faststack.imaging.prefetch import apply_exif_orientation - - -class TestExifDisplayOrientation(unittest.TestCase): - """Tests for apply_exif_orientation function.""" - - def setUp(self): - self.test_dir = tempfile.mkdtemp() - - def tearDown(self): - shutil.rmtree(self.test_dir) - - def _create_test_image(self, filename: str, orientation: int) -> Path: - """Creates a JPEG with a specific EXIF orientation. - - The image is 100x50 with red on left, blue on right. - This makes it easy to verify rotation by checking pixel colors. - """ - path = Path(self.test_dir) / filename - - # Create asymmetric image: 100w x 50h - # Left half (0-49) = red, right half (50-99) = blue - img = Image.new('RGB', (100, 50), color='red') - for x in range(50, 100): - for y in range(50): - img.putpixel((x, y), (0, 0, 255)) - - exif = img.getexif() - exif[ExifTags.Base.Orientation] = orientation - - img.save(path, format='JPEG', exif=exif.tobytes()) - return path - - def test_orientation_1_no_change(self): - """Orientation 1 (normal) should return unchanged buffer.""" - path = self._create_test_image("test_ori1.jpg", 1) - - with Image.open(path) as img: - original = np.array(img.convert("RGB")) - - result = apply_exif_orientation(original.copy(), path) - - self.assertEqual(result.shape, original.shape) - np.testing.assert_array_equal(result, original) - - def test_orientation_3_rotate_180(self): - """Orientation 3 should rotate 180 degrees.""" - path = self._create_test_image("test_ori3.jpg", 3) - - with Image.open(path) as img: - original = np.array(img.convert("RGB")) - - result = apply_exif_orientation(original.copy(), path) - - # Shape unchanged (still 50x100) - self.assertEqual(result.shape, original.shape) - - # After 180 rotation, top-left should now be blue (was bottom-right) - # Check that top-left pixel is blue - self.assertTrue(result[0, 0, 2] > 200) # Blue channel high - self.assertTrue(result[0, 0, 0] < 50) # Red channel low - - def test_orientation_6_rotate_90_cw(self): - """Orientation 6 should rotate 90 degrees clockwise (270 CCW).""" - path = self._create_test_image("test_ori6.jpg", 6) - - with Image.open(path) as img: - original = np.array(img.convert("RGB")) - - result = apply_exif_orientation(original.copy(), path) - - # Dimensions should swap: 100x50 -> 50x100 - self.assertEqual(result.shape, (100, 50, 3)) - - # After 90 CW rotation of [red-left, blue-right], - # top should be red, bottom should be blue - # Check top-left pixel is red - self.assertTrue(result[0, 0, 0] > 200) # Red channel high - self.assertTrue(result[0, 0, 2] < 50) # Blue channel low - - def test_orientation_8_rotate_90_ccw(self): - """Orientation 8 should rotate 90 degrees counter-clockwise.""" - path = self._create_test_image("test_ori8.jpg", 8) - - with Image.open(path) as img: - original = np.array(img.convert("RGB")) - - result = apply_exif_orientation(original.copy(), path) - - # Dimensions should swap: 100x50 -> 50x100 - self.assertEqual(result.shape, (100, 50, 3)) - - # After 90 CCW rotation of [red-left, blue-right], - # top should be blue, bottom should be red - # Check top-left pixel is blue - self.assertTrue(result[0, 0, 2] > 200) # Blue channel high - self.assertTrue(result[0, 0, 0] < 50) # Red channel low - - def test_orientation_2_mirror_horizontal(self): - """Orientation 2 should mirror horizontally.""" - path = self._create_test_image("test_ori2.jpg", 2) - - with Image.open(path) as img: - original = np.array(img.convert("RGB")) - - result = apply_exif_orientation(original.copy(), path) - - # Shape unchanged - self.assertEqual(result.shape, original.shape) - - # After horizontal flip, left becomes blue, right becomes red - # Check top-left pixel is blue - self.assertTrue(result[0, 0, 2] > 200) # Blue channel high - self.assertTrue(result[0, 0, 0] < 50) # Red channel low - - def test_no_exif_returns_unchanged(self): - """Image without EXIF should return unchanged buffer.""" - path = Path(self.test_dir) / "no_exif.jpg" - - # Create image without EXIF - img = Image.new('RGB', (100, 50), color='green') - img.save(path, format='JPEG') - - with Image.open(path) as img: - original = np.array(img.convert("RGB")) - - result = apply_exif_orientation(original.copy(), path) - - np.testing.assert_array_equal(result, original) - - def test_invalid_path_returns_unchanged(self): - """Non-existent file should return unchanged buffer.""" - path = Path(self.test_dir) / "nonexistent.jpg" - - dummy = np.zeros((50, 100, 3), dtype=np.uint8) - result = apply_exif_orientation(dummy.copy(), path) - - np.testing.assert_array_equal(result, dummy) - - def test_orientation_contiguity(self): - """Verify that the result is always C-contiguous after transformations.""" - # Orientation 6 involves rotation which often results in non-contiguous arrays - path = self._create_test_image("test_contiguity.jpg", 6) - - with Image.open(path) as img: - original = np.array(img.convert("RGB")) - - # Ensure input is contiguous - self.assertTrue(original.flags['C_CONTIGUOUS']) - - result = apply_exif_orientation(original, path) - - # Verify the result is C-contiguous - self.assertTrue(result.flags['C_CONTIGUOUS'], "Result of apply_exif_orientation should be C-contiguous") - - -if __name__ == '__main__': - unittest.main() +"""Tests for EXIF orientation correction during display.""" + +import os +import sys +import shutil +import tempfile +import unittest +from pathlib import Path + +import numpy as np +from PIL import Image, ExifTags + +# Adjust path to import faststack +sys.path.insert(0, str(Path(__file__).parents[1])) + +from faststack.imaging.prefetch import apply_exif_orientation + + +class TestExifDisplayOrientation(unittest.TestCase): + """Tests for apply_exif_orientation function.""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def _create_test_image(self, filename: str, orientation: int) -> Path: + """Creates a JPEG with a specific EXIF orientation. + + The image is 100x50 with red on left, blue on right. + This makes it easy to verify rotation by checking pixel colors. + """ + path = Path(self.test_dir) / filename + + # Create asymmetric image: 100w x 50h + # Left half (0-49) = red, right half (50-99) = blue + img = Image.new('RGB', (100, 50), color='red') + for x in range(50, 100): + for y in range(50): + img.putpixel((x, y), (0, 0, 255)) + + exif = img.getexif() + exif[ExifTags.Base.Orientation] = orientation + + img.save(path, format='JPEG', exif=exif.tobytes()) + return path + + def test_orientation_1_no_change(self): + """Orientation 1 (normal) should return unchanged buffer.""" + path = self._create_test_image("test_ori1.jpg", 1) + + with Image.open(path) as img: + original = np.array(img.convert("RGB")) + + result = apply_exif_orientation(original.copy(), path) + + self.assertEqual(result.shape, original.shape) + np.testing.assert_array_equal(result, original) + + def test_orientation_3_rotate_180(self): + """Orientation 3 should rotate 180 degrees.""" + path = self._create_test_image("test_ori3.jpg", 3) + + with Image.open(path) as img: + original = np.array(img.convert("RGB")) + + result = apply_exif_orientation(original.copy(), path) + + # Shape unchanged (still 50x100) + self.assertEqual(result.shape, original.shape) + + # After 180 rotation, top-left should now be blue (was bottom-right) + # Check that top-left pixel is blue + self.assertTrue(result[0, 0, 2] > 200) # Blue channel high + self.assertTrue(result[0, 0, 0] < 50) # Red channel low + + def test_orientation_6_rotate_90_cw(self): + """Orientation 6 should rotate 90 degrees clockwise (270 CCW).""" + path = self._create_test_image("test_ori6.jpg", 6) + + with Image.open(path) as img: + original = np.array(img.convert("RGB")) + + result = apply_exif_orientation(original.copy(), path) + + # Dimensions should swap: 100x50 -> 50x100 + self.assertEqual(result.shape, (100, 50, 3)) + + # After 90 CW rotation of [red-left, blue-right], + # top should be red, bottom should be blue + # Check top-left pixel is red + self.assertTrue(result[0, 0, 0] > 200) # Red channel high + self.assertTrue(result[0, 0, 2] < 50) # Blue channel low + + def test_orientation_8_rotate_90_ccw(self): + """Orientation 8 should rotate 90 degrees counter-clockwise.""" + path = self._create_test_image("test_ori8.jpg", 8) + + with Image.open(path) as img: + original = np.array(img.convert("RGB")) + + result = apply_exif_orientation(original.copy(), path) + + # Dimensions should swap: 100x50 -> 50x100 + self.assertEqual(result.shape, (100, 50, 3)) + + # After 90 CCW rotation of [red-left, blue-right], + # top should be blue, bottom should be red + # Check top-left pixel is blue + self.assertTrue(result[0, 0, 2] > 200) # Blue channel high + self.assertTrue(result[0, 0, 0] < 50) # Red channel low + + def test_orientation_2_mirror_horizontal(self): + """Orientation 2 should mirror horizontally.""" + path = self._create_test_image("test_ori2.jpg", 2) + + with Image.open(path) as img: + original = np.array(img.convert("RGB")) + + result = apply_exif_orientation(original.copy(), path) + + # Shape unchanged + self.assertEqual(result.shape, original.shape) + + # After horizontal flip, left becomes blue, right becomes red + # Check top-left pixel is blue + self.assertTrue(result[0, 0, 2] > 200) # Blue channel high + self.assertTrue(result[0, 0, 0] < 50) # Red channel low + + def test_no_exif_returns_unchanged(self): + """Image without EXIF should return unchanged buffer.""" + path = Path(self.test_dir) / "no_exif.jpg" + + # Create image without EXIF + img = Image.new('RGB', (100, 50), color='green') + img.save(path, format='JPEG') + + with Image.open(path) as img: + original = np.array(img.convert("RGB")) + + result = apply_exif_orientation(original.copy(), path) + + np.testing.assert_array_equal(result, original) + + def test_invalid_path_returns_unchanged(self): + """Non-existent file should return unchanged buffer.""" + path = Path(self.test_dir) / "nonexistent.jpg" + + dummy = np.zeros((50, 100, 3), dtype=np.uint8) + result = apply_exif_orientation(dummy.copy(), path) + + np.testing.assert_array_equal(result, dummy) + + def test_orientation_contiguity(self): + """Verify that the result is always C-contiguous after transformations.""" + # Orientation 6 involves rotation which often results in non-contiguous arrays + path = self._create_test_image("test_contiguity.jpg", 6) + + with Image.open(path) as img: + original = np.array(img.convert("RGB")) + + # Ensure input is contiguous + self.assertTrue(original.flags['C_CONTIGUOUS']) + + result = apply_exif_orientation(original, path) + + # Verify the result is C-contiguous + self.assertTrue(result.flags['C_CONTIGUOUS'], "Result of apply_exif_orientation should be C-contiguous") + + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_exif_orientation.py b/faststack/tests/test_exif_orientation.py index 394e487..3f95e90 100644 --- a/faststack/tests/test_exif_orientation.py +++ b/faststack/tests/test_exif_orientation.py @@ -1,173 +1,173 @@ - -import os -import shutil -import tempfile -import unittest -from pathlib import Path -from PIL import Image, ExifTags -import numpy as np - -# Adjust path to import faststack -from unittest.mock import MagicMock, patch -import sys -# Removed global sys.modules override -sys.path.append(str(Path(__file__).parents[2])) - -# MOVED: from faststack.imaging.editor import ImageEditor - -class TestExifOrientation(unittest.TestCase): - def setUp(self): - # Patch sys.modules safely per-test - self.modules_patcher = patch.dict(sys.modules, {'cv2': MagicMock()}) - self.modules_patcher.start() - - # Import internally to respect the patch - try: - from faststack.imaging.editor import ImageEditor - self.ImageEditorClass = ImageEditor - except ImportError: - # Fallback if path issues persist (shouldn't with sys.path.append) - raise - - self.test_dir = tempfile.mkdtemp() - self.editor = self.ImageEditorClass() - - def tearDown(self): - self.modules_patcher.stop() - shutil.rmtree(self.test_dir) - # Ensure we don't pollute other tests with our mocked-import version - sys.modules.pop('faststack.imaging.editor', None) - - def _create_test_image(self, filename, orientation=1): - """Creates a dummy JPEG with specific EXIF orientation.""" - path = Path(self.test_dir) / filename - - # Create a simple image: Red on left, Blue on right (to detect rotation) - # 100x50 - img = Image.new('RGB', (100, 50), color='red') - # Make right half blue - for x in range(50, 100): - for y in range(50): - img.putpixel((x, y), (0, 0, 255)) - - exif = img.getexif() - exif[ExifTags.Base.Orientation] = orientation - # Add another tag to verify general EXIF preservation (e.g. ImageDescription) - # 0x010E is ImageDescription - exif[0x010E] = "Test Image" - - img.save(path, format='JPEG', exif=exif.tobytes()) - return path - - def test_orientation_sanitization_on_rotation(self): - """Verify Orientation is reset to 1 if we rotate the image.""" - for start_ori in [3, 6, 8]: - with self.subTest(start_ori=start_ori): - path = self._create_test_image(f"test_rot_{start_ori}.jpg", orientation=start_ori) - - # Load - self.editor.load_image(str(path)) - - # Apply Rotation (90 degrees) - this usually rotates CCW in our pipeline - # but the key is that 'transforms_applied' becomes True. - self.editor.current_edits['rotation'] = 90 - - # Save - saved_path, _ = self.editor.save_image() - - # Verify - with Image.open(saved_path) as res: - exif = res.getexif() - orientation = exif.get(ExifTags.Base.Orientation) - # Should be sanitized to 1 - self.assertEqual(orientation, 1, f"Expected Orientation 1, got {orientation} for start {start_ori}") - - # Double rotation check: if we reload this image, it should look correct - # without any further rotation needed. - # Start 6 (Vertical 50x100) -> Baked (50x100) -> Rotate 90 -> 100x50 - # Start 8 (Vertical 50x100) -> Baked (50x100) -> Rotate 90 -> 100x50 - # Start 3 (Horizontal 100x50) -> Baked (100x50) -> Rotate 90 -> 50x100 - expected_size = (50, 100) if start_ori == 3 else (100, 50) - self.assertEqual(res.size, expected_size, f"Dimensions check failed for start {start_ori} with rotation") - - def test_orientation_preserved_no_rotation(self): - """Verify Orientation is PRESERVED if we do NOT rotate.""" - for start_ori in [3, 6, 8]: - with self.subTest(start_ori=start_ori): - path = self._create_test_image(f"test_no_rot_{start_ori}.jpg", orientation=start_ori) - - # Load - self.editor.load_image(str(path)) - - # Apply NO geometric edits, just color - self.editor.current_edits['exposure'] = 0.5 - - # Save - saved_path, _ = self.editor.save_image() - - # Verify - with Image.open(saved_path) as res: - exif = res.getexif() - orientation = exif.get(ExifTags.Base.Orientation) - - # Should be sanitized to 1 because editor now ALWAYS bakes orientation - self.assertEqual(orientation, 1, f"Orientation should be sanitized to 1, got {orientation}") - - # Verify pixels are rotated if necessary (Start 5-8 involve 90 deg rotation or swap) - # Start 3 (180) -> Same dims - # Start 6 (90 CW) -> Swapped dims - # Start 8 (90 CCW) -> Swapped dims - if start_ori in [5, 6, 7, 8]: - self.assertEqual(res.size, (50, 100), f"Dimensions should be swapped for start {start_ori} due to baking") - else: - self.assertEqual(res.size, (100, 50), f"Dimensions should be preserved for start {start_ori}") - - def test_raw_mode_exif_preservation(self): - """Verify that camera EXIF from a source JPEG is preserved when 'developing' RAW (simulated with TIFF).""" - # 1. Create a "source" JPEG with camera EXIF and Orientation=6 - source_path = self._create_test_image("camera_source.jpg", orientation=6) - - with Image.open(source_path) as src: - source_exif_bytes = src.info.get('exif') - self.assertIsNotNone(source_exif_bytes, "Source image should have EXIF") - - # 2. Create a "working TIFF" (simulating developed RAW output) which lacks EXIF - tiff_path = Path(self.test_dir) / "working_source.tif" - tiff_img = Image.new('RGB', (100, 50), color='green') - tiff_img.save(tiff_path, format='TIFF') - - # 3. Load TIFF into editor, passing the source EXIF - self.editor.load_image(str(tiff_path), source_exif=source_exif_bytes) - - # 4. Save developed JPG WITHOUT transforms -> Orientation should be preserved (?) - # Actually, RAW development usually results in an image that is visually upright - # if the developer (RawTherapee) handled orientation. - # But our save_image logic says: if no transforms_applied, preserve original EXIF. - # If the original EXIF said Orientation=6, but the TIFF is already upright, - # we might get a "double rotation" IF the viewer respects EXIF. - # HOWEVER, the user said: "if you do sanitize, ensure you don’t accidentally lose other tags" - # and "ensure no 'double rotation' on reload". - - # If we ARE developing a RAW, we usually want to bake in the orientation - # or at least ensure the output is correct. - - # Let's test what happens currently: - res = self.editor.save_image(write_developed_jpg=True) - developed_path = Path(self.test_dir) / "working_source-developed.jpg" - - with Image.open(developed_path) as dev: - exif = dev.getexif() - self.assertEqual(exif.get(ExifTags.Base.Orientation), 6, "Orientation preserved if no editor transforms") - - # 5. Now apply an editor transform (90 deg) - self.editor.current_edits['rotation'] = 90 - self.editor.save_image(write_developed_jpg=True) - - with Image.open(developed_path) as dev: - exif = dev.getexif() - description = exif.get(0x010E) - self.assertEqual(description, "Test Image", "EXIF tags preserved") - self.assertEqual(exif.get(ExifTags.Base.Orientation), 1, "Orientation sanitized after rotation") - -if __name__ == '__main__': - unittest.main() + +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from PIL import Image, ExifTags +import numpy as np + +# Adjust path to import faststack +from unittest.mock import MagicMock, patch +import sys +# Removed global sys.modules override +sys.path.append(str(Path(__file__).parents[2])) + +# MOVED: from faststack.imaging.editor import ImageEditor + +class TestExifOrientation(unittest.TestCase): + def setUp(self): + # Patch sys.modules safely per-test + self.modules_patcher = patch.dict(sys.modules, {'cv2': MagicMock()}) + self.modules_patcher.start() + + # Import internally to respect the patch + try: + from faststack.imaging.editor import ImageEditor + self.ImageEditorClass = ImageEditor + except ImportError: + # Fallback if path issues persist (shouldn't with sys.path.append) + raise + + self.test_dir = tempfile.mkdtemp() + self.editor = self.ImageEditorClass() + + def tearDown(self): + self.modules_patcher.stop() + shutil.rmtree(self.test_dir) + # Ensure we don't pollute other tests with our mocked-import version + sys.modules.pop('faststack.imaging.editor', None) + + def _create_test_image(self, filename, orientation=1): + """Creates a dummy JPEG with specific EXIF orientation.""" + path = Path(self.test_dir) / filename + + # Create a simple image: Red on left, Blue on right (to detect rotation) + # 100x50 + img = Image.new('RGB', (100, 50), color='red') + # Make right half blue + for x in range(50, 100): + for y in range(50): + img.putpixel((x, y), (0, 0, 255)) + + exif = img.getexif() + exif[ExifTags.Base.Orientation] = orientation + # Add another tag to verify general EXIF preservation (e.g. ImageDescription) + # 0x010E is ImageDescription + exif[0x010E] = "Test Image" + + img.save(path, format='JPEG', exif=exif.tobytes()) + return path + + def test_orientation_sanitization_on_rotation(self): + """Verify Orientation is reset to 1 if we rotate the image.""" + for start_ori in [3, 6, 8]: + with self.subTest(start_ori=start_ori): + path = self._create_test_image(f"test_rot_{start_ori}.jpg", orientation=start_ori) + + # Load + self.editor.load_image(str(path)) + + # Apply Rotation (90 degrees) - this usually rotates CCW in our pipeline + # but the key is that 'transforms_applied' becomes True. + self.editor.current_edits['rotation'] = 90 + + # Save + saved_path, _ = self.editor.save_image() + + # Verify + with Image.open(saved_path) as res: + exif = res.getexif() + orientation = exif.get(ExifTags.Base.Orientation) + # Should be sanitized to 1 + self.assertEqual(orientation, 1, f"Expected Orientation 1, got {orientation} for start {start_ori}") + + # Double rotation check: if we reload this image, it should look correct + # without any further rotation needed. + # Start 6 (Vertical 50x100) -> Baked (50x100) -> Rotate 90 -> 100x50 + # Start 8 (Vertical 50x100) -> Baked (50x100) -> Rotate 90 -> 100x50 + # Start 3 (Horizontal 100x50) -> Baked (100x50) -> Rotate 90 -> 50x100 + expected_size = (50, 100) if start_ori == 3 else (100, 50) + self.assertEqual(res.size, expected_size, f"Dimensions check failed for start {start_ori} with rotation") + + def test_orientation_preserved_no_rotation(self): + """Verify Orientation is PRESERVED if we do NOT rotate.""" + for start_ori in [3, 6, 8]: + with self.subTest(start_ori=start_ori): + path = self._create_test_image(f"test_no_rot_{start_ori}.jpg", orientation=start_ori) + + # Load + self.editor.load_image(str(path)) + + # Apply NO geometric edits, just color + self.editor.current_edits['exposure'] = 0.5 + + # Save + saved_path, _ = self.editor.save_image() + + # Verify + with Image.open(saved_path) as res: + exif = res.getexif() + orientation = exif.get(ExifTags.Base.Orientation) + + # Should be sanitized to 1 because editor now ALWAYS bakes orientation + self.assertEqual(orientation, 1, f"Orientation should be sanitized to 1, got {orientation}") + + # Verify pixels are rotated if necessary (Start 5-8 involve 90 deg rotation or swap) + # Start 3 (180) -> Same dims + # Start 6 (90 CW) -> Swapped dims + # Start 8 (90 CCW) -> Swapped dims + if start_ori in [5, 6, 7, 8]: + self.assertEqual(res.size, (50, 100), f"Dimensions should be swapped for start {start_ori} due to baking") + else: + self.assertEqual(res.size, (100, 50), f"Dimensions should be preserved for start {start_ori}") + + def test_raw_mode_exif_preservation(self): + """Verify that camera EXIF from a source JPEG is preserved when 'developing' RAW (simulated with TIFF).""" + # 1. Create a "source" JPEG with camera EXIF and Orientation=6 + source_path = self._create_test_image("camera_source.jpg", orientation=6) + + with Image.open(source_path) as src: + source_exif_bytes = src.info.get('exif') + self.assertIsNotNone(source_exif_bytes, "Source image should have EXIF") + + # 2. Create a "working TIFF" (simulating developed RAW output) which lacks EXIF + tiff_path = Path(self.test_dir) / "working_source.tif" + tiff_img = Image.new('RGB', (100, 50), color='green') + tiff_img.save(tiff_path, format='TIFF') + + # 3. Load TIFF into editor, passing the source EXIF + self.editor.load_image(str(tiff_path), source_exif=source_exif_bytes) + + # 4. Save developed JPG WITHOUT transforms -> Orientation should be preserved (?) + # Actually, RAW development usually results in an image that is visually upright + # if the developer (RawTherapee) handled orientation. + # But our save_image logic says: if no transforms_applied, preserve original EXIF. + # If the original EXIF said Orientation=6, but the TIFF is already upright, + # we might get a "double rotation" IF the viewer respects EXIF. + # HOWEVER, the user said: "if you do sanitize, ensure you don’t accidentally lose other tags" + # and "ensure no 'double rotation' on reload". + + # If we ARE developing a RAW, we usually want to bake in the orientation + # or at least ensure the output is correct. + + # Let's test what happens currently: + res = self.editor.save_image(write_developed_jpg=True) + developed_path = Path(self.test_dir) / "working_source-developed.jpg" + + with Image.open(developed_path) as dev: + exif = dev.getexif() + self.assertEqual(exif.get(ExifTags.Base.Orientation), 6, "Orientation preserved if no editor transforms") + + # 5. Now apply an editor transform (90 deg) + self.editor.current_edits['rotation'] = 90 + self.editor.save_image(write_developed_jpg=True) + + with Image.open(developed_path) as dev: + exif = dev.getexif() + description = exif.get(0x010E) + self.assertEqual(description, "Test Image", "EXIF tags preserved") + self.assertEqual(exif.get(ExifTags.Base.Orientation), 1, "Orientation sanitized after rotation") + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_fallback_blur.py b/faststack/tests/test_fallback_blur.py index 432f245..d42ac32 100644 --- a/faststack/tests/test_fallback_blur.py +++ b/faststack/tests/test_fallback_blur.py @@ -1,47 +1,47 @@ - -import unittest -import numpy as np -from unittest.mock import patch, MagicMock -from PIL import Image - -# Import the functionality to test -from faststack.imaging import editor - -class TestFallbackBlur(unittest.TestCase): - - def test_fallback_blur_logic(self): - """Test that _gaussian_blur_float works even when cv2 is None""" - - # Setup a dummy float image (checkerboard) - # 0.0 and 1.0 values - arr = np.zeros((20, 20, 3), dtype=np.float32) - arr[::2, ::2] = 1.0 - - # Calculate expected "unblurred" std dev - orig_std = np.std(arr) - - # Mock cv2 to be None to force fallback path - with patch('faststack.imaging.editor.cv2', None): - # Verify we are hitting the fallback - self.assertIsNone(editor.cv2) - - # Run the blur function - blurred = editor._gaussian_blur_float(arr, radius=2.0) - - # Check shape/type preservation - self.assertEqual(blurred.shape, arr.shape) - self.assertEqual(blurred.dtype, np.float32) - - # Check that it actually blurred - # A blurred checkerboard should have lower standard deviation than the original - new_std = np.std(blurred) - print(f"Original Std: {orig_std:.4f}, Blurred Std: {new_std:.4f}") - - self.assertLess(new_std, orig_std, "Image should be blurred (lower variance)") - - # Additional check: max value should decrease, min value should increase (for 0/1 checkerboard) - self.assertLess(blurred.max(), 1.0) - self.assertGreater(blurred.min(), 0.0) - -if __name__ == '__main__': - unittest.main() + +import unittest +import numpy as np +from unittest.mock import patch, MagicMock +from PIL import Image + +# Import the functionality to test +from faststack.imaging import editor + +class TestFallbackBlur(unittest.TestCase): + + def test_fallback_blur_logic(self): + """Test that _gaussian_blur_float works even when cv2 is None""" + + # Setup a dummy float image (checkerboard) + # 0.0 and 1.0 values + arr = np.zeros((20, 20, 3), dtype=np.float32) + arr[::2, ::2] = 1.0 + + # Calculate expected "unblurred" std dev + orig_std = np.std(arr) + + # Mock cv2 to be None to force fallback path + with patch('faststack.imaging.editor.cv2', None): + # Verify we are hitting the fallback + self.assertIsNone(editor.cv2) + + # Run the blur function + blurred = editor._gaussian_blur_float(arr, radius=2.0) + + # Check shape/type preservation + self.assertEqual(blurred.shape, arr.shape) + self.assertEqual(blurred.dtype, np.float32) + + # Check that it actually blurred + # A blurred checkerboard should have lower standard deviation than the original + new_std = np.std(blurred) + print(f"Original Std: {orig_std:.4f}, Blurred Std: {new_std:.4f}") + + self.assertLess(new_std, orig_std, "Image should be blurred (lower variance)") + + # Additional check: max value should decrease, min value should increase (for 0/1 checkerboard) + self.assertLess(blurred.max(), 1.0) + self.assertGreater(blurred.min(), 0.0) + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_file_locking.py b/faststack/tests/test_file_locking.py index d64bc2b..30a5174 100644 --- a/faststack/tests/test_file_locking.py +++ b/faststack/tests/test_file_locking.py @@ -1,137 +1,137 @@ -"""Tests for file locking handling in undo operations.""" -import unittest -from unittest.mock import MagicMock, patch, PropertyMock -from pathlib import Path -import tempfile -import shutil -import os - - -class TestRestoreBackupSafe(unittest.TestCase): - """Tests for _restore_backup_safe method without mocking.""" - - def setUp(self): - """Create temp directory with test files.""" - self.temp_dir = tempfile.mkdtemp() - self.saved_path = Path(self.temp_dir) / "test_image.jpg" - self.backup_path = Path(self.temp_dir) / "test_image.jpg.backup" - - # Create a backup file with content - self.backup_path.write_bytes(b"backup content") - - def tearDown(self): - """Clean up temp directory.""" - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def _make_controller(self): - """Create a minimal controller with just what _restore_backup_safe needs.""" - # We can't easily instantiate AppController, so we'll test the logic directly - # by calling the function with a mock self - from faststack.app import AppController - - # Patch __init__ to skip complex initialization - with patch.object(AppController, '__init__', return_value=None): - controller = AppController() - controller.update_status_message = MagicMock() - return controller - - def test_simple_restore_no_target(self): - """Test restoring backup when target doesn't exist.""" - controller = self._make_controller() - - # Target doesn't exist, backup exists - self.assertFalse(self.saved_path.exists()) - self.assertTrue(self.backup_path.exists()) - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - - self.assertTrue(result) - self.assertTrue(self.saved_path.exists()) - self.assertFalse(self.backup_path.exists()) - self.assertEqual(self.saved_path.read_bytes(), b"backup content") - - def test_restore_replaces_target(self): - """Test restoring backup when target already exists (replaced cleanly).""" - controller = self._make_controller() - - # Create both files - self.saved_path.write_bytes(b"old content") - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - - self.assertTrue(result) - self.assertTrue(self.saved_path.exists()) - self.assertFalse(self.backup_path.exists()) - self.assertEqual(self.saved_path.read_bytes(), b"backup content") - - def test_backup_not_found(self): - """Test handling when backup file doesn't exist.""" - controller = self._make_controller() - - # Remove backup - self.backup_path.unlink() - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - - self.assertFalse(result) - controller.update_status_message.assert_called() - - def test_verification_after_move(self): - """Test that the method verifies the file exists after move.""" - controller = self._make_controller() - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - - self.assertTrue(result) - # File must exist and have content - self.assertTrue(self.saved_path.exists()) - self.assertGreater(self.saved_path.stat().st_size, 0) - - def test_unique_temp_path_used(self): - """Test that unique temp paths don't collide with existing files.""" - controller = self._make_controller() - - # Create a file that would collide with a fixed .tmp_restore suffix - collision_path = self.saved_path.with_suffix('.tmp_restore') - collision_path.write_bytes(b"collision content") - - # Create target to force the locked-file path - self.saved_path.write_bytes(b"old content") - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - - self.assertTrue(result) - # Collision file should be untouched - self.assertTrue(collision_path.exists()) - self.assertEqual(collision_path.read_bytes(), b"collision content") - - -class TestUndoDeleteVerification(unittest.TestCase): - """Integration tests for restore_file verification in undo_delete.""" - - def setUp(self): - self.temp_dir = tempfile.mkdtemp() - - def tearDown(self): - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_restore_file_verifies_success(self): - """Test that restore_file nested function verifies shutil.move succeeded.""" - src_path = Path(self.temp_dir) / "source.jpg" - bin_path = Path(self.temp_dir) / "bin" / "source.jpg" - - # Create bin directory and file - bin_path.parent.mkdir(parents=True, exist_ok=True) - bin_path.write_bytes(b"test content") - - # Move it - shutil.move(str(bin_path), str(src_path)) - - # Verify it worked - self.assertTrue(src_path.exists()) - self.assertFalse(bin_path.exists()) - self.assertEqual(src_path.read_bytes(), b"test content") - - -if __name__ == '__main__': - unittest.main(verbosity=2) +"""Tests for file locking handling in undo operations.""" +import unittest +from unittest.mock import MagicMock, patch, PropertyMock +from pathlib import Path +import tempfile +import shutil +import os + + +class TestRestoreBackupSafe(unittest.TestCase): + """Tests for _restore_backup_safe method without mocking.""" + + def setUp(self): + """Create temp directory with test files.""" + self.temp_dir = tempfile.mkdtemp() + self.saved_path = Path(self.temp_dir) / "test_image.jpg" + self.backup_path = Path(self.temp_dir) / "test_image.jpg.backup" + + # Create a backup file with content + self.backup_path.write_bytes(b"backup content") + + def tearDown(self): + """Clean up temp directory.""" + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def _make_controller(self): + """Create a minimal controller with just what _restore_backup_safe needs.""" + # We can't easily instantiate AppController, so we'll test the logic directly + # by calling the function with a mock self + from faststack.app import AppController + + # Patch __init__ to skip complex initialization + with patch.object(AppController, '__init__', return_value=None): + controller = AppController() + controller.update_status_message = MagicMock() + return controller + + def test_simple_restore_no_target(self): + """Test restoring backup when target doesn't exist.""" + controller = self._make_controller() + + # Target doesn't exist, backup exists + self.assertFalse(self.saved_path.exists()) + self.assertTrue(self.backup_path.exists()) + + result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) + + self.assertTrue(result) + self.assertTrue(self.saved_path.exists()) + self.assertFalse(self.backup_path.exists()) + self.assertEqual(self.saved_path.read_bytes(), b"backup content") + + def test_restore_replaces_target(self): + """Test restoring backup when target already exists (replaced cleanly).""" + controller = self._make_controller() + + # Create both files + self.saved_path.write_bytes(b"old content") + + result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) + + self.assertTrue(result) + self.assertTrue(self.saved_path.exists()) + self.assertFalse(self.backup_path.exists()) + self.assertEqual(self.saved_path.read_bytes(), b"backup content") + + def test_backup_not_found(self): + """Test handling when backup file doesn't exist.""" + controller = self._make_controller() + + # Remove backup + self.backup_path.unlink() + + result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) + + self.assertFalse(result) + controller.update_status_message.assert_called() + + def test_verification_after_move(self): + """Test that the method verifies the file exists after move.""" + controller = self._make_controller() + + result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) + + self.assertTrue(result) + # File must exist and have content + self.assertTrue(self.saved_path.exists()) + self.assertGreater(self.saved_path.stat().st_size, 0) + + def test_unique_temp_path_used(self): + """Test that unique temp paths don't collide with existing files.""" + controller = self._make_controller() + + # Create a file that would collide with a fixed .tmp_restore suffix + collision_path = self.saved_path.with_suffix('.tmp_restore') + collision_path.write_bytes(b"collision content") + + # Create target to force the locked-file path + self.saved_path.write_bytes(b"old content") + + result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) + + self.assertTrue(result) + # Collision file should be untouched + self.assertTrue(collision_path.exists()) + self.assertEqual(collision_path.read_bytes(), b"collision content") + + +class TestUndoDeleteVerification(unittest.TestCase): + """Integration tests for restore_file verification in undo_delete.""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_restore_file_verifies_success(self): + """Test that restore_file nested function verifies shutil.move succeeded.""" + src_path = Path(self.temp_dir) / "source.jpg" + bin_path = Path(self.temp_dir) / "bin" / "source.jpg" + + # Create bin directory and file + bin_path.parent.mkdir(parents=True, exist_ok=True) + bin_path.write_bytes(b"test content") + + # Move it + shutil.move(str(bin_path), str(src_path)) + + # Verify it worked + self.assertTrue(src_path.exists()) + self.assertFalse(bin_path.exists()) + self.assertEqual(src_path.read_bytes(), b"test content") + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/faststack/tests/test_generation_aware_preview.py b/faststack/tests/test_generation_aware_preview.py index 62c08ca..dfa3fbf 100644 --- a/faststack/tests/test_generation_aware_preview.py +++ b/faststack/tests/test_generation_aware_preview.py @@ -1,111 +1,111 @@ - -import unittest -from unittest.mock import MagicMock, patch -from PySide6.QtCore import QObject -from PySide6.QtGui import QImage - -# Import the class to test (assuming it's importable) -# We might need to mock imports if they depend on full Qt app structure -import sys -import os - -# Adjust path to allow imports -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -from faststack.ui.provider import ImageProvider - -class TestGenerationAwarePreview(unittest.TestCase): - def setUp(self): - self.mock_controller = MagicMock() - self.mock_controller.ui_state = MagicMock() - self.mock_controller.ui_state.isEditorOpen = True - self.mock_controller.ui_state.isZoomed = False - self.mock_controller.current_index = 0 - - # Setup mock images - self.mock_preview = MagicMock() - self.mock_preview.buffer = b'\x00' * 100 - self.mock_preview.width = 10 - self.mock_preview.height = 10 - self.mock_preview.bytes_per_line = 30 - self.mock_preview.format = QImage.Format.Format_RGB888 - - self.mock_decoded = MagicMock() - self.mock_decoded.buffer = b'\xFF' * 100 - self.mock_decoded.width = 10 - self.mock_decoded.height = 10 - self.mock_decoded.bytes_per_line = 30 - self.mock_decoded.format = QImage.Format.Format_RGB888 - - self.mock_controller._last_rendered_preview = self.mock_preview - self.mock_controller.get_decoded_image.return_value = self.mock_decoded - - self.provider = ImageProvider(self.mock_controller) - - def test_matching_generation(self): - """Should serve preview when generation matches.""" - # Setup matching state - self.mock_controller._last_rendered_preview_index = 0 - self.mock_controller._last_rendered_preview_gen = 5 - - # Request with matching generation - img = self.provider.requestImage("0/5", None, None) - - # Should be the preview (dark gray placeholder if fails, but here we mocked QImage creation?) - # Wait, requestImage creates a QImage from the buffer. - # We check WHICH buffer was used. - # Since we cannot easily check the pixels of the returned QImage without a GUI instance, - # we can check if get_decoded_image was called. - - # If it used preview, get_decoded_image should NOT be called (or only if preview is None) - # But wait, logic is: - # image_data = self.app_controller._last_rendered_preview if use_editor_preview else self.app_controller.get_decoded_image(index) - - # So we reset the mock - self.mock_controller.get_decoded_image.reset_mock() - - self.provider.requestImage("0/5", None, None) - - self.mock_controller.get_decoded_image.assert_not_called() - - def test_mismatched_generation(self): - """Should fallback to decoded image when generation does not match.""" - # Setup state: preview is old (gen 4) - self.mock_controller._last_rendered_preview_index = 0 - self.mock_controller._last_rendered_preview_gen = 4 - - # Request new generation (5) - self.mock_controller.get_decoded_image.reset_mock() - - self.provider.requestImage("0/5", None, None) - - self.mock_controller.get_decoded_image.assert_called_with(0) - - def test_mismatched_index(self): - """Should fallback when index does not match.""" - self.mock_controller._last_rendered_preview_index = 1 - self.mock_controller._last_rendered_preview_gen = 5 - - self.mock_controller.get_decoded_image.reset_mock() - self.provider.requestImage("0/5", None, None) - - self.mock_controller.get_decoded_image.assert_called_with(0) - - def test_no_generation_checking_if_not_provided(self): - """If generation not provided in ID, should ignore tracking? - The code says: (gen is None or getattr(...) == gen) - If ID is '0', gen is None. - (None is None) is True. So it matches. - So legacy requests (without gen) will still serve preview if index matches. - """ - self.mock_controller._last_rendered_preview_index = 0 - self.mock_controller._last_rendered_preview_gen = 99 - - self.mock_controller.get_decoded_image.reset_mock() - # Request without generation - self.provider.requestImage("0", None, None) - - self.mock_controller.get_decoded_image.assert_not_called() - -if __name__ == '__main__': - unittest.main() + +import unittest +from unittest.mock import MagicMock, patch +from PySide6.QtCore import QObject +from PySide6.QtGui import QImage + +# Import the class to test (assuming it's importable) +# We might need to mock imports if they depend on full Qt app structure +import sys +import os + +# Adjust path to allow imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from faststack.ui.provider import ImageProvider + +class TestGenerationAwarePreview(unittest.TestCase): + def setUp(self): + self.mock_controller = MagicMock() + self.mock_controller.ui_state = MagicMock() + self.mock_controller.ui_state.isEditorOpen = True + self.mock_controller.ui_state.isZoomed = False + self.mock_controller.current_index = 0 + + # Setup mock images + self.mock_preview = MagicMock() + self.mock_preview.buffer = b'\x00' * 100 + self.mock_preview.width = 10 + self.mock_preview.height = 10 + self.mock_preview.bytes_per_line = 30 + self.mock_preview.format = QImage.Format.Format_RGB888 + + self.mock_decoded = MagicMock() + self.mock_decoded.buffer = b'\xFF' * 100 + self.mock_decoded.width = 10 + self.mock_decoded.height = 10 + self.mock_decoded.bytes_per_line = 30 + self.mock_decoded.format = QImage.Format.Format_RGB888 + + self.mock_controller._last_rendered_preview = self.mock_preview + self.mock_controller.get_decoded_image.return_value = self.mock_decoded + + self.provider = ImageProvider(self.mock_controller) + + def test_matching_generation(self): + """Should serve preview when generation matches.""" + # Setup matching state + self.mock_controller._last_rendered_preview_index = 0 + self.mock_controller._last_rendered_preview_gen = 5 + + # Request with matching generation + img = self.provider.requestImage("0/5", None, None) + + # Should be the preview (dark gray placeholder if fails, but here we mocked QImage creation?) + # Wait, requestImage creates a QImage from the buffer. + # We check WHICH buffer was used. + # Since we cannot easily check the pixels of the returned QImage without a GUI instance, + # we can check if get_decoded_image was called. + + # If it used preview, get_decoded_image should NOT be called (or only if preview is None) + # But wait, logic is: + # image_data = self.app_controller._last_rendered_preview if use_editor_preview else self.app_controller.get_decoded_image(index) + + # So we reset the mock + self.mock_controller.get_decoded_image.reset_mock() + + self.provider.requestImage("0/5", None, None) + + self.mock_controller.get_decoded_image.assert_not_called() + + def test_mismatched_generation(self): + """Should fallback to decoded image when generation does not match.""" + # Setup state: preview is old (gen 4) + self.mock_controller._last_rendered_preview_index = 0 + self.mock_controller._last_rendered_preview_gen = 4 + + # Request new generation (5) + self.mock_controller.get_decoded_image.reset_mock() + + self.provider.requestImage("0/5", None, None) + + self.mock_controller.get_decoded_image.assert_called_with(0) + + def test_mismatched_index(self): + """Should fallback when index does not match.""" + self.mock_controller._last_rendered_preview_index = 1 + self.mock_controller._last_rendered_preview_gen = 5 + + self.mock_controller.get_decoded_image.reset_mock() + self.provider.requestImage("0/5", None, None) + + self.mock_controller.get_decoded_image.assert_called_with(0) + + def test_no_generation_checking_if_not_provided(self): + """If generation not provided in ID, should ignore tracking? + The code says: (gen is None or getattr(...) == gen) + If ID is '0', gen is None. + (None is None) is True. So it matches. + So legacy requests (without gen) will still serve preview if index matches. + """ + self.mock_controller._last_rendered_preview_index = 0 + self.mock_controller._last_rendered_preview_gen = 99 + + self.mock_controller.get_decoded_image.reset_mock() + # Request without generation + self.provider.requestImage("0", None, None) + + self.mock_controller.get_decoded_image.assert_not_called() + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_headroom_semantics.py b/faststack/tests/test_headroom_semantics.py index 5a2f68d..95d1da4 100644 --- a/faststack/tests/test_headroom_semantics.py +++ b/faststack/tests/test_headroom_semantics.py @@ -1,61 +1,61 @@ -import unittest -import numpy as np -import sys -import os -from unittest.mock import MagicMock - -# Mock cv2/turbojpeg -sys.modules['cv2'] = MagicMock() -sys.modules['turbojpeg'] = MagicMock() -sys.modules['PyTurboJPEG'] = MagicMock() - -# Ensure faststack is in path -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) - -from faststack.imaging.editor import ImageEditor, _analyze_highlight_state, _srgb_to_linear - -class TestHeadroomSemantics(unittest.TestCase): - def test_headroom_exposure_independence(self): - """Verify headroom calculation ignores exposure gain.""" - # 1. Create a synthetic image with max=1.0 (No headroom) - # Linear space: 1.0 - img = np.ones((100, 100, 3), dtype=np.float32) * 1.0 - - # 2. Analyze state with NO exposure change - # Pre-exposure is same as input - state = _analyze_highlight_state(img, pre_exposure_linear=img) - self.assertEqual(state['headroom_pct'], 0.0) - - # 3. Simulate High Exposure (+1 EV -> 2x gain) - # Current linear becomes 2.0 - exposed_img = img * 2.0 - - # Analyze: pass exposed as 'rgb_linear' but original as 'pre_exposure_linear' - state_exposed = _analyze_highlight_state(exposed_img, pre_exposure_linear=img) - - # Headroom should STILL be 0.0 because pre-exposure < 1.0 - self.assertEqual(state_exposed['headroom_pct'], 0.0, "Headroom should not be triggering just because of exposure") - - # 4. Reference: If we didn't pass pre-exposure, it WOULD show headroom - state_naive = _analyze_highlight_state(exposed_img, pre_exposure_linear=None) - self.assertGreater(state_naive['headroom_pct'], 0.99, "Naive analysis should show headroom (sanity check)") - - def test_true_headroom_detection(self): - """Verify actual headroom is detected regardless of exposure.""" - # 1. Image with real headroom (max=1.5) - img = np.ones((100, 100, 3), dtype=np.float32) * 1.5 - - # 2. Even if we darken it (-1 EV -> 0.75), we should know it HAD headroom? - # Typically "headroom" implies "recoverable data". - # If I underexpose a RAW, I still want to know it has headroom. - # Actually, if I underexpose, the values become < 1.0. - # But the SOURCE has values > 1.0. - # So yes, headroom_pct should ideally reflect source capability. - - darkened_img = img * 0.5 - - state = _analyze_highlight_state(darkened_img, pre_exposure_linear=img) - self.assertGreater(state['headroom_pct'], 0.99) - -if __name__ == '__main__': - unittest.main() +import unittest +import numpy as np +import sys +import os +from unittest.mock import MagicMock + +# Mock cv2/turbojpeg +sys.modules['cv2'] = MagicMock() +sys.modules['turbojpeg'] = MagicMock() +sys.modules['PyTurboJPEG'] = MagicMock() + +# Ensure faststack is in path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from faststack.imaging.editor import ImageEditor, _analyze_highlight_state, _srgb_to_linear + +class TestHeadroomSemantics(unittest.TestCase): + def test_headroom_exposure_independence(self): + """Verify headroom calculation ignores exposure gain.""" + # 1. Create a synthetic image with max=1.0 (No headroom) + # Linear space: 1.0 + img = np.ones((100, 100, 3), dtype=np.float32) * 1.0 + + # 2. Analyze state with NO exposure change + # Pre-exposure is same as input + state = _analyze_highlight_state(img, pre_exposure_linear=img) + self.assertEqual(state['headroom_pct'], 0.0) + + # 3. Simulate High Exposure (+1 EV -> 2x gain) + # Current linear becomes 2.0 + exposed_img = img * 2.0 + + # Analyze: pass exposed as 'rgb_linear' but original as 'pre_exposure_linear' + state_exposed = _analyze_highlight_state(exposed_img, pre_exposure_linear=img) + + # Headroom should STILL be 0.0 because pre-exposure < 1.0 + self.assertEqual(state_exposed['headroom_pct'], 0.0, "Headroom should not be triggering just because of exposure") + + # 4. Reference: If we didn't pass pre-exposure, it WOULD show headroom + state_naive = _analyze_highlight_state(exposed_img, pre_exposure_linear=None) + self.assertGreater(state_naive['headroom_pct'], 0.99, "Naive analysis should show headroom (sanity check)") + + def test_true_headroom_detection(self): + """Verify actual headroom is detected regardless of exposure.""" + # 1. Image with real headroom (max=1.5) + img = np.ones((100, 100, 3), dtype=np.float32) * 1.5 + + # 2. Even if we darken it (-1 EV -> 0.75), we should know it HAD headroom? + # Typically "headroom" implies "recoverable data". + # If I underexpose a RAW, I still want to know it has headroom. + # Actually, if I underexpose, the values become < 1.0. + # But the SOURCE has values > 1.0. + # So yes, headroom_pct should ideally reflect source capability. + + darkened_img = img * 0.5 + + state = _analyze_highlight_state(darkened_img, pre_exposure_linear=img) + self.assertGreater(state['headroom_pct'], 0.99) + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_highlight_recovery.py b/faststack/tests/test_highlight_recovery.py index 1ae1a2c..84d6f95 100644 --- a/faststack/tests/test_highlight_recovery.py +++ b/faststack/tests/test_highlight_recovery.py @@ -1,212 +1,213 @@ -"""Tests for highlight recovery system. - -Tests the new brightness-based highlight recovery that: -- Preserves hue/chroma via brightness rescaling -- Uses adaptive parameters based on headroom and clipping -- Handles both 16-bit (headroom) and 8-bit (JPEG) sources -""" -import sys -import os -# Add parent directory to path for standalone execution -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -# Mock cv2 if not available (for test environments) -try: - import cv2 -except ImportError: - from unittest import mock - sys.modules['cv2'] = mock.MagicMock() - -import numpy as np -import time - -from faststack.imaging.editor import ( - _highlight_recover_linear, - _highlight_boost_linear, - _apply_headroom_shoulder, - _analyze_highlight_state, - _smoothstep01, -) - - -def test_monotonicity(): - """Gradient 0→2.0 should be non-decreasing after recovery.""" - # Create gradient with headroom - gradient = np.linspace(0, 2.0, 100).reshape(10, 10) - rgb = np.stack([gradient, gradient * 0.5, gradient * 0.3], axis=2).astype(np.float32) - - recovered = _highlight_recover_linear(rgb, amount=1.0, pivot=0.5, k=6.0) - - # Check max-channel brightness is non-decreasing - brightness = recovered.max(axis=2).flatten() - diffs = np.diff(brightness) - eps = 1e-7 - - assert np.all(diffs >= -eps), f"Monotonicity violated: min diff = {diffs.min()}" - print("test_monotonicity passed") - - -def test_no_nan_inf(): - """Random input including edge cases should produce finite output.""" - np.random.seed(42) - - # Include zeros, ones, headroom, and extreme values - test_cases = [ - np.random.rand(50, 50, 3).astype(np.float32), # Normal - np.zeros((10, 10, 3), dtype=np.float32), # All zeros - np.ones((10, 10, 3), dtype=np.float32), # All ones - np.ones((10, 10, 3), dtype=np.float32) * 2.0, # Headroom - np.array([[[0, 0, 0], [1e-10, 1e-10, 1e-10], [10.0, 5.0, 2.0]]], dtype=np.float32), # Edge cases - ] - - for i, arr in enumerate(test_cases): - recovered = _highlight_recover_linear(arr, amount=1.0, pivot=0.5, k=6.0) - assert np.isfinite(recovered).all(), f"NaN/inf in test case {i}" - - boosted = _highlight_boost_linear(arr, amount=1.0, pivot=0.5) - assert np.isfinite(boosted).all(), f"NaN/inf in boost test case {i}" - - print("test_no_nan_inf passed") - - -def test_hue_preservation(): - """Saturated highlight ramp should preserve RGB ratios (hue).""" - # Create saturated red gradient with headroom - brightness = np.linspace(0.1, 2.0, 50).reshape(5, 10) - rgb = np.stack([brightness, brightness * 0.2, brightness * 0.2], axis=2).astype(np.float32) - - recovered = _highlight_recover_linear(rgb, amount=0.8, pivot=0.5, k=6.0, chroma_rolloff=0.0) - - # Check R:G:B ratios where brightness > 0.01 - orig_brightness = rgb.max(axis=2) - mask = orig_brightness > 0.01 - - if np.any(mask): - # Normalize to get ratio - orig_norm = rgb[mask] / (orig_brightness[mask, None] + 1e-7) - rec_brightness = recovered.max(axis=2) - rec_norm = recovered[mask] / (rec_brightness[mask, None] + 1e-7) - - # Ratios should be within 5% - ratio_diff = np.abs(orig_norm - rec_norm).max() - assert ratio_diff < 0.05, f"Hue shift too large: {ratio_diff}" - - print("test_hue_preservation passed") - - -def test_mask_isolation(): - """Pixels with max-channel below pivot should barely change.""" - # Create image with values below and above pivot - low = np.ones((10, 10, 3), dtype=np.float32) * 0.3 # Below pivot 0.5 - - recovered = _highlight_recover_linear(low, amount=1.0, pivot=0.5, k=6.0) - - # Changes should be minimal - diff = np.abs(recovered - low).max() - assert diff < 1e-4, f"Below-pivot pixels changed by {diff}" - - print("test_mask_isolation passed") - - -def test_plateau_stability(): - """Clipped [1,1,1] region should stay uniform after recovery (no ringing).""" - # Uniform white plateau - plateau = np.ones((20, 20, 3), dtype=np.float32) - - recovered = _highlight_recover_linear(plateau, amount=1.0, pivot=0.5, k=6.0) - - # All pixels should be the same (uniform) - std = recovered.std() - assert std < 1e-6, f"Plateau became non-uniform: std = {std}" - - print("test_plateau_stability passed") - - -def test_headroom_shoulder(): - """Global shoulder should compress values > 1.0 correctly.""" - x = np.array([0.5, 1.0, 1.5, 2.0, 5.0], dtype=np.float32) - out = _apply_headroom_shoulder(x, max_overshoot=0.05) - - # f(x) for x <= 1 should be unchanged - assert out[0] == 0.5 - assert out[1] == 1.0 - - # f(x) for x > 1 should be > 1 but < x - for i in range(2, len(x)): - assert out[i] > 1.0, f"Value at {x[i]} should be > 1.0, got {out[i]}" - assert out[i] < x[i], f"Value at {x[i]} should be compressed, got {out[i]}" - - # Should be monotonic - assert np.all(np.diff(out) >= 0), "Shoulder is not monotonic" - - print("test_headroom_shoulder passed") - - -def test_analyze_highlight_state(): - """Highlight state analysis should detect headroom and clipping.""" - # Image with headroom - headroom_img = np.ones((10, 10, 3), dtype=np.float32) * 1.5 - state = _analyze_highlight_state(headroom_img) - assert state['headroom_pct'] > 0.9, f"Should detect headroom: {state['headroom_pct']}" - - # Normal image - normal_img = np.ones((10, 10, 3), dtype=np.float32) * 0.5 - state = _analyze_highlight_state(normal_img) - assert state['headroom_pct'] < 0.01, f"Should not detect headroom: {state['headroom_pct']}" - - print("test_analyze_highlight_state passed") - - -def test_source_clipping_detection(): - """Verify that srgb_u8 correctly influences clipping results even if linear is dimmed.""" - # 1. Create a "clipped" source image (uint8) - srgb_u8 = np.ones((10, 10, 3), dtype=np.uint8) * 255 - - # 2. Create a "dimmed" linear image (it was clipped in source, but exposure pulled it down) - # Even though it's 0.2, it WAS clipped at the source. - rgb_linear = np.ones((10, 10, 3), dtype=np.float32) * 0.2 - - # 3. Analyze WITHOUT srgb_u8 -> should report 0 clipping because 0.2 < threshold - state_no_u8 = _analyze_highlight_state(rgb_linear, srgb_u8=None) - assert state_no_u8['source_clipped_pct'] == 0.0 - - # 4. Analyze WITH srgb_u8 -> should report 100% clipping because srgb_u8 is 255 - state_with_u8 = _analyze_highlight_state(rgb_linear, srgb_u8=srgb_u8) - assert state_with_u8['source_clipped_pct'] == 1.0 - - print("test_source_clipping_detection passed") - - -def test_benchmark(): - """1920x1080 should be processed in reasonable time (vectorized).""" - arr = np.random.rand(1080, 1920, 3).astype(np.float32) - - # Warm up - _highlight_recover_linear(arr, amount=0.5, pivot=0.5, k=6.0) - - # Benchmark - start = time.perf_counter() - for _ in range(3): - _highlight_recover_linear(arr, amount=0.5, pivot=0.5, k=6.0) - elapsed = (time.perf_counter() - start) / 3 - - print(f"test_benchmark: 1920x1080 recovery in {elapsed*1000:.1f}ms") - # Informational only - no hard assertion for CI stability - - -if __name__ == "__main__": - try: - test_monotonicity() - test_no_nan_inf() - test_hue_preservation() - test_mask_isolation() - test_plateau_stability() - test_headroom_shoulder() - test_analyze_highlight_state() - test_benchmark() - print("\nALL TESTS PASSED") - except Exception as e: - print(f"\nTEST FAILED: {e}") - import traceback - traceback.print_exc() - exit(1) +"""Tests for highlight recovery system. + +Tests the new brightness-based highlight recovery that: +- Preserves hue/chroma via brightness rescaling +- Uses adaptive parameters based on headroom and clipping +- Handles both 16-bit (headroom) and 8-bit (JPEG) sources +""" +import sys +import os +# Add parent directory to path for standalone execution +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +# Mock cv2 if not available (for test environments) +try: + import cv2 +except ImportError: + from unittest import mock + sys.modules['cv2'] = mock.MagicMock() + +import numpy as np +import time + +from faststack.imaging.math_utils import ( + _highlight_recover_linear, + _highlight_boost_linear, + _apply_headroom_shoulder, + _analyze_highlight_state, + _smoothstep01, +) + + +def test_monotonicity(): + """Gradient 0→2.0 should be non-decreasing after recovery.""" + # Create gradient with headroom + gradient = np.linspace(0, 2.0, 100).reshape(10, 10) + rgb = np.stack([gradient, gradient * 0.5, gradient * 0.3], axis=2).astype(np.float32) + + recovered = _highlight_recover_linear(rgb, amount=1.0, pivot=0.5) + + # Check max-channel brightness is non-decreasing + brightness = recovered.max(axis=2).flatten() + diffs = np.diff(brightness) + eps = 1e-7 + + assert np.all(diffs >= -eps), f"Monotonicity violated: min diff = {diffs.min()}" + print("test_monotonicity passed") + + +def test_no_nan_inf(): + """Random input including edge cases should produce finite output.""" + np.random.seed(42) + + # Include zeros, ones, headroom, and extreme values + test_cases = [ + np.random.rand(50, 50, 3).astype(np.float32), # Normal + np.zeros((10, 10, 3), dtype=np.float32), # All zeros + np.ones((10, 10, 3), dtype=np.float32), # All ones + np.ones((10, 10, 3), dtype=np.float32) * 2.0, # Headroom + np.array([[[0, 0, 0], [1e-10, 1e-10, 1e-10], [10.0, 5.0, 2.0]]], dtype=np.float32), # Edge cases + ] + + for i, arr in enumerate(test_cases): + recovered = _highlight_recover_linear(arr, amount=1.0, pivot=0.5) + assert np.isfinite(recovered).all(), f"NaN/inf in test case {i}" + + boosted = _highlight_boost_linear(arr, amount=1.0, pivot=0.5) + assert np.isfinite(boosted).all(), f"NaN/inf in boost test case {i}" + + print("test_no_nan_inf passed") + + +def test_hue_preservation(): + """Saturated highlight ramp should preserve RGB ratios (hue).""" + # Create saturated red gradient with headroom + brightness = np.linspace(0.1, 2.0, 50).reshape(5, 10) + rgb = np.stack([brightness, brightness * 0.2, brightness * 0.2], axis=2).astype(np.float32) + + recovered = _highlight_recover_linear(rgb, amount=0.8, pivot=0.5, chroma_rolloff=0.0) + + # Check R:G:B ratios where brightness > 0.01 + orig_brightness = rgb.max(axis=2) + mask = orig_brightness > 0.01 + + if np.any(mask): + # Normalize to get ratio + orig_norm = rgb[mask] / (orig_brightness[mask, None] + 1e-7) + rec_brightness = recovered.max(axis=2) + rec_norm = recovered[mask] / (rec_brightness[mask, None] + 1e-7) + + # Ratios should be within 5% + ratio_diff = np.abs(orig_norm - rec_norm).max() + assert ratio_diff < 0.05, f"Hue shift too large: {ratio_diff}" + + print("test_hue_preservation passed") + + +def test_mask_isolation(): + """Pixels with max-channel below pivot should barely change.""" + # Create image with values below and above pivot + low = np.ones((10, 10, 3), dtype=np.float32) * 0.3 # Below pivot 0.5 + + recovered = _highlight_recover_linear(low, amount=1.0, pivot=0.5) + + # Changes should be minimal + diff = np.abs(recovered - low).max() + assert diff < 1e-4, f"Below-pivot pixels changed by {diff}" + + print("test_mask_isolation passed") + + +def test_plateau_stability(): + """Clipped [1,1,1] region should stay uniform after recovery (no ringing).""" + # Uniform white plateau + plateau = np.ones((20, 20, 3), dtype=np.float32) + + recovered = _highlight_recover_linear(plateau, amount=1.0, pivot=0.5) + + # All pixels should be the same (uniform) + std = recovered.std() + assert std < 1e-6, f"Plateau became non-uniform: std = {std}" + + print("test_plateau_stability passed") + + +def test_headroom_shoulder(): + """Global shoulder should compress values > 1.0 correctly.""" + x = np.array([0.5, 1.0, 1.5, 2.0, 5.0], dtype=np.float32) + out = _apply_headroom_shoulder(x, max_overshoot=0.05) + + # f(x) for x <= 1 should be unchanged + assert out[0] == 0.5 + assert out[1] == 1.0 + + # f(x) for x > 1 should be > 1 but < x + for i in range(2, len(x)): + assert out[i] > 1.0, f"Value at {x[i]} should be > 1.0, got {out[i]}" + assert out[i] < x[i], f"Value at {x[i]} should be compressed, got {out[i]}" + + # Should be monotonic + assert np.all(np.diff(out) >= 0), "Shoulder is not monotonic" + + print("test_headroom_shoulder passed") + + +def test_analyze_highlight_state(): + """Highlight state analysis should detect headroom and clipping.""" + # Image with headroom + headroom_img = np.ones((10, 10, 3), dtype=np.float32) * 1.5 + state = _analyze_highlight_state(headroom_img) + assert state['headroom_pct'] > 0.9, f"Should detect headroom: {state['headroom_pct']}" + + # Normal image + normal_img = np.ones((10, 10, 3), dtype=np.float32) * 0.5 + state = _analyze_highlight_state(normal_img) + assert state['headroom_pct'] < 0.01, f"Should not detect headroom: {state['headroom_pct']}" + + print("test_analyze_highlight_state passed") + + +def test_source_clipping_detection(): + """Verify that srgb_u8 correctly influences clipping results even if linear is dimmed.""" + # 1. Create a "clipped" source image (uint8) + srgb_u8 = np.ones((10, 10, 3), dtype=np.uint8) * 255 + + # 2. Create a "dimmed" linear image (it was clipped in source, but exposure pulled it down) + # Even though it's 0.2, it WAS clipped at the source. + rgb_linear = np.ones((10, 10, 3), dtype=np.float32) * 0.2 + + # 3. Analyze WITHOUT srgb_u8 -> should report 0 clipping because 0.2 < threshold + state_no_u8 = _analyze_highlight_state(rgb_linear, srgb_u8=None) + assert state_no_u8['source_clipped_pct'] == 0.0 + + # 4. Analyze WITH srgb_u8 -> should report 100% clipping because srgb_u8 is 255 + state_with_u8 = _analyze_highlight_state(rgb_linear, srgb_u8=srgb_u8) + assert state_with_u8['source_clipped_pct'] == 1.0 + + print("test_source_clipping_detection passed") + + +def test_benchmark(): + """1920x1080 should be processed in reasonable time (vectorized).""" + arr = np.random.rand(1080, 1920, 3).astype(np.float32) + + # Warm up + _highlight_recover_linear(arr, amount=0.5, pivot=0.5) + + # Benchmark + start = time.perf_counter() + for _ in range(3): + _highlight_recover_linear(arr, amount=0.5, pivot=0.5) + elapsed = (time.perf_counter() - start) / 3 + + print(f"test_benchmark: 1920x1080 recovery in {elapsed*1000:.1f}ms") + # Informational only - no hard assertion for CI stability + + +if __name__ == "__main__": + try: + test_monotonicity() + test_no_nan_inf() + test_hue_preservation() + test_mask_isolation() + test_plateau_stability() + test_headroom_shoulder() + test_analyze_highlight_state() + test_source_clipping_detection() + test_benchmark() + print("\nALL TESTS PASSED") + except Exception as e: + print(f"\nTEST FAILED: {e}") + import traceback + traceback.print_exc() + exit(1) diff --git a/faststack/tests/test_highlight_state_normalization.py b/faststack/tests/test_highlight_state_normalization.py index e92dd30..15ac9e4 100644 --- a/faststack/tests/test_highlight_state_normalization.py +++ b/faststack/tests/test_highlight_state_normalization.py @@ -1,63 +1,63 @@ -"""Unit test for highlightState normalization in UIState.""" -import unittest -from unittest.mock import MagicMock -from faststack.ui.provider import UIState - -class TestUIStateNormalization(unittest.TestCase): - def setUp(self): - # Mock app_controller and image_editor - self.mock_controller = MagicMock() - self.mock_editor = MagicMock() - self.mock_controller.image_editor = self.mock_editor - self.ui_state = UIState(self.mock_controller) - - def test_highlight_state_normalization_standard(self): - """Test with standard keys.""" - self.mock_editor._last_highlight_state = { - 'headroom_pct': 0.1, - 'clipped_pct': 0.2, - 'near_white_pct': 0.3 - } - # Controller returns canonical keys using the passed dict (even if they were wrong in backend, provider normalizes? - # NO, provider simply gets what is in the dict. - # Wait, provider logic: - # return { - # 'headroom_pct': state.get('headroom_pct', 0.0), - # 'source_clipped_pct': state.get('source_clipped_pct', 0.0), - # 'current_nearwhite_pct': state.get('current_nearwhite_pct', 0.0) - # } - # So if backend has OLD keys, provider will return 0.0 for new keys! - # This confirms that backend MUST populate new keys. - - def test_highlight_state_normalization_standard(self): - """Test with canonical keys present.""" - self.mock_editor._last_highlight_state = { - 'headroom_pct': 0.1, - 'source_clipped_pct': 0.4, - 'current_nearwhite_pct': 0.5 - } - state = self.ui_state.highlightState - self.assertEqual(state['headroom_pct'], 0.1) - self.assertEqual(state['source_clipped_pct'], 0.4) - self.assertEqual(state['current_nearwhite_pct'], 0.5) - - def test_highlight_state_normalization_empty(self): - """Test with empty state.""" - self.mock_editor._last_highlight_state = None - state = self.ui_state.highlightState - self.assertEqual(state['headroom_pct'], 0.0) - self.assertEqual(state['source_clipped_pct'], 0.0) - self.assertEqual(state['current_nearwhite_pct'], 0.0) - - def test_highlight_state_normalization_missing_keys(self): - """Test with missing keys.""" - self.mock_editor._last_highlight_state = { - 'headroom_pct': 0.1 - } - state = self.ui_state.highlightState - self.assertEqual(state['headroom_pct'], 0.1) - self.assertEqual(state['source_clipped_pct'], 0.0) - self.assertEqual(state['current_nearwhite_pct'], 0.0) - -if __name__ == '__main__': - unittest.main() +"""Unit test for highlightState normalization in UIState.""" +import unittest +from unittest.mock import MagicMock +from faststack.ui.provider import UIState + +class TestUIStateNormalization(unittest.TestCase): + def setUp(self): + # Mock app_controller and image_editor + self.mock_controller = MagicMock() + self.mock_editor = MagicMock() + self.mock_controller.image_editor = self.mock_editor + self.ui_state = UIState(self.mock_controller) + + def test_highlight_state_normalization_standard(self): + """Test with standard keys.""" + self.mock_editor._last_highlight_state = { + 'headroom_pct': 0.1, + 'clipped_pct': 0.2, + 'near_white_pct': 0.3 + } + # Controller returns canonical keys using the passed dict (even if they were wrong in backend, provider normalizes? + # NO, provider simply gets what is in the dict. + # Wait, provider logic: + # return { + # 'headroom_pct': state.get('headroom_pct', 0.0), + # 'source_clipped_pct': state.get('source_clipped_pct', 0.0), + # 'current_nearwhite_pct': state.get('current_nearwhite_pct', 0.0) + # } + # So if backend has OLD keys, provider will return 0.0 for new keys! + # This confirms that backend MUST populate new keys. + + def test_highlight_state_normalization_standard(self): + """Test with canonical keys present.""" + self.mock_editor._last_highlight_state = { + 'headroom_pct': 0.1, + 'source_clipped_pct': 0.4, + 'current_nearwhite_pct': 0.5 + } + state = self.ui_state.highlightState + self.assertEqual(state['headroom_pct'], 0.1) + self.assertEqual(state['source_clipped_pct'], 0.4) + self.assertEqual(state['current_nearwhite_pct'], 0.5) + + def test_highlight_state_normalization_empty(self): + """Test with empty state.""" + self.mock_editor._last_highlight_state = None + state = self.ui_state.highlightState + self.assertEqual(state['headroom_pct'], 0.0) + self.assertEqual(state['source_clipped_pct'], 0.0) + self.assertEqual(state['current_nearwhite_pct'], 0.0) + + def test_highlight_state_normalization_missing_keys(self): + """Test with missing keys.""" + self.mock_editor._last_highlight_state = { + 'headroom_pct': 0.1 + } + state = self.ui_state.highlightState + self.assertEqual(state['headroom_pct'], 0.1) + self.assertEqual(state['source_clipped_pct'], 0.0) + self.assertEqual(state['current_nearwhite_pct'], 0.0) + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_highlights_responsiveness.py b/faststack/tests/test_highlights_responsiveness.py index 3a1013a..304b9ca 100644 --- a/faststack/tests/test_highlights_responsiveness.py +++ b/faststack/tests/test_highlights_responsiveness.py @@ -1,46 +1,46 @@ -import unittest -import numpy as np -import sys -import os -from unittest.mock import MagicMock - -# Mock dependencies -sys.modules['cv2'] = MagicMock() -sys.modules['turbojpeg'] = MagicMock() -sys.modules['PyTurboJPEG'] = MagicMock() - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) - -from faststack.imaging.editor import ImageEditor - -class TestHighlightsResponsiveness(unittest.TestCase): - def test_highlights_at_various_levels(self): - """Test how much highlights recovery affects various brightness levels.""" - editor = ImageEditor() - - # Create a gradient from 0.0 to 1.0 (linear) - # 0.5 linear is about 186/255 in sRGB - # 0.25 linear is about 137/255 in sRGB - steps = 11 - vals = np.linspace(0.0, 1.0, steps, dtype=np.float32) - linear = np.stack([vals]*3, axis=-1).reshape(1, steps, 3) - - # Apply edits with highlights at -1.0 (max recovery) - edits = editor._initial_edits() - edits['highlights'] = -1.0 - - out = editor._apply_edits(linear.copy(), edits=edits, for_export=True) - - print("\nBrightness Levels (Linear 0.0 -> 1.0):") - print("Input -> Output (Diff)") - for i in range(steps): - inp = vals[i] - outp = out[0, i, 0] - diff = inp - outp - print(f"{inp:0.2f} -> {outp:0.4f} ({diff:0.4f})") - - # The goal is to see significant changes (diff > 0.01) starting from lower levels - # Currently, with pivot 0.75, values below 0.75 should be unchanged (diff=0) - -if __name__ == '__main__': - unittest.main() +import unittest +import numpy as np +import sys +import os +from unittest.mock import MagicMock + +# Mock dependencies +sys.modules['cv2'] = MagicMock() +sys.modules['turbojpeg'] = MagicMock() +sys.modules['PyTurboJPEG'] = MagicMock() + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from faststack.imaging.editor import ImageEditor + +class TestHighlightsResponsiveness(unittest.TestCase): + def test_highlights_at_various_levels(self): + """Test how much highlights recovery affects various brightness levels.""" + editor = ImageEditor() + + # Create a gradient from 0.0 to 1.0 (linear) + # 0.5 linear is about 186/255 in sRGB + # 0.25 linear is about 137/255 in sRGB + steps = 11 + vals = np.linspace(0.0, 1.0, steps, dtype=np.float32) + linear = np.stack([vals]*3, axis=-1).reshape(1, steps, 3) + + # Apply edits with highlights at -1.0 (max recovery) + edits = editor._initial_edits() + edits['highlights'] = -1.0 + + out = editor._apply_edits(linear.copy(), edits=edits, for_export=True) + + print("\nBrightness Levels (Linear 0.0 -> 1.0):") + print("Input -> Output (Diff)") + for i in range(steps): + inp = vals[i] + outp = out[0, i, 0] + diff = inp - outp + print(f"{inp:0.2f} -> {outp:0.4f} ({diff:0.4f})") + + # The goal is to see significant changes (diff > 0.01) starting from lower levels + # Currently, with pivot 0.75, values below 0.75 should be unchanged (diff=0) + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_highlights_v2.py b/faststack/tests/test_highlights_v2.py index cf65b2a..bcc8268 100644 --- a/faststack/tests/test_highlights_v2.py +++ b/faststack/tests/test_highlights_v2.py @@ -1,93 +1,93 @@ -import unittest -import numpy as np -# Adjust import path if necessary, but faststack is likely installed or in pythonpath -import sys -import os -from unittest.mock import MagicMock -# Mock cv2 before importing faststack modules that depend on it -sys.modules['cv2'] = MagicMock() -sys.modules['turbojpeg'] = MagicMock() -sys.modules['PyTurboJPEG'] = MagicMock() - -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) - -from faststack.imaging.editor import ImageEditor, _apply_headroom_shoulder -from faststack.ui.provider import UIState -from faststack.app import AppController -from PySide6.QtCore import QObject, Signal - -class TestHighlightsV2(unittest.TestCase): - def test_shoulder_asymptote(self): - """Verify the new shoulder asymptotes to 1.0 + max_overshoot.""" - x = np.array([1.0, 2.0, 10.0, 100.0], dtype=np.float32) - max_overshoot = 0.05 - out = _apply_headroom_shoulder(x, max_overshoot=max_overshoot) - - # At 1.0, should be 1.0 - self.assertAlmostEqual(out[0], 1.0, places=5) - - # Above 1.0, should be < 1.0 + max_overshoot - self.assertTrue(np.all(out[1:] < 1.0 + max_overshoot)) - - # Monotonicity - self.assertTrue(out[1] > out[0]) - self.assertTrue(out[2] > out[1]) - - # Asymptote check: at very large x, should be close to 1.05 - self.assertAlmostEqual(out[-1], 1.0 + max_overshoot, delta=0.001) - - def test_analysis_decoupling(self): - """Verify analysis runs before adjustments and is cached via preview path.""" - editor = ImageEditor() - # Create a linear image with some headroom - linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2 - # sRGB mock indicating some clipping (e.g. 255) - srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255 - - # Setup editor state to simulate the image being loaded - # We need this because _apply_edits works on self.float_image/preview logic usually, - # but one can pass arr. - # But _apply_edits updates _last_highlight_state. - - # Run _apply_edits flow - edits = editor._initial_edits() - edits['highlights'] = -0.5 - - # _apply_edits expects global self.float_image for some contexts? - # No, it takes img_arr arg. - - editor._apply_edits(linear, edits=edits, for_export=False) - - # Check cache - self.assertIsNotNone(editor._last_highlight_state) - # Note: update logic might use striding so check rough values - self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9) - - def test_robust_ceiling(self): - """Verify headroom ceiling handles hot pixels.""" - try: - editor = ImageEditor() - linear = np.ones((100, 100, 3), dtype=np.float32) * 1.1 # Moderate headroom - # Add a single hot pixel - linear[50, 50, :] = 1000.0 - - # Use highlights recovery, ensuring we pass srgb_u8 if needed by analysis - # (Though robust ceiling logic is in the adjustment phase, analysis happens first) - editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) - - # Check that we didn't explode or crash - # The result is returned by _apply_highlights_shadows, but editor doesn't store it in place of input? - # Wait, editor method returns new array. - out = editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) - - self.assertTrue(np.isfinite(out).all()) - # The hot pixel should be compressed but not NaN - self.assertLess(out[50, 50, 0], 1000.0) - except Exception: - import traceback - import sys - traceback.print_exc(file=sys.__stderr__) - raise - -if __name__ == '__main__': - unittest.main() +import unittest +import numpy as np +# Adjust import path if necessary, but faststack is likely installed or in pythonpath +import sys +import os +from unittest.mock import MagicMock +# Mock cv2 before importing faststack modules that depend on it +sys.modules['cv2'] = MagicMock() +sys.modules['turbojpeg'] = MagicMock() +sys.modules['PyTurboJPEG'] = MagicMock() + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) + +from faststack.imaging.editor import ImageEditor, _apply_headroom_shoulder +from faststack.ui.provider import UIState +from faststack.app import AppController +from PySide6.QtCore import QObject, Signal + +class TestHighlightsV2(unittest.TestCase): + def test_shoulder_asymptote(self): + """Verify the new shoulder asymptotes to 1.0 + max_overshoot.""" + x = np.array([1.0, 2.0, 10.0, 100.0], dtype=np.float32) + max_overshoot = 0.05 + out = _apply_headroom_shoulder(x, max_overshoot=max_overshoot) + + # At 1.0, should be 1.0 + self.assertAlmostEqual(out[0], 1.0, places=5) + + # Above 1.0, should be < 1.0 + max_overshoot + self.assertTrue(np.all(out[1:] < 1.0 + max_overshoot)) + + # Monotonicity + self.assertTrue(out[1] > out[0]) + self.assertTrue(out[2] > out[1]) + + # Asymptote check: at very large x, should be close to 1.05 + self.assertAlmostEqual(out[-1], 1.0 + max_overshoot, delta=0.001) + + def test_analysis_decoupling(self): + """Verify analysis runs before adjustments and is cached via preview path.""" + editor = ImageEditor() + # Create a linear image with some headroom + linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2 + # sRGB mock indicating some clipping (e.g. 255) + srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255 + + # Setup editor state to simulate the image being loaded + # We need this because _apply_edits works on self.float_image/preview logic usually, + # but one can pass arr. + # But _apply_edits updates _last_highlight_state. + + # Run _apply_edits flow + edits = editor._initial_edits() + edits['highlights'] = -0.5 + + # _apply_edits expects global self.float_image for some contexts? + # No, it takes img_arr arg. + + editor._apply_edits(linear, edits=edits, for_export=False) + + # Check cache + self.assertIsNotNone(editor._last_highlight_state) + # Note: update logic might use striding so check rough values + self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9) + + def test_robust_ceiling(self): + """Verify headroom ceiling handles hot pixels.""" + try: + editor = ImageEditor() + linear = np.ones((100, 100, 3), dtype=np.float32) * 1.1 # Moderate headroom + # Add a single hot pixel + linear[50, 50, :] = 1000.0 + + # Use highlights recovery, ensuring we pass srgb_u8 if needed by analysis + # (Though robust ceiling logic is in the adjustment phase, analysis happens first) + editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) + + # Check that we didn't explode or crash + # The result is returned by _apply_highlights_shadows, but editor doesn't store it in place of input? + # Wait, editor method returns new array. + out = editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) + + self.assertTrue(np.isfinite(out).all()) + # The hot pixel should be compressed but not NaN + self.assertLess(out[50, 50, 0], 1000.0) + except Exception: + import traceback + import sys + traceback.print_exc(file=sys.__stderr__) + raise + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/tests/test_metadata.py b/faststack/tests/test_metadata.py index 80e2e48..9618651 100644 --- a/faststack/tests/test_metadata.py +++ b/faststack/tests/test_metadata.py @@ -1,108 +1,108 @@ - -import unittest -from unittest.mock import MagicMock, patch -from pathlib import Path -from faststack.imaging.metadata import get_exif_data, clean_exif_value -from PIL import ExifTags - -class TestMetadata(unittest.TestCase): - @patch('pathlib.Path.exists', return_value=True) - @patch('faststack.imaging.metadata.Image.open') - def test_get_exif_data_success(self, mock_open, mock_exists): - try: - # Setup mock image and exif data - mock_img = MagicMock() - - # Create a reverse mapping for tags to IDs for easier setup - tag_map = {v: k for k, v in ExifTags.TAGS.items()} - - exif_dict = { - tag_map["DateTimeOriginal"]: "2023:01:01 12:00:00", - tag_map["Make"]: "Canon\x00", # Null terminated - tag_map["Model"]: "Canon EOS R5", - tag_map["LensModel"]: "RF 24-70mm F2.8L IS USM", - tag_map["ISOSpeedRatings"]: 100, - tag_map["FNumber"]: (28, 10), # f/2.8 - tag_map["ExposureTime"]: (1, 200), # 1/200s - tag_map["FocalLength"]: (50, 1), # 50mm - tag_map["MakerNote"]: b'Some binary data \x00\x01\x02', # Binary data - tag_map["UserComment"]: b'ASCII comment\x00', # ASCII bytes - tag_map["Flash"]: 1, # Fired - tag_map["GPSInfo"]: { - 1: 'N', - 2: (34.0, 0.0, 0.0), # 34 deg N - 3: 'W', - 4: (118.0, 15.0, 0.0) # 118 deg 15 min W - } - } - - mock_img._getexif.return_value = exif_dict - mock_open.return_value = mock_img - - # Test - result = get_exif_data(Path("dummy.jpg")) - - # Verify summary - summary = result["summary"] - self.assertEqual(summary["Date Taken"], "2023:01:01 12:00:00") - self.assertEqual(summary["Camera"], "Canon EOS R5") # Make should be collapsed into Model - self.assertEqual(summary["Lens"], "RF 24-70mm F2.8L IS USM") - self.assertEqual(summary["ISO"], "100") - self.assertEqual(summary["Aperture"], "f/2.8") - self.assertEqual(summary["Shutter Speed"], "1/200s") - self.assertEqual(summary["Focal Length"], "50mm") - self.assertEqual(summary["Flash"], "1") - # 34 + 0/60 + 0/3600 = 34.00000 - # 118 + 15/60 + 0/3600 = 118.25000 -> -118.25000 (W) - self.assertEqual(summary["GPS"], "34.00000, -118.25000") - - # Verify full data contains keys and handles binary - full = result["full"] - self.assertIn("DateTimeOriginal", full) - self.assertEqual(full["Model"], "Canon EOS R5") - self.assertTrue(full["MakerNote"].startswith(" -118.25000 (W) + self.assertEqual(summary["GPS"], "34.00000, -118.25000") + + # Verify full data contains keys and handles binary + full = result["full"] + self.assertIn("DateTimeOriginal", full) + self.assertEqual(full["Model"], "Canon EOS R5") + self.assertTrue(full["MakerNote"].startswith(" strength = 1.0 - arr_reasonable = np.linspace(50, 200, 10000, dtype=np.uint8).reshape(100, 100) - img_reasonable = Image.fromarray(arr_reasonable) - self.editor.original_image = img_reasonable - self.editor._preview_image = img_reasonable - - blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - - # Calculate what strength should be based on stretch factor - dynamic_range = p_high - p_low - stretch_full = 255.0 / dynamic_range - STRETCH_CAP = 4.0 - - if stretch_full <= STRETCH_CAP: - expected_strength = 1.0 - else: - expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) - - print(f"Reasonable range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, " - f"stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") - - # For reasonable range, should use full strength - self.assertAlmostEqual(expected_strength, 1.0, places=2) - - # Test case 2: Low dynamic range (100-140, range=40) - # Expected: stretch = 255/40 = 6.375x (> 4x cap) => strength = 3/5.375 ≈ 0.558 - arr_low_range = np.clip(np.linspace(100, 140, 10000, dtype=np.uint8), 100, 140).reshape(100, 100) - img_low_range = Image.fromarray(arr_low_range) - self.editor.original_image = img_low_range - self.editor._preview_image = img_low_range - - blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - - dynamic_range = p_high - p_low - stretch_full = 255.0 / dynamic_range if dynamic_range >= 1.0 else 255.0 - - if stretch_full <= STRETCH_CAP: - expected_strength = 1.0 - else: - expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) - - print(f"Low range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") - - # Stretch should exceed cap, strength should be reduced - self.assertGreater(stretch_full, STRETCH_CAP) - self.assertLess(expected_strength, 1.0) - self.assertGreater(expected_strength, 0.3) # Should still be reasonable - - # Test case 3: Very low dynamic range (120-121, range≈1) - # Expected: strength = 0 (degenerate case) - arr_flat = np.full((100, 100), 120, dtype=np.uint8) - # Add tiny variation to avoid completely flat - arr_flat[0, 0] = 119 - arr_flat[99, 99] = 121 - img_flat = Image.fromarray(arr_flat) - self.editor.original_image = img_flat - self.editor._preview_image = img_flat - - blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - - dynamic_range = p_high - p_low - - print(f"Flat image: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}") - - # For very low range, should be near 0 or exactly 0 - self.assertLess(dynamic_range, 3.0) - - def test_auto_levels_clipping_tolerance(self): - """ - Regression test: Verify that auto-levels respects the threshold setting - and doesn't introduce excessive clipping beyond the configured tolerance. - - Uses deterministic synthetic images to verify clipping stays within bounds. - """ - threshold_percent = 0.1 - - # Create a deterministic image with known distribution - # Use a beta distribution to create realistic luminance distribution - # Beta(2, 5) gives a left-skewed distribution (more shadows, fewer highlights) - np.random.seed(42) # Deterministic - beta_samples = np.random.beta(2, 5, size=10000) - arr = (beta_samples * 255).astype(np.uint8).reshape(100, 100) - img = Image.fromarray(arr) - - self.editor.original_image = img - self.editor._preview_image = img - - blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - - # Apply at full strength - self.editor.set_edit_param('blacks', blacks) - self.editor.set_edit_param('whites', whites) - result = self.editor._apply_edits(img.convert('RGB')) - result_arr = np.array(result.convert('L')) - - # Count pixels at extremes - total_pixels = result_arr.size - clipped_low = np.sum(result_arr == 0) - clipped_high = np.sum(result_arr == 255) - - pct_clipped_low = (clipped_low / total_pixels) * 100.0 - pct_clipped_high = (clipped_high / total_pixels) * 100.0 - - print(f"Beta distribution: Low clip: {pct_clipped_low:.2f}%, High clip: {pct_clipped_high:.2f}%") - - # Allow small tolerance for rounding and integer quantization - # The threshold defines the percentiles, but due to discrete pixel values - # and the mapping, we may end up with slightly different clipping - tolerance = 0.5 # 0.1% threshold + 0.5% tolerance = 0.6% max - - self.assertLessEqual(pct_clipped_low, threshold_percent + tolerance, - f"Excessive shadow clipping: {pct_clipped_low:.2f}% > {threshold_percent + tolerance}%") - self.assertLessEqual(pct_clipped_high, threshold_percent + tolerance, - f"Excessive highlight clipping: {pct_clipped_high:.2f}% > {threshold_percent + tolerance}%") - - # Verify mapping is monotonic (sanity check) - # Create a gradient and verify it maps monotonically - gradient = np.arange(256, dtype=np.uint8) - gradient_img = Image.fromarray(gradient.reshape(1, 256)) - self.editor.original_image = gradient_img - self.editor._preview_image = gradient_img - - blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - self.editor.set_edit_param('blacks', blacks) - self.editor.set_edit_param('whites', whites) - result = self.editor._apply_edits(gradient_img.convert('RGB')) - result_arr = np.array(result.convert('L'))[0, :] - - # Check monotonicity - diffs = np.diff(result_arr.astype(np.int16)) - self.assertTrue(np.all(diffs >= 0), "Mapping is not monotonic") - -if __name__ == '__main__': - unittest.main(verbosity=2) +import sys +import unittest +import numpy as np +from PIL import Image +from faststack.imaging.editor import ImageEditor + +class TestNewFeatures(unittest.TestCase): + def setUp(self): + self.editor = ImageEditor() + # Create a gradient image 0-255 + self.img = Image.fromarray(np.tile(np.arange(256, dtype=np.uint8), (10, 1)).astype(np.uint8)) + self.editor.original_image = self.img + self.editor._preview_image = self.img + + def test_auto_levels_strength(self): + # Create an image capable of clipping but with limited range to force non-zero adjustments + # Range 50-200. Auto-levels should expand this to 0-255. + arr = np.linspace(50, 200, 10000).reshape(100, 100).astype(np.uint8) + img = Image.fromarray(arr) + + self.editor.original_image = img + self.editor._preview_image = img + + # Calculate auto levels - now returns (blacks, whites, p_low, p_high) + blacks, whites, p_low, p_high = self.editor.auto_levels(0.1) + + # With range [50, 200], we expect: + # p_low should be around 50, p_high around 200 (with 0.1% percentile) + # blacks approx -50/40 = -1.25 + # whites approx (255-200)/40 = 1.375 + self.assertNotEqual(blacks, 0.0) + self.assertNotEqual(whites, 0.0) + self.assertLess(blacks, 0.0) + self.assertGreater(whites, 0.0) + + # Verify percentile values are reasonable + self.assertGreater(p_low, 45) # Should be close to 50 + self.assertLess(p_low, 55) + self.assertGreater(p_high, 195) # Should be close to 200 + self.assertLess(p_high, 205) + + # Mock strength application matching app.py logic + strength = 0.5 + b_scaled = blacks * strength + w_scaled = whites * strength + + # Verify scaling works correctly and produces expected intermediate values + self.assertAlmostEqual(b_scaled, blacks * 0.5) + self.assertAlmostEqual(w_scaled, whites * 0.5) + # Verify magnitude is reduced + self.assertLess(abs(b_scaled), abs(blacks)) + self.assertLess(abs(w_scaled), abs(whites)) + + def test_highlights_recovery(self): + # Set highlights to -1.0 (Recovery) + self.editor.current_edits['highlights'] = -1.0 + + # Apply edits + res = self.editor._apply_edits(self.img.copy()) + res_arr = np.array(res) + + # Check pixel at 255 (should be darker) + # Original 255. + # Mask at 255 = (255-128)/127 = 1.0. + # Factor = 1.0 + (-1.0 * 0.75 * 1.0) = 0.25. + # Expected = 255 * 0.25 = 63.75. + + val_255 = res_arr[0, 255] + print(f"Highlights -1.0 on 255: {val_255}") + self.assertTrue(val_255 < 255) + self.assertTrue(val_255 < 100) # Significant darkening + + # Check pixel at 128 (should be unchanged) + # Mask at 128 = 0. + # Factor = 1.0. + val_128 = res_arr[0, 128] + print(f"Highlights -1.0 on 128: {val_128}") + # Allow small deviation due to float/int conversion + self.assertTrue(abs(val_128 - 128) < 2) + + def test_straighten_angle(self): + # Set straighten angle + self.editor.current_edits['straighten_angle'] = 45.0 + + # Apply + res = self.editor._apply_edits(self.img.copy(), for_export=True) + + # Image should be rotated and larger (expand=True) + # Original width 256. 45 deg rotation of valid rect makes it wider? + # Not necessarily if aspect ratio is extreme. + # Just check that dimensions changed. + print(f"Original size: {self.img.size}, Rotated size: {res.size}") + self.assertNotEqual(res.size, self.img.size) + + def test_auto_levels_stretch_capping(self): + """ + Regression test: Verify that auto-strength uses stretch-factor capping + to prevent insane levels on low-dynamic-range images. + + Tests: + 1. Reasonable dynamic range: should use full strength (strength=1.0) + 2. Low dynamic range: should cap stretch at 4x maximum + 3. Very low dynamic range: should set strength=0 + """ + threshold_percent = 0.1 + + # Test case 1: Reasonable dynamic range (50-200, range=150) + # Expected: stretch = 255/150 = 1.7x (< 4x cap) => strength = 1.0 + arr_reasonable = np.linspace(50, 200, 10000, dtype=np.uint8).reshape(100, 100) + img_reasonable = Image.fromarray(arr_reasonable) + self.editor.original_image = img_reasonable + self.editor._preview_image = img_reasonable + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + # Calculate what strength should be based on stretch factor + dynamic_range = p_high - p_low + stretch_full = 255.0 / dynamic_range + STRETCH_CAP = 4.0 + + if stretch_full <= STRETCH_CAP: + expected_strength = 1.0 + else: + expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) + + print(f"Reasonable range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, " + f"stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") + + # For reasonable range, should use full strength + self.assertAlmostEqual(expected_strength, 1.0, places=2) + + # Test case 2: Low dynamic range (100-140, range=40) + # Expected: stretch = 255/40 = 6.375x (> 4x cap) => strength = 3/5.375 ≈ 0.558 + arr_low_range = np.clip(np.linspace(100, 140, 10000, dtype=np.uint8), 100, 140).reshape(100, 100) + img_low_range = Image.fromarray(arr_low_range) + self.editor.original_image = img_low_range + self.editor._preview_image = img_low_range + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + dynamic_range = p_high - p_low + stretch_full = 255.0 / dynamic_range if dynamic_range >= 1.0 else 255.0 + + if stretch_full <= STRETCH_CAP: + expected_strength = 1.0 + else: + expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) + + print(f"Low range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") + + # Stretch should exceed cap, strength should be reduced + self.assertGreater(stretch_full, STRETCH_CAP) + self.assertLess(expected_strength, 1.0) + self.assertGreater(expected_strength, 0.3) # Should still be reasonable + + # Test case 3: Very low dynamic range (120-121, range≈1) + # Expected: strength = 0 (degenerate case) + arr_flat = np.full((100, 100), 120, dtype=np.uint8) + # Add tiny variation to avoid completely flat + arr_flat[0, 0] = 119 + arr_flat[99, 99] = 121 + img_flat = Image.fromarray(arr_flat) + self.editor.original_image = img_flat + self.editor._preview_image = img_flat + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + dynamic_range = p_high - p_low + + print(f"Flat image: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}") + + # For very low range, should be near 0 or exactly 0 + self.assertLess(dynamic_range, 3.0) + + def test_auto_levels_clipping_tolerance(self): + """ + Regression test: Verify that auto-levels respects the threshold setting + and doesn't introduce excessive clipping beyond the configured tolerance. + + Uses deterministic synthetic images to verify clipping stays within bounds. + """ + threshold_percent = 0.1 + + # Create a deterministic image with known distribution + # Use a beta distribution to create realistic luminance distribution + # Beta(2, 5) gives a left-skewed distribution (more shadows, fewer highlights) + np.random.seed(42) # Deterministic + beta_samples = np.random.beta(2, 5, size=10000) + arr = (beta_samples * 255).astype(np.uint8).reshape(100, 100) + img = Image.fromarray(arr) + + self.editor.original_image = img + self.editor._preview_image = img + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + # Apply at full strength + self.editor.set_edit_param('blacks', blacks) + self.editor.set_edit_param('whites', whites) + result = self.editor._apply_edits(img.convert('RGB')) + result_arr = np.array(result.convert('L')) + + # Count pixels at extremes + total_pixels = result_arr.size + clipped_low = np.sum(result_arr == 0) + clipped_high = np.sum(result_arr == 255) + + pct_clipped_low = (clipped_low / total_pixels) * 100.0 + pct_clipped_high = (clipped_high / total_pixels) * 100.0 + + print(f"Beta distribution: Low clip: {pct_clipped_low:.2f}%, High clip: {pct_clipped_high:.2f}%") + + # Allow small tolerance for rounding and integer quantization + # The threshold defines the percentiles, but due to discrete pixel values + # and the mapping, we may end up with slightly different clipping + tolerance = 0.5 # 0.1% threshold + 0.5% tolerance = 0.6% max + + self.assertLessEqual(pct_clipped_low, threshold_percent + tolerance, + f"Excessive shadow clipping: {pct_clipped_low:.2f}% > {threshold_percent + tolerance}%") + self.assertLessEqual(pct_clipped_high, threshold_percent + tolerance, + f"Excessive highlight clipping: {pct_clipped_high:.2f}% > {threshold_percent + tolerance}%") + + # Verify mapping is monotonic (sanity check) + # Create a gradient and verify it maps monotonically + gradient = np.arange(256, dtype=np.uint8) + gradient_img = Image.fromarray(gradient.reshape(1, 256)) + self.editor.original_image = gradient_img + self.editor._preview_image = gradient_img + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + self.editor.set_edit_param('blacks', blacks) + self.editor.set_edit_param('whites', whites) + result = self.editor._apply_edits(gradient_img.convert('RGB')) + result_arr = np.array(result.convert('L'))[0, :] + + # Check monotonicity + diffs = np.diff(result_arr.astype(np.int16)) + self.assertTrue(np.all(diffs >= 0), "Mapping is not monotonic") + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/faststack/tests/test_pairing.py b/faststack/tests/test_pairing.py index 3a325fa..8a8978e 100644 --- a/faststack/tests/test_pairing.py +++ b/faststack/tests/test_pairing.py @@ -1,74 +1,74 @@ -"""Tests for the RAW-JPG pairing logic.""" - -import os -import time -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -from faststack.io.indexer import find_images, _find_raw_pair - -@pytest.fixture -def mock_image_dir(tmp_path: Path): - """Creates a temporary directory with mock image files.""" - # JPGs - (tmp_path / "IMG_0001.JPG").touch() - time.sleep(0.01) - (tmp_path / "IMG_0002.jpg").touch() - time.sleep(0.01) - (tmp_path / "IMG_0003.jpeg").touch() - time.sleep(0.01) - - # Raws (CR3) - (tmp_path / "IMG_0001.CR3").touch() # Perfect match - # Match for 0002, but with a slight time diff - two_cr3 = (tmp_path / "IMG_0002.CR3") - two_cr3.touch() - # Change timestamp slightly - os.utime(two_cr3, (two_cr3.stat().st_atime, two_cr3.stat().st_mtime + 0.5)) - - # A raw with no JPG - (tmp_path / "IMG_0004.CR3").touch() - - return tmp_path - -def test_find_images(mock_image_dir: Path): - """Tests the main find_images function.""" - images = find_images(mock_image_dir) - - assert len(images) == 4 - assert images[0].path.name == "IMG_0001.JPG" - assert images[0].raw_pair is not None - assert images[0].raw_pair.name == "IMG_0001.CR3" - - assert images[1].path.name == "IMG_0002.jpg" - assert images[1].raw_pair is not None - assert images[1].raw_pair.name == "IMG_0002.CR3" - - assert images[2].path.name == "IMG_0003.jpeg" - assert images[2].raw_pair is None - -def test_raw_pairing_logic(): - """Unit tests the _find_raw_pair function specifically.""" - jpg_path = Path("IMG_01.JPG") - jpg_stat = MagicMock(); jpg_stat.st_mtime = 1000.0 - - # Case 1: Perfect match - raw1_path = Path("IMG_01.CR3"); raw1_stat = MagicMock(); raw1_stat.st_mtime = 1000.1 - potentials = [(raw1_path, raw1_stat)] - assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw1_path - - # Case 2: No match (time delta too large) - raw2_path = Path("IMG_01.CR3"); raw2_stat = MagicMock(); raw2_stat.st_mtime = 1003.0 - potentials = [(raw2_path, raw2_stat)] - assert _find_raw_pair(jpg_path, jpg_stat, potentials) is None - - # Case 3: Closest match is chosen - raw3_path = Path("IMG_01_A.CR3"); raw3_stat = MagicMock(); raw3_stat.st_mtime = 1000.5 - raw4_path = Path("IMG_01_B.CR3"); raw4_stat = MagicMock(); raw4_stat.st_mtime = 1001.8 - potentials = [(raw3_path, raw3_stat), (raw4_path, raw4_stat)] - assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw3_path - - # Case 4: No potential RAWs - assert _find_raw_pair(jpg_path, jpg_stat, []) is None +"""Tests for the RAW-JPG pairing logic.""" + +import os +import time +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from faststack.io.indexer import find_images, _find_raw_pair + +@pytest.fixture +def mock_image_dir(tmp_path: Path): + """Creates a temporary directory with mock image files.""" + # JPGs + (tmp_path / "IMG_0001.JPG").touch() + time.sleep(0.01) + (tmp_path / "IMG_0002.jpg").touch() + time.sleep(0.01) + (tmp_path / "IMG_0003.jpeg").touch() + time.sleep(0.01) + + # Raws (CR3) + (tmp_path / "IMG_0001.CR3").touch() # Perfect match + # Match for 0002, but with a slight time diff + two_cr3 = (tmp_path / "IMG_0002.CR3") + two_cr3.touch() + # Change timestamp slightly + os.utime(two_cr3, (two_cr3.stat().st_atime, two_cr3.stat().st_mtime + 0.5)) + + # A raw with no JPG + (tmp_path / "IMG_0004.CR3").touch() + + return tmp_path + +def test_find_images(mock_image_dir: Path): + """Tests the main find_images function.""" + images = find_images(mock_image_dir) + + assert len(images) == 4 + assert images[0].path.name == "IMG_0001.JPG" + assert images[0].raw_pair is not None + assert images[0].raw_pair.name == "IMG_0001.CR3" + + assert images[1].path.name == "IMG_0002.jpg" + assert images[1].raw_pair is not None + assert images[1].raw_pair.name == "IMG_0002.CR3" + + assert images[2].path.name == "IMG_0003.jpeg" + assert images[2].raw_pair is None + +def test_raw_pairing_logic(): + """Unit tests the _find_raw_pair function specifically.""" + jpg_path = Path("IMG_01.JPG") + jpg_stat = MagicMock(); jpg_stat.st_mtime = 1000.0 + + # Case 1: Perfect match + raw1_path = Path("IMG_01.CR3"); raw1_stat = MagicMock(); raw1_stat.st_mtime = 1000.1 + potentials = [(raw1_path, raw1_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw1_path + + # Case 2: No match (time delta too large) + raw2_path = Path("IMG_01.CR3"); raw2_stat = MagicMock(); raw2_stat.st_mtime = 1003.0 + potentials = [(raw2_path, raw2_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) is None + + # Case 3: Closest match is chosen + raw3_path = Path("IMG_01_A.CR3"); raw3_stat = MagicMock(); raw3_stat.st_mtime = 1000.5 + raw4_path = Path("IMG_01_B.CR3"); raw4_stat = MagicMock(); raw4_stat.st_mtime = 1001.8 + potentials = [(raw3_path, raw3_stat), (raw4_path, raw4_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw3_path + + # Case 4: No potential RAWs + assert _find_raw_pair(jpg_path, jpg_stat, []) is None diff --git a/faststack/tests/test_prefetch_logic.py b/faststack/tests/test_prefetch_logic.py index c06b556..3c0f15a 100644 --- a/faststack/tests/test_prefetch_logic.py +++ b/faststack/tests/test_prefetch_logic.py @@ -1,69 +1,70 @@ -import unittest -from unittest.mock import MagicMock, patch -import sys - -# Mock config before importing prefetcher -sys.modules['faststack.config'] = MagicMock() -from faststack.imaging.prefetch import Prefetcher - -class TestPrefetcher(unittest.TestCase): - def test_submit_task_priority_cancellation(self): - try: - # Mock dependencies - mock_cache_put = MagicMock() - mock_get_display_info = MagicMock(return_value=(100, 100, 1)) - - # Create dummy image files - image_files = [MagicMock() for _ in range(10)] - - prefetcher = Prefetcher( - image_files=image_files, - cache_put=mock_cache_put, - prefetch_radius=4, - get_display_info=mock_get_display_info - ) - - # Mock executor - prefetcher.executor = MagicMock() - - # Helper to create a mock future - def create_future(): - f = MagicMock(spec=Future) - f.done.return_value = False - f.cancel.return_value = True - return f - - # Setup initial state - f0 = create_future() - f5 = create_future() - - prefetcher.futures[0] = f0 - prefetcher.futures[5] = f5 - - print("Submitting task 4...") - # Submit priority task for index 4 - prefetcher.submit_task(index=4, generation=0, priority=True) - print("Task 4 submitted.") - - # Check if task 4 was added - if 4 not in prefetcher.futures: - raise Exception("Task 4 was not added to futures!") - - # Check cancellation of task 0 (should cancel) - print("Checking task 0 cancellation...") - f0.cancel.assert_called() - print("Task 0 cancelled as expected.") - - # Check cancellation of task 5 (should NOT cancel) - print("Checking task 5 cancellation...") - f5.cancel.assert_not_called() - print("Task 5 NOT cancelled as expected.") - - print("Test passed!") - except Exception: - import traceback - traceback.print_exc() - raise - -if __name__ == "__main__": - unittest.main() +import unittest +from unittest.mock import MagicMock, patch +from concurrent.futures import Future +import sys + +# Mock config before importing prefetcher +sys.modules['faststack.config'] = MagicMock() +from faststack.imaging.prefetch import Prefetcher + +class TestPrefetcher(unittest.TestCase): + def test_submit_task_priority_cancellation(self): + try: + # Mock dependencies + mock_cache_put = MagicMock() + mock_get_display_info = MagicMock(return_value=(100, 100, 1)) + + # Create dummy image files + image_files = [MagicMock() for _ in range(10)] + + prefetcher = Prefetcher( + image_files=image_files, + cache_put=mock_cache_put, + prefetch_radius=4, + get_display_info=mock_get_display_info + ) + + # Mock executor + prefetcher.executor = MagicMock() + + # Helper to create a mock future + def create_future(): + f = MagicMock(spec=Future) + f.done.return_value = False + f.cancel.return_value = True + return f + + # Setup initial state + f0 = create_future() + f5 = create_future() + + prefetcher.futures[0] = f0 + prefetcher.futures[5] = f5 + + print("Submitting task 4...") + # Submit priority task for index 4 + prefetcher.submit_task(index=4, generation=0, priority=True) + print("Task 4 submitted.") + + # Check if task 4 was added + if 4 not in prefetcher.futures: + raise Exception("Task 4 was not added to futures!") + + # Check cancellation of task 0 (should cancel) + print("Checking task 0 cancellation...") + f0.cancel.assert_called() + print("Task 0 cancelled as expected.") + + # Check cancellation of task 5 (should NOT cancel) + print("Checking task 5 cancellation...") + f5.cancel.assert_not_called() + print("Task 5 NOT cancelled as expected.") + + print("Test passed!") + except Exception: + import traceback + traceback.print_exc() + raise + +if __name__ == "__main__": + unittest.main() diff --git a/faststack/tests/test_raw_pipeline.py b/faststack/tests/test_raw_pipeline.py index 302b16c..e9d607f 100644 --- a/faststack/tests/test_raw_pipeline.py +++ b/faststack/tests/test_raw_pipeline.py @@ -1,279 +1,279 @@ -import os -import unittest -from unittest.mock import MagicMock, patch, ANY -from pathlib import Path -import tempfile -import shutil -import subprocess -import numpy as np -from PIL import Image - -from faststack.models import ImageFile -from faststack.app import AppController -from faststack.imaging.editor import ImageEditor -import logging - -# Ensure logs are visible -logging.basicConfig(level=logging.DEBUG) -log = logging.getLogger(__name__) - -class TestRawPipeline(unittest.TestCase): - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') - @patch('faststack.app.threading.Thread') - def test_develop_raw_empty_output_cleanup(self, mock_thread, mock_config_get, mock_run, mock_exists): - """Test garbage collection if RT exits 0 but produces 0-byte file.""" - mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" - mock_exists.return_value = True # exe exists - - # Make Thread().start() run the target immediately (synchronous for testing) - def side_effect_start(*args, **kwargs): - _, thread_kwargs = mock_thread.call_args - target = thread_kwargs.get('target') - if target: - target() - - mock_thread.return_value.start.side_effect = side_effect_start - - # Mock subprocess.run to return success (returncode=0) - mock_run.return_value = subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr="" - ) - - app = MagicMock() - app.image_files = [self.image_file] - app.current_index = 0 - app.update_status_message = MagicMock() - - # Bind the real _develop_raw_backend method to our mock - app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) - - # Create 0-byte zombie file BEFORE calling develop - tif_path = self.image_file.working_tif_path - tif_path.touch() - self.assertTrue(tif_path.exists()) - self.assertEqual(tif_path.stat().st_size, 0) - - app._develop_raw_backend() - - # Expect file to be DELETED because it was 0 bytes - self.assertFalse(tif_path.exists(), "Zombie 0-byte file should be cleaned up") - - @patch('faststack.app.QTimer.singleShot') - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') - @patch('faststack.app.threading.Thread') - def test_develop_raw_timeout(self, mock_thread, mock_config_get, mock_run, mock_exists, mock_single_shot): - mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" - mock_exists.return_value = True - - def side_effect_start(*args, **kwargs): - _, thread_kwargs = mock_thread.call_args - target = thread_kwargs.get('target') - if target: - target() - mock_thread.return_value.start.side_effect = side_effect_start - - mock_run.side_effect = subprocess.TimeoutExpired(cmd="rawtherapee-cli", timeout=60) - - app = MagicMock() - app.image_files = [self.image_file] - app.current_index = 0 - app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) - - app._develop_raw_backend() - - # Check QTimer call - mock_single_shot.assert_called() - # call_args[0] is (0, partial_obj) - _, callback = mock_single_shot.call_args[0] - # callback is functools.partial(self._on_develop_finished, False, err_msg) - # For a bound method, callback.func is the method - self.assertTrue(hasattr(callback, 'func')) - self.assertTrue('_on_develop_finished' in str(callback.func)) - self.assertEqual(callback.args[0], False) # Success = False - self.assertIn("timed out", callback.args[1]) # Msg - - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') - @patch('faststack.app.threading.Thread') - def test_develop_raw_with_custom_args(self, mock_thread, mock_config_get, mock_run, mock_exists): - """Test that custom RawTherapee args are correctly passed to the command.""" - # Setup mock behavior for config.get - def mock_config_side_effect(section, option): - if section == "rawtherapee" and option == "exe": - return "c:\\path\\to\\rawtherapee-cli.exe" - if section == "rawtherapee" and option == "args": - return "-p my_profile.pp3 -s" - return None - mock_config_get.side_effect = mock_config_side_effect - mock_exists.return_value = True - - # Run target in thread immediately - def side_effect_start(*args, **kwargs): - _, thread_kwargs = mock_thread.call_args - target = thread_kwargs.get('target') - if target: - target() - mock_thread.return_value.start.side_effect = side_effect_start - - # Mock subprocess.run - mock_run.return_value = subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr="" - ) - - app = MagicMock() - app.image_files = [self.image_file] - app.current_index = 0 - app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) - - app._develop_raw_backend() - - # Verify command - mock_run.assert_called_once() - cmd = mock_run.call_args[0][0] - - # Check base command structure - self.assertEqual(cmd[0], "c:\\path\\to\\rawtherapee-cli.exe") - self.assertIn("-t", cmd) - self.assertIn("-b16", cmd) - self.assertIn("-Y", cmd) - - # Check custom args - self.assertIn("-p", cmd) - self.assertIn("my_profile.pp3", cmd) - self.assertIn("-s", cmd) - - # Check input/output order (input -c should be after args) - self.assertEqual(cmd[-2], "-c") - self.assertEqual(cmd[-1], str(self.image_file.raw_path)) - - - def setUp(self): - self.tmp_dir = tempfile.mkdtemp() - self.tmp_path = Path(self.tmp_dir) - - # Setup dummy RAW file - self.raw_path = self.tmp_path / "test_image.CR2" - self.raw_path.touch() - - # Setup dummy JPG for indexer (FastStack usually finds JPGs first) - self.jpg_path = self.tmp_path / "test_image.jpg" - # Create a real small JPG - img = Image.new('RGB', (100, 100), color='red') - img.save(self.jpg_path) - - self.image_file = ImageFile(path=self.jpg_path) - self.image_file.raw_pair = self.raw_path - - def tearDown(self): - shutil.rmtree(self.tmp_dir) - - def test_image_file_properties(self): - """Test computed properties for RAW pipeline.""" - self.assertTrue(self.image_file.has_raw) - self.assertEqual(self.image_file.raw_path, self.tmp_path / "test_image.CR2") - self.assertEqual(self.image_file.working_tif_path, self.tmp_path / "test_image-working.tif") - self.assertEqual(self.image_file.developed_jpg_path, self.tmp_path / "test_image-developed.jpg") - - # Rename raw to break pairing - shutil.move(self.raw_path, self.tmp_path / "other.CR2") - img2 = ImageFile(path=self.jpg_path) - self.assertFalse(img2.has_raw) - - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') - def test_develop_raw_slot(self, mock_config_get, mock_run, mock_exists): - """Test the develop_raw_for_current_image slot.""" - # Mock Config - mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" - mock_exists.return_value = True # Pretend exe exists - - # Mock AppController partial environment - app = MagicMock() - app.image_files = [self.image_file] - app.current_index = 0 - app.update_status_message = MagicMock() - app.load_image_for_editing = MagicMock() - app.enable_raw_editing = MagicMock() - - # Bind real method - func = AppController.develop_raw_for_current_image.__get__(app, AppController) - func() - - # Verify delegation - app.enable_raw_editing.assert_called_once() - - def test_editor_float_pipeline_io(self): - """Test that editor saves 16-bit TIFF and Developed JPG.""" - editor = ImageEditor() - - # Create a dummy 16-bit TIFF - # We simulate this by creating a float array and 'loading' it manually - # because standard PIL won't write our 16-bit TIFF easily for setup. - # But we can create the file using our NEW writer! - - tif_path = self.tmp_path / "working-working.tif" - tif_path.touch() # Ensure it exists for backup logic - - # Create float data - arr = np.zeros((50, 50, 3), dtype=np.float32) - arr[:, :, 0] = 1.0 # Red - - # Use private writer to create source file (bootstrapping) - # Or just use load_image with a JPG and save as TIFF - - # Let's load the JPG as source, but 'fake' the current filepath as TIFF - editor.load_image(str(self.jpg_path)) - editor.current_filepath = tif_path # Trick it - - # Apply edits - editor.current_edits['exposure'] = 1.0 # +1 EV -> 2x gain - - # Save - res = editor.save_image(write_developed_jpg=True) - self.assertIsNotNone(res) - saved_path, backup_path = res - - self.assertEqual(saved_path, tif_path) - self.assertTrue(tif_path.exists()) - # With "working-working.tif" as current_filepath, the stem is "working-working". - # Our new logic strips one "-working", so it becomes "working-developed.jpg". - expected_dev_path = self.tmp_path / "working-developed.jpg" - self.assertTrue(expected_dev_path.exists(), f"Expected {expected_dev_path} to exist") - - # Verify TIFF Content (Basic) - with open(tif_path, 'rb') as f: - header = f.read(4) - self.assertEqual(header, b'II\x2a\x00') # Little endian TIFF - - # Verify Developed JPG exists - self.assertTrue(expected_dev_path.exists()) - - def test_editor_edit_float_logic(self): - """Test float math.""" - editor = ImageEditor() - arr = np.ones((10, 10, 3), dtype=np.float32) * 0.5 # Mid gray - - # Exposure +1 (2x gain in linear space) - # 0.5 sRGB is ~0.214 linear. 2x -> 0.428 linear. 0.428 linear is ~0.6858 sRGB. - edits = {'exposure': 1.0} - res = editor._apply_edits(arr.copy(), edits, for_export=True) - np.testing.assert_allclose(res, 0.6858, atol=0.01) - - # Exposure -1 (0.5x gain in linear space) - # 0.5 sRGB is ~0.214 linear. 0.5x -> 0.107 linear. 0.107 linear is ~0.3617 sRGB. - edits = {'exposure': -1.0} - res = editor._apply_edits(arr.copy(), edits, for_export=True) - np.testing.assert_allclose(res, 0.3617, atol=0.01) - - # Brightness (sRGB Multiplication) - # Brightness 0.5 -> 1.5x gain on sRGB - # 0.5 sRGB * 1.5 = 0.75 sRGB. - edits = {'brightness': 0.5} - res = editor._apply_edits(arr.copy(), edits, for_export=True) - np.testing.assert_allclose(res, 0.75, atol=0.01) +import os +import unittest +from unittest.mock import MagicMock, patch, ANY +from pathlib import Path +import tempfile +import shutil +import subprocess +import numpy as np +from PIL import Image + +from faststack.models import ImageFile +from faststack.app import AppController +from faststack.imaging.editor import ImageEditor +import logging + +# Ensure logs are visible +logging.basicConfig(level=logging.DEBUG) +log = logging.getLogger(__name__) + +class TestRawPipeline(unittest.TestCase): + @patch('faststack.app.os.path.exists') + @patch('faststack.app.subprocess.run') + @patch('faststack.config.config.get') + @patch('faststack.app.threading.Thread') + def test_develop_raw_empty_output_cleanup(self, mock_thread, mock_config_get, mock_run, mock_exists): + """Test garbage collection if RT exits 0 but produces 0-byte file.""" + mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" + mock_exists.return_value = True # exe exists + + # Make Thread().start() run the target immediately (synchronous for testing) + def side_effect_start(*args, **kwargs): + _, thread_kwargs = mock_thread.call_args + target = thread_kwargs.get('target') + if target: + target() + + mock_thread.return_value.start.side_effect = side_effect_start + + # Mock subprocess.run to return success (returncode=0) + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + app = MagicMock() + app.image_files = [self.image_file] + app.current_index = 0 + app.update_status_message = MagicMock() + + # Bind the real _develop_raw_backend method to our mock + app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) + + # Create 0-byte zombie file BEFORE calling develop + tif_path = self.image_file.working_tif_path + tif_path.touch() + self.assertTrue(tif_path.exists()) + self.assertEqual(tif_path.stat().st_size, 0) + + app._develop_raw_backend() + + # Expect file to be DELETED because it was 0 bytes + self.assertFalse(tif_path.exists(), "Zombie 0-byte file should be cleaned up") + + @patch('faststack.app.QTimer.singleShot') + @patch('faststack.app.os.path.exists') + @patch('faststack.app.subprocess.run') + @patch('faststack.config.config.get') + @patch('faststack.app.threading.Thread') + def test_develop_raw_timeout(self, mock_thread, mock_config_get, mock_run, mock_exists, mock_single_shot): + mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" + mock_exists.return_value = True + + def side_effect_start(*args, **kwargs): + _, thread_kwargs = mock_thread.call_args + target = thread_kwargs.get('target') + if target: + target() + mock_thread.return_value.start.side_effect = side_effect_start + + mock_run.side_effect = subprocess.TimeoutExpired(cmd="rawtherapee-cli", timeout=60) + + app = MagicMock() + app.image_files = [self.image_file] + app.current_index = 0 + app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) + + app._develop_raw_backend() + + # Check QTimer call + mock_single_shot.assert_called() + # call_args[0] is (0, partial_obj) + _, callback = mock_single_shot.call_args[0] + # callback is functools.partial(self._on_develop_finished, False, err_msg) + # For a bound method, callback.func is the method + self.assertTrue(hasattr(callback, 'func')) + self.assertTrue('_on_develop_finished' in str(callback.func)) + self.assertEqual(callback.args[0], False) # Success = False + self.assertIn("timed out", callback.args[1]) # Msg + + @patch('faststack.app.os.path.exists') + @patch('faststack.app.subprocess.run') + @patch('faststack.config.config.get') + @patch('faststack.app.threading.Thread') + def test_develop_raw_with_custom_args(self, mock_thread, mock_config_get, mock_run, mock_exists): + """Test that custom RawTherapee args are correctly passed to the command.""" + # Setup mock behavior for config.get + def mock_config_side_effect(section, option): + if section == "rawtherapee" and option == "exe": + return "c:\\path\\to\\rawtherapee-cli.exe" + if section == "rawtherapee" and option == "args": + return "-p my_profile.pp3 -s" + return None + mock_config_get.side_effect = mock_config_side_effect + mock_exists.return_value = True + + # Run target in thread immediately + def side_effect_start(*args, **kwargs): + _, thread_kwargs = mock_thread.call_args + target = thread_kwargs.get('target') + if target: + target() + mock_thread.return_value.start.side_effect = side_effect_start + + # Mock subprocess.run + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + app = MagicMock() + app.image_files = [self.image_file] + app.current_index = 0 + app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) + + app._develop_raw_backend() + + # Verify command + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + + # Check base command structure + self.assertEqual(cmd[0], "c:\\path\\to\\rawtherapee-cli.exe") + self.assertIn("-t", cmd) + self.assertIn("-b16", cmd) + self.assertIn("-Y", cmd) + + # Check custom args + self.assertIn("-p", cmd) + self.assertIn("my_profile.pp3", cmd) + self.assertIn("-s", cmd) + + # Check input/output order (input -c should be after args) + self.assertEqual(cmd[-2], "-c") + self.assertEqual(cmd[-1], str(self.image_file.raw_path)) + + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.tmp_path = Path(self.tmp_dir) + + # Setup dummy RAW file + self.raw_path = self.tmp_path / "test_image.CR2" + self.raw_path.touch() + + # Setup dummy JPG for indexer (FastStack usually finds JPGs first) + self.jpg_path = self.tmp_path / "test_image.jpg" + # Create a real small JPG + img = Image.new('RGB', (100, 100), color='red') + img.save(self.jpg_path) + + self.image_file = ImageFile(path=self.jpg_path) + self.image_file.raw_pair = self.raw_path + + def tearDown(self): + shutil.rmtree(self.tmp_dir) + + def test_image_file_properties(self): + """Test computed properties for RAW pipeline.""" + self.assertTrue(self.image_file.has_raw) + self.assertEqual(self.image_file.raw_path, self.tmp_path / "test_image.CR2") + self.assertEqual(self.image_file.working_tif_path, self.tmp_path / "test_image-working.tif") + self.assertEqual(self.image_file.developed_jpg_path, self.tmp_path / "test_image-developed.jpg") + + # Rename raw to break pairing + shutil.move(self.raw_path, self.tmp_path / "other.CR2") + img2 = ImageFile(path=self.jpg_path) + self.assertFalse(img2.has_raw) + + @patch('faststack.app.os.path.exists') + @patch('faststack.app.subprocess.run') + @patch('faststack.config.config.get') + def test_develop_raw_slot(self, mock_config_get, mock_run, mock_exists): + """Test the develop_raw_for_current_image slot.""" + # Mock Config + mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" + mock_exists.return_value = True # Pretend exe exists + + # Mock AppController partial environment + app = MagicMock() + app.image_files = [self.image_file] + app.current_index = 0 + app.update_status_message = MagicMock() + app.load_image_for_editing = MagicMock() + app.enable_raw_editing = MagicMock() + + # Bind real method + func = AppController.develop_raw_for_current_image.__get__(app, AppController) + func() + + # Verify delegation + app.enable_raw_editing.assert_called_once() + + def test_editor_float_pipeline_io(self): + """Test that editor saves 16-bit TIFF and Developed JPG.""" + editor = ImageEditor() + + # Create a dummy 16-bit TIFF + # We simulate this by creating a float array and 'loading' it manually + # because standard PIL won't write our 16-bit TIFF easily for setup. + # But we can create the file using our NEW writer! + + tif_path = self.tmp_path / "working-working.tif" + tif_path.touch() # Ensure it exists for backup logic + + # Create float data + arr = np.zeros((50, 50, 3), dtype=np.float32) + arr[:, :, 0] = 1.0 # Red + + # Use private writer to create source file (bootstrapping) + # Or just use load_image with a JPG and save as TIFF + + # Let's load the JPG as source, but 'fake' the current filepath as TIFF + editor.load_image(str(self.jpg_path)) + editor.current_filepath = tif_path # Trick it + + # Apply edits + editor.current_edits['exposure'] = 1.0 # +1 EV -> 2x gain + + # Save + res = editor.save_image(write_developed_jpg=True) + self.assertIsNotNone(res) + saved_path, backup_path = res + + self.assertEqual(saved_path, tif_path) + self.assertTrue(tif_path.exists()) + # With "working-working.tif" as current_filepath, the stem is "working-working". + # Our new logic strips one "-working", so it becomes "working-developed.jpg". + expected_dev_path = self.tmp_path / "working-developed.jpg" + self.assertTrue(expected_dev_path.exists(), f"Expected {expected_dev_path} to exist") + + # Verify TIFF Content (Basic) + with open(tif_path, 'rb') as f: + header = f.read(4) + self.assertEqual(header, b'II\x2a\x00') # Little endian TIFF + + # Verify Developed JPG exists + self.assertTrue(expected_dev_path.exists()) + + def test_editor_edit_float_logic(self): + """Test float math.""" + editor = ImageEditor() + arr = np.ones((10, 10, 3), dtype=np.float32) * 0.5 # Mid gray + + # Exposure +1 (2x gain in linear space) + # 0.5 sRGB is ~0.214 linear. 2x -> 0.428 linear. 0.428 linear is ~0.6858 sRGB. + edits = {'exposure': 1.0} + res = editor._apply_edits(arr.copy(), edits, for_export=True) + np.testing.assert_allclose(res, 0.6858, atol=0.01) + + # Exposure -1 (0.5x gain in linear space) + # 0.5 sRGB is ~0.214 linear. 0.5x -> 0.107 linear. 0.107 linear is ~0.3617 sRGB. + edits = {'exposure': -1.0} + res = editor._apply_edits(arr.copy(), edits, for_export=True) + np.testing.assert_allclose(res, 0.3617, atol=0.01) + + # Brightness (sRGB Multiplication) + # Brightness 0.5 -> 1.5x gain on sRGB + # 0.5 sRGB * 1.5 = 0.75 sRGB. + edits = {'brightness': 0.5} + res = editor._apply_edits(arr.copy(), edits, for_export=True) + np.testing.assert_allclose(res, 0.75, atol=0.01) diff --git a/faststack/tests/test_rolloff.py b/faststack/tests/test_rolloff.py index 3a7c5cf..37d8eca 100644 --- a/faststack/tests/test_rolloff.py +++ b/faststack/tests/test_rolloff.py @@ -1,81 +1,81 @@ -import unittest -from unittest.mock import MagicMock, patch -import numpy as np -import sys - -# We check for modules that might be missing and mock them if needed -# inside the test setup to avoid import errors at module level. - -class TestRolloff(unittest.TestCase): - def setUp(self): - # Now we use the pure math utils, so no need to mock cv2/gui/models - # unless math_utils unexpectedly depends on them. - - from faststack.imaging.math_utils import _apply_headroom_shoulder - self._apply_headroom_shoulder = _apply_headroom_shoulder - - def tearDown(self): - pass - - def test_apply_headroom_shoulder_threshold(self): - # Test that values <= 1.0 are unchanged - max_overshoot = 0.05 - x = np.array([0.0, 0.5, 0.9, 1.0]) - out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - np.testing.assert_allclose(out, x) - - def test_apply_headroom_shoulder_rolloff(self): - # Test that values > 1.0 are compressed - max_overshoot = 0.05 - # 1.0 + max_overshoot is the asymptote - x = np.array([1.01, 1.1, 2.0, 10.0]) - out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - - # Check that they are compressed (out < x) - self.assertTrue(np.all(out < x)) - - # Check that they stay above 1.0 - self.assertTrue(np.all(out > 1.0)) - - # Check asymptote (should never exceed 1.0 + max_overshoot) - self.assertTrue(np.all(out < 1.0 + max_overshoot)) - - def test_apply_headroom_shoulder_monotonic(self): - # Test monotonicity - max_overshoot = 0.05 - x = np.linspace(0.9, 5.0, 100) - out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - - # Check if strictly increasing - diffs = np.diff(out) - self.assertTrue(np.all(diffs > 0), "Output should be monotonic increasing") - - def test_apply_headroom_shoulder_continuity(self): - # Test continuity at 1.0 - max_overshoot = 0.05 - # Check very close to 1.0 from both sides - x = np.array([1.0 - 1e-7, 1.0, 1.0 + 1e-7]) - out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - - # Difference should be negligible - diffs = np.diff(out) - # Should be very small but positive - self.assertTrue(np.all(np.abs(diffs) < 1e-6)) - - def test_apply_headroom_shoulder_asymptote_check(self): - # Verification Plan Step: Feed synthetic array with very high values - max_overshoot = 0.05 - x = np.array([1.0, 1.0 + max_overshoot/2, 1.0 + 1000.0]) - out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - - # f(1.0) == 1.0 - self.assertAlmostEqual(out[0], 1.0) - - # f(very_large) should be close to 1.0 + max_overshoot - self.assertAlmostEqual(out[2], 1.0 + max_overshoot, places=4) - - # values <= 1.0 + max_overshoot - self.assertTrue(np.all(out <= 1.0 + max_overshoot + 1e-9)) - -if __name__ == "__main__": - unittest.main() +import unittest +from unittest.mock import MagicMock, patch +import numpy as np +import sys + +# We check for modules that might be missing and mock them if needed +# inside the test setup to avoid import errors at module level. + +class TestRolloff(unittest.TestCase): + def setUp(self): + # Now we use the pure math utils, so no need to mock cv2/gui/models + # unless math_utils unexpectedly depends on them. + + from faststack.imaging.math_utils import _apply_headroom_shoulder + self._apply_headroom_shoulder = _apply_headroom_shoulder + + def tearDown(self): + pass + + def test_apply_headroom_shoulder_threshold(self): + # Test that values <= 1.0 are unchanged + max_overshoot = 0.05 + x = np.array([0.0, 0.5, 0.9, 1.0]) + out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) + np.testing.assert_allclose(out, x) + + def test_apply_headroom_shoulder_rolloff(self): + # Test that values > 1.0 are compressed + max_overshoot = 0.05 + # 1.0 + max_overshoot is the asymptote + x = np.array([1.01, 1.1, 2.0, 10.0]) + out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) + + # Check that they are compressed (out < x) + self.assertTrue(np.all(out < x)) + + # Check that they stay above 1.0 + self.assertTrue(np.all(out > 1.0)) + + # Check asymptote (should never exceed 1.0 + max_overshoot) + self.assertTrue(np.all(out < 1.0 + max_overshoot)) + + def test_apply_headroom_shoulder_monotonic(self): + # Test monotonicity + max_overshoot = 0.05 + x = np.linspace(0.9, 5.0, 100) + out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) + + # Check if strictly increasing + diffs = np.diff(out) + self.assertTrue(np.all(diffs > 0), "Output should be monotonic increasing") + + def test_apply_headroom_shoulder_continuity(self): + # Test continuity at 1.0 + max_overshoot = 0.05 + # Check very close to 1.0 from both sides + x = np.array([1.0 - 1e-7, 1.0, 1.0 + 1e-7]) + out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) + + # Difference should be negligible + diffs = np.diff(out) + # Should be very small but positive + self.assertTrue(np.all(np.abs(diffs) < 1e-6)) + + def test_apply_headroom_shoulder_asymptote_check(self): + # Verification Plan Step: Feed synthetic array with very high values + max_overshoot = 0.05 + x = np.array([1.0, 1.0 + max_overshoot/2, 1.0 + 1000.0]) + out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) + + # f(1.0) == 1.0 + self.assertAlmostEqual(out[0], 1.0) + + # f(very_large) should be close to 1.0 + max_overshoot + self.assertAlmostEqual(out[2], 1.0 + max_overshoot, places=4) + + # values <= 1.0 + max_overshoot + self.assertTrue(np.all(out <= 1.0 + max_overshoot + 1e-9)) + +if __name__ == "__main__": + unittest.main() diff --git a/faststack/tests/test_rotation_unittest.py b/faststack/tests/test_rotation_unittest.py index 39c909f..00d2336 100644 --- a/faststack/tests/test_rotation_unittest.py +++ b/faststack/tests/test_rotation_unittest.py @@ -1,92 +1,92 @@ -import unittest -import numpy as np -from PIL import Image -from faststack.imaging.editor import ImageEditor - -class TestEditorRotation(unittest.TestCase): - def setUp(self): - self.editor = ImageEditor() - self.editor.current_filepath = "dummy.jpg" - - def create_quadrant_image_float(self, w=100, h=100): - # TL: Red (1, 0, 0) - # TR: Green (0, 1, 0) - # BL: Blue (0, 0, 1) - # BR: White (1, 1, 1) - arr = np.zeros((h, w, 3), dtype=np.float32) - cx, cy = w // 2, h // 2 - - # TL - arr[:cy, :cx] = [1, 0, 0] - # TR - arr[:cy, cx:] = [0, 1, 0] - # BL - arr[cy:, :cx] = [0, 0, 1] - # BR - arr[cy:, cx:] = [1, 1, 1] - - return arr - - def test_rotate_cw(self): - """Test CW rotation (90 deg clockwise).""" - # Logic: (current - 90). np.rot90 k=1 is CCW. - # CW = -90 = 270 CCW. k=3. - - arr = self.create_quadrant_image_float() - - # Manually set rotation to 270 (which is -90 CW) - self.editor.current_edits['rotation'] = 270 - - # Apply - res = self.editor._apply_edits(arr.copy(), for_export=True) - - # Check Quadrants - # TL (Red) -> TR - # TR (Green) -> BR - # BL (Blue) -> TL - # BR (White) -> BL - - w, h = 100, 100 - qw, qh = 25, 25 - - # New TL (was BL Blue) - np.testing.assert_allclose(res[qh, qw], [0, 0, 1], err_msg="TL should be Blue") - # New TR (was TL Red) - np.testing.assert_allclose(res[qh, w-qw], [1, 0, 0], err_msg="TR should be Red") - # New BL (was BR White) - np.testing.assert_allclose(res[h-qh, qw], [1, 1, 1], err_msg="BL should be White") - # New BR (was TR Green) - np.testing.assert_allclose(res[h-qh, w-qw], [0, 1, 0], err_msg="BR should be Green") - - def test_straighten_angle(self): - """Test free rotation.""" - arr = np.zeros((100, 100, 3), dtype=np.float32) - # Draw a horizontal line in middle - arr[48:52, :, :] = 1.0 - - # Rotate 90 degrees via straighten - self.editor.current_edits['straighten_angle'] = 90.0 - # Should result in vertical line - - # Note: _rotate_float_image uses PIL rotate. - # PIL rotate(angle) is Counter-Clockwise. - # straighten_angle=90 -> call rotate(-90) -> Clockwise 90? - # My implementation: `self._rotate_float_image(arr, -straighten_angle, expand=True)` - # If straighten_angle is 90, we call rotate(-90). - # rotate(-90) is Clockwise 90. - # So horizontal line becomes vertical. - - res = self.editor._apply_edits(arr.copy(), for_export=True) - - # Check shape (expanded) - # If expanded, and 90 deg, size should swap (but here 100x100 -> 100x100) - self.assertEqual(res.shape[0], 100) - self.assertEqual(res.shape[1], 100) - - # Check center column is white-ish (due to bicubic interpolation might be fuzzy) - # mid x = 50. - center_col = res[:, 50, 0] - self.assertTrue(np.mean(center_col) > 0.1) # Should have signal - - # Check left/right columns are black - self.assertTrue(np.mean(res[:, 10, 0]) < 0.1) +import unittest +import numpy as np +from PIL import Image +from faststack.imaging.editor import ImageEditor + +class TestEditorRotation(unittest.TestCase): + def setUp(self): + self.editor = ImageEditor() + self.editor.current_filepath = "dummy.jpg" + + def create_quadrant_image_float(self, w=100, h=100): + # TL: Red (1, 0, 0) + # TR: Green (0, 1, 0) + # BL: Blue (0, 0, 1) + # BR: White (1, 1, 1) + arr = np.zeros((h, w, 3), dtype=np.float32) + cx, cy = w // 2, h // 2 + + # TL + arr[:cy, :cx] = [1, 0, 0] + # TR + arr[:cy, cx:] = [0, 1, 0] + # BL + arr[cy:, :cx] = [0, 0, 1] + # BR + arr[cy:, cx:] = [1, 1, 1] + + return arr + + def test_rotate_cw(self): + """Test CW rotation (90 deg clockwise).""" + # Logic: (current - 90). np.rot90 k=1 is CCW. + # CW = -90 = 270 CCW. k=3. + + arr = self.create_quadrant_image_float() + + # Manually set rotation to 270 (which is -90 CW) + self.editor.current_edits['rotation'] = 270 + + # Apply + res = self.editor._apply_edits(arr.copy(), for_export=True) + + # Check Quadrants + # TL (Red) -> TR + # TR (Green) -> BR + # BL (Blue) -> TL + # BR (White) -> BL + + w, h = 100, 100 + qw, qh = 25, 25 + + # New TL (was BL Blue) + np.testing.assert_allclose(res[qh, qw], [0, 0, 1], err_msg="TL should be Blue") + # New TR (was TL Red) + np.testing.assert_allclose(res[qh, w-qw], [1, 0, 0], err_msg="TR should be Red") + # New BL (was BR White) + np.testing.assert_allclose(res[h-qh, qw], [1, 1, 1], err_msg="BL should be White") + # New BR (was TR Green) + np.testing.assert_allclose(res[h-qh, w-qw], [0, 1, 0], err_msg="BR should be Green") + + def test_straighten_angle(self): + """Test free rotation.""" + arr = np.zeros((100, 100, 3), dtype=np.float32) + # Draw a horizontal line in middle + arr[48:52, :, :] = 1.0 + + # Rotate 90 degrees via straighten + self.editor.current_edits['straighten_angle'] = 90.0 + # Should result in vertical line + + # Note: _rotate_float_image uses PIL rotate. + # PIL rotate(angle) is Counter-Clockwise. + # straighten_angle=90 -> call rotate(-90) -> Clockwise 90? + # My implementation: `self._rotate_float_image(arr, -straighten_angle, expand=True)` + # If straighten_angle is 90, we call rotate(-90). + # rotate(-90) is Clockwise 90. + # So horizontal line becomes vertical. + + res = self.editor._apply_edits(arr.copy(), for_export=True) + + # Check shape (expanded) + # If expanded, and 90 deg, size should swap (but here 100x100 -> 100x100) + self.assertEqual(res.shape[0], 100) + self.assertEqual(res.shape[1], 100) + + # Check center column is white-ish (due to bicubic interpolation might be fuzzy) + # mid x = 50. + center_col = res[:, 50, 0] + self.assertTrue(np.mean(center_col) > 0.1) # Should have signal + + # Check left/right columns are black + self.assertTrue(np.mean(res[:, 10, 0]) < 0.1) diff --git a/faststack/tests/test_sensitivity.py b/faststack/tests/test_sensitivity.py index 5007095..e6431f4 100644 --- a/faststack/tests/test_sensitivity.py +++ b/faststack/tests/test_sensitivity.py @@ -1,52 +1,52 @@ -import numpy as np -import sys -import os - -# Add parent directory to sys.path to allow importing faststack -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) - -from faststack.imaging.editor import ImageEditor - -def test_contrast_saturation_sensitivity(): - print("Testing contrast and saturation sensitivity...") - editor = ImageEditor() - # Create a 100x100 dummy image (gray with some color) - arr = np.zeros((100, 100, 3), dtype=np.float32) - arr[:, :50, 0] = 0.8 # Red left half - arr[:, 50:, 1] = 0.8 # Green right half - editor.float_preview = arr - - # Test Contrast at 100 (backend value 1.0) - print("Testing Contrast at 1.0...") - edits = editor._initial_edits() - edits['contrast'] = 1.0 - out = editor._apply_edits(arr.copy(), edits=edits) - - # Original contrast factor was 1.0 + 1.0 = 2.0 - # New contrast factor should be 1.0 + 1.0 * 0.4 = 1.4 - # Check a pixel that was 0.8: (0.8 - 0.5) * 1.4 + 0.5 = 0.3 * 1.4 + 0.5 = 0.42 + 0.5 = 0.92 - val = out[0, 0, 0] - print(f"Contrast 1.0 result: {val}") - assert np.allclose(val, 0.92, atol=0.01), f"Expected 0.92, got {val}" - - # Test Saturation at 100 (backend value 1.0) - print("Testing Saturation at 1.0...") - edits = editor._initial_edits() - edits['saturation'] = 1.0 - out = editor._apply_edits(arr.copy(), edits=edits) - - # Original saturation factor was 1.0 + 1.0 = 2.0 - # New saturation factor should be 1.0 + 1.0 * 0.5 = 1.5 - # Pixel (0.8, 0, 0): gray = 0.8 * 0.299 = 0.2392 - # New: 0.2392 + (0.8 - 0.2392) * 1.5 = 0.2392 + 0.5608 * 1.5 = 0.2392 + 0.8412 = 1.0804 - val_sat = out[0, 0, 0] - print(f"Saturation 1.0 result: {val_sat}") - assert np.allclose(val_sat, 1.0804, atol=0.01), f"Expected 1.0804, got {val_sat}" - print("All tests passed!") - -if __name__ == "__main__": - try: - test_contrast_saturation_sensitivity() - except Exception as e: - print(f"Test failed: {e}") - sys.exit(1) +import numpy as np +import sys +import os + +# Add parent directory to sys.path to allow importing faststack +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from faststack.imaging.editor import ImageEditor + +def test_contrast_saturation_sensitivity(): + print("Testing contrast and saturation sensitivity...") + editor = ImageEditor() + # Create a 100x100 dummy image (gray with some color) + arr = np.zeros((100, 100, 3), dtype=np.float32) + arr[:, :50, 0] = 0.8 # Red left half + arr[:, 50:, 1] = 0.8 # Green right half + editor.float_preview = arr + + # Test Contrast at 100 (backend value 1.0) + print("Testing Contrast at 1.0...") + edits = editor._initial_edits() + edits['contrast'] = 1.0 + out = editor._apply_edits(arr.copy(), edits=edits) + + # Original contrast factor was 1.0 + 1.0 = 2.0 + # New contrast factor should be 1.0 + 1.0 * 0.4 = 1.4 + # Check a pixel that was 0.8: (0.8 - 0.5) * 1.4 + 0.5 = 0.3 * 1.4 + 0.5 = 0.42 + 0.5 = 0.92 + val = out[0, 0, 0] + print(f"Contrast 1.0 result: {val}") + assert np.allclose(val, 0.92, atol=0.01), f"Expected 0.92, got {val}" + + # Test Saturation at 100 (backend value 1.0) + print("Testing Saturation at 1.0...") + edits = editor._initial_edits() + edits['saturation'] = 1.0 + out = editor._apply_edits(arr.copy(), edits=edits) + + # Original saturation factor was 1.0 + 1.0 = 2.0 + # New saturation factor should be 1.0 + 1.0 * 0.5 = 1.5 + # Pixel (0.8, 0, 0): gray = 0.8 * 0.299 = 0.2392 + # New: 0.2392 + (0.8 - 0.2392) * 1.5 = 0.2392 + 0.5608 * 1.5 = 0.2392 + 0.8412 = 1.0804 + val_sat = out[0, 0, 0] + print(f"Saturation 1.0 result: {val_sat}") + assert np.allclose(val_sat, 1.0804, atol=0.01), f"Expected 1.0804, got {val_sat}" + print("All tests passed!") + +if __name__ == "__main__": + try: + test_contrast_saturation_sensitivity() + except Exception as e: + print(f"Test failed: {e}") + sys.exit(1) diff --git a/faststack/tests/test_sidecar.py b/faststack/tests/test_sidecar.py index ea2862d..e38b964 100644 --- a/faststack/tests/test_sidecar.py +++ b/faststack/tests/test_sidecar.py @@ -1,77 +1,77 @@ -"""Tests for the SidecarManager.""" - -import json -from pathlib import Path - -import pytest - -from faststack.io.sidecar import SidecarManager -from faststack.models import EntryMetadata - -@pytest.fixture -def mock_sidecar_dir(tmp_path: Path): - """Creates a temp dir and can pre-populate a sidecar file.""" - def _create(content: dict = None): - if content: - (tmp_path / "faststack.json").write_text(json.dumps(content)) - return tmp_path - return _create - -def test_sidecar_load_non_existent(mock_sidecar_dir): - """Tests loading when no sidecar file exists.""" - d = mock_sidecar_dir() - sm = SidecarManager(d, None) - assert sm.data.version == 2 - assert sm.data.last_index == 0 - assert not sm.data.entries - -def test_sidecar_load_existing(mock_sidecar_dir): - """Tests loading a valid, existing sidecar file.""" - content = { - "version": 2, - "last_index": 42, - "entries": { - "IMG_0001": { "flag": True, "reject": False, "stack_id": 1 }, - "IMG_0002": { "flag": False, "reject": True, "stack_id": None }, - } - } - d = mock_sidecar_dir(content) - sm = SidecarManager(d, None) - - assert sm.data.last_index == 42 - assert len(sm.data.entries) == 2 - - # flag and reject are legacy and not in current model, so they are dropped. - # stack_id IS in the current model, so it should be preserved. - assert sm.data.entries["IMG_0001"].stack_id == 1 - - # IMG_0002 has stack_id=None - assert sm.data.entries["IMG_0002"].stack_id is None - -def test_sidecar_save(mock_sidecar_dir): - """Tests saving data back to the JSON file.""" - d = mock_sidecar_dir() - sm = SidecarManager(d, None) - - # Modify data - sm.set_last_index(10) - meta = sm.get_metadata("IMG_TEST") - # Modify a valid field - meta.stack_id = 99 - - # Save - sm.save() - - # Verify file content - saved_data = json.loads((d / "faststack.json").read_text()) - assert saved_data["last_index"] == 10 - assert saved_data["entries"]["IMG_TEST"]["stack_id"] == 99 - -def test_sidecar_get_metadata_creates_new(mock_sidecar_dir): - """Tests that get_metadata creates a new entry if one doesn't exist.""" - d = mock_sidecar_dir() - sm = SidecarManager(d, None) - assert "NEW_IMG" not in sm.data.entries - meta = sm.get_metadata("NEW_IMG") - assert isinstance(meta, EntryMetadata) - assert "NEW_IMG" in sm.data.entries +"""Tests for the SidecarManager.""" + +import json +from pathlib import Path + +import pytest + +from faststack.io.sidecar import SidecarManager +from faststack.models import EntryMetadata + +@pytest.fixture +def mock_sidecar_dir(tmp_path: Path): + """Creates a temp dir and can pre-populate a sidecar file.""" + def _create(content: dict = None): + if content: + (tmp_path / "faststack.json").write_text(json.dumps(content)) + return tmp_path + return _create + +def test_sidecar_load_non_existent(mock_sidecar_dir): + """Tests loading when no sidecar file exists.""" + d = mock_sidecar_dir() + sm = SidecarManager(d, None) + assert sm.data.version == 2 + assert sm.data.last_index == 0 + assert not sm.data.entries + +def test_sidecar_load_existing(mock_sidecar_dir): + """Tests loading a valid, existing sidecar file.""" + content = { + "version": 2, + "last_index": 42, + "entries": { + "IMG_0001": { "flag": True, "reject": False, "stack_id": 1 }, + "IMG_0002": { "flag": False, "reject": True, "stack_id": None }, + } + } + d = mock_sidecar_dir(content) + sm = SidecarManager(d, None) + + assert sm.data.last_index == 42 + assert len(sm.data.entries) == 2 + + # flag and reject are legacy and not in current model, so they are dropped. + # stack_id IS in the current model, so it should be preserved. + assert sm.data.entries["IMG_0001"].stack_id == 1 + + # IMG_0002 has stack_id=None + assert sm.data.entries["IMG_0002"].stack_id is None + +def test_sidecar_save(mock_sidecar_dir): + """Tests saving data back to the JSON file.""" + d = mock_sidecar_dir() + sm = SidecarManager(d, None) + + # Modify data + sm.set_last_index(10) + meta = sm.get_metadata("IMG_TEST") + # Modify a valid field + meta.stack_id = 99 + + # Save + sm.save() + + # Verify file content + saved_data = json.loads((d / "faststack.json").read_text()) + assert saved_data["last_index"] == 10 + assert saved_data["entries"]["IMG_TEST"]["stack_id"] == 99 + +def test_sidecar_get_metadata_creates_new(mock_sidecar_dir): + """Tests that get_metadata creates a new entry if one doesn't exist.""" + d = mock_sidecar_dir() + sm = SidecarManager(d, None) + assert "NEW_IMG" not in sm.data.entries + meta = sm.get_metadata("NEW_IMG") + assert isinstance(meta, EntryMetadata) + assert "NEW_IMG" in sm.data.entries diff --git a/faststack/tests/test_version_sort.py b/faststack/tests/test_version_sort.py index 6465681..09deb47 100644 --- a/faststack/tests/test_version_sort.py +++ b/faststack/tests/test_version_sort.py @@ -1,58 +1,58 @@ -import unittest -import re -from pathlib import PureWindowsPath - -# Re-implementing the function locally to match the fix in config.py -# (The function in config.py is nested inside detect_rawtherapee_path so not easily importable) -def version_sort_key(path): - for part in reversed(PureWindowsPath(path).parts): - if re.fullmatch(r'\d+(?:\.\d+)*', part): - return [int(n) for n in part.split(".")] - return [0] - - -class TestVersionSort(unittest.TestCase): - def test_version_sort_preference(self): - """ - Test that higher version numbers are preferred regardless of parent directory names. - """ - # Scenario: 5.10 (x86) vs 5.9 (x64) - # The x86 path has "86" in "Program Files (x86)", which should NOT confuse the sort. - - path_5_10_x86 = r"C:\Program Files (x86)\RawTherapee\5.10\rawtherapee-cli.exe" - path_5_9_x64 = r"C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe" - - paths = [path_5_9_x64, path_5_10_x86] - - # BROKEN behavior check (optional logic, just to demonstrate the issue) - # In natural sort, "Program Files (x86)" might come after "Program Files" depending on how it handles " (" vs "" - # But specifically, the "86" is a number. - # "Program Files" split -> ['Program Files'] - # "Program Files (x86)" split -> ['Program Files (x', 86, ')'] - # Comparison logic is complex but often fails here. - - # CORRECT behavior check - paths.sort(key=version_sort_key, reverse=True) - self.assertEqual(paths[0], path_5_10_x86, "Should select 5.10 over 5.9") - - def test_same_version_different_arch(self): - """ - If versions are identical, stability or secondary sort doesn't strictly matter for validity, - but we want to ensure it doesn't crash or return garbage. - """ - p1 = r"C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe" - p2 = r"C:\Program Files (x86)\RawTherapee\5.9\rawtherapee-cli.exe" - - key1 = version_sort_key(p1) - key2 = version_sort_key(p2) - - self.assertEqual(key1, [5, 9]) - self.assertEqual(key2, [5, 9]) - self.assertEqual(key1, key2) - - def test_no_version_in_path(self): - p = r"C:\Program Files\RawTherapee\bin\rawtherapee-cli.exe" - self.assertEqual(version_sort_key(p), [0]) - -if __name__ == '__main__': - unittest.main() +import unittest +import re +from pathlib import PureWindowsPath + +# Re-implementing the function locally to match the fix in config.py +# (The function in config.py is nested inside detect_rawtherapee_path so not easily importable) +def version_sort_key(path): + for part in reversed(PureWindowsPath(path).parts): + if re.fullmatch(r'\d+(?:\.\d+)*', part): + return [int(n) for n in part.split(".")] + return [0] + + +class TestVersionSort(unittest.TestCase): + def test_version_sort_preference(self): + """ + Test that higher version numbers are preferred regardless of parent directory names. + """ + # Scenario: 5.10 (x86) vs 5.9 (x64) + # The x86 path has "86" in "Program Files (x86)", which should NOT confuse the sort. + + path_5_10_x86 = r"C:\Program Files (x86)\RawTherapee\5.10\rawtherapee-cli.exe" + path_5_9_x64 = r"C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe" + + paths = [path_5_9_x64, path_5_10_x86] + + # BROKEN behavior check (optional logic, just to demonstrate the issue) + # In natural sort, "Program Files (x86)" might come after "Program Files" depending on how it handles " (" vs "" + # But specifically, the "86" is a number. + # "Program Files" split -> ['Program Files'] + # "Program Files (x86)" split -> ['Program Files (x', 86, ')'] + # Comparison logic is complex but often fails here. + + # CORRECT behavior check + paths.sort(key=version_sort_key, reverse=True) + self.assertEqual(paths[0], path_5_10_x86, "Should select 5.10 over 5.9") + + def test_same_version_different_arch(self): + """ + If versions are identical, stability or secondary sort doesn't strictly matter for validity, + but we want to ensure it doesn't crash or return garbage. + """ + p1 = r"C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe" + p2 = r"C:\Program Files (x86)\RawTherapee\5.9\rawtherapee-cli.exe" + + key1 = version_sort_key(p1) + key2 = version_sort_key(p2) + + self.assertEqual(key1, [5, 9]) + self.assertEqual(key2, [5, 9]) + self.assertEqual(key1, key2) + + def test_no_version_in_path(self): + p = r"C:\Program Files\RawTherapee\bin\rawtherapee-cli.exe" + self.assertEqual(version_sort_key(p), [0]) + +if __name__ == '__main__': + unittest.main() diff --git a/faststack/ui/keystrokes.py b/faststack/ui/keystrokes.py index 926fc78..c870a66 100644 --- a/faststack/ui/keystrokes.py +++ b/faststack/ui/keystrokes.py @@ -1,123 +1,123 @@ -# faststack/ui/keystrokes.py -import logging -from PySide6.QtCore import Qt - -log = logging.getLogger(__name__) - -class Keybinder: - def __init__(self, controller): - """ - controller is your AppController. - We will call controller.() by default, - but if controller.main_window has a QML method of the same name, - we'll call that instead so the footer/UI stays in sync. - """ - self.controller = controller - - # map keys → method names (not callables) - self.key_map = { - # Navigation - Qt.Key_J: "next_image", - Qt.Key_Right: "next_image", - Qt.Key_K: "prev_image", - Qt.Key_Left: "prev_image", - Qt.Key_G: "show_jump_to_image_dialog", - - # Stacking - Qt.Key_BracketLeft: "begin_new_stack", - Qt.Key_BracketRight: "end_current_stack", - Qt.Key_S: "toggle_stack_membership", - - # Batching - Qt.Key_BraceLeft: "begin_new_batch", - Qt.Key_BraceRight: "end_current_batch", - Qt.Key_Backslash: "clear_all_batches", - Qt.Key_B: "toggle_batch_membership", - - # Remove from batch/stack - Qt.Key_X: "remove_from_batch_or_stack", - - # Toggle flags - Qt.Key_U: "toggle_uploaded", - Qt.Key_I: "show_exif_dialog", - - # Actions - Qt.Key_Enter: "launch_helicon", - Qt.Key_Return: "launch_helicon", - Qt.Key_P: "edit_in_photoshop", - Qt.Key_C: "clear_all_stacks", - Qt.Key_A: "quick_auto_white_balance", - Qt.Key_L: "quick_auto_levels", - Qt.Key_O: "toggle_crop_mode", - Qt.Key_H: "toggle_histogram", - Qt.Key_Delete: "delete_current_image", - Qt.Key_Backspace: "delete_current_image", - } - - self.modifier_key_map = { - (Qt.Key_C, Qt.ControlModifier): "copy_path_to_clipboard", - (Qt.Key_0, Qt.ControlModifier): "reset_zoom_pan", - (Qt.Key_Z, Qt.ControlModifier): "undo_delete", - (Qt.Key_E, Qt.ControlModifier): "toggle_edited", - (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", - - (Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier): "quick_auto_white_balance", - (Qt.Key_1, Qt.ControlModifier): "zoom_100", - (Qt.Key_2, Qt.ControlModifier): "zoom_200", - (Qt.Key_3, Qt.ControlModifier): "zoom_300", - (Qt.Key_4, Qt.ControlModifier): "zoom_400", - } - - def _call(self, method_name: str): - """ - Try QML root first (to keep footer/UI happy), then controller. - """ - mw = getattr(self.controller, "main_window", None) - if mw is not None and hasattr(mw, method_name): - getattr(mw, method_name)() - return - - if hasattr(self.controller, method_name): - getattr(self.controller, method_name)() - return - - log.warning(f"Keybinder: neither main_window nor controller has '{method_name}'") - - def handle_key_press(self, event): - key = event.key() - text = event.text() - modifiers = event.modifiers() - log.debug(f"Key pressed: {key} ({text!r}) with modifiers {modifiers}") - - # Check for modifier + key combinations - for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): - # Check if required modifier is present in event modifiers - if key == mapped_key and (modifiers & mapped_modifier): - log.debug(f"Matched modifier key: {key} + {mapped_modifier} -> {method_name}") - self._call(method_name) - return True - - # Check for single key presses - method_name = self.key_map.get(key) - if method_name: - self._call(method_name) - return True - - # extra safety for layouts where bracket keycodes are odd - if text == "[": - self._call("begin_new_stack") - return True - if text == "]": - self._call("end_current_stack") - return True - if text == "{": - self._call("begin_new_batch") - return True - if text == "}": - self._call("end_current_batch") - return True - if text == "\\": - self._call("clear_all_batches") - return True - - return False +# faststack/ui/keystrokes.py +import logging +from PySide6.QtCore import Qt + +log = logging.getLogger(__name__) + +class Keybinder: + def __init__(self, controller): + """ + controller is your AppController. + We will call controller.() by default, + but if controller.main_window has a QML method of the same name, + we'll call that instead so the footer/UI stays in sync. + """ + self.controller = controller + + # map keys → method names (not callables) + self.key_map = { + # Navigation + Qt.Key_J: "next_image", + Qt.Key_Right: "next_image", + Qt.Key_K: "prev_image", + Qt.Key_Left: "prev_image", + Qt.Key_G: "show_jump_to_image_dialog", + + # Stacking + Qt.Key_BracketLeft: "begin_new_stack", + Qt.Key_BracketRight: "end_current_stack", + Qt.Key_S: "toggle_stack_membership", + + # Batching + Qt.Key_BraceLeft: "begin_new_batch", + Qt.Key_BraceRight: "end_current_batch", + Qt.Key_Backslash: "clear_all_batches", + Qt.Key_B: "toggle_batch_membership", + + # Remove from batch/stack + Qt.Key_X: "remove_from_batch_or_stack", + + # Toggle flags + Qt.Key_U: "toggle_uploaded", + Qt.Key_I: "show_exif_dialog", + + # Actions + Qt.Key_Enter: "launch_helicon", + Qt.Key_Return: "launch_helicon", + Qt.Key_P: "edit_in_photoshop", + Qt.Key_C: "clear_all_stacks", + Qt.Key_A: "quick_auto_white_balance", + Qt.Key_L: "quick_auto_levels", + Qt.Key_O: "toggle_crop_mode", + Qt.Key_H: "toggle_histogram", + Qt.Key_Delete: "delete_current_image", + Qt.Key_Backspace: "delete_current_image", + } + + self.modifier_key_map = { + (Qt.Key_C, Qt.ControlModifier): "copy_path_to_clipboard", + (Qt.Key_0, Qt.ControlModifier): "reset_zoom_pan", + (Qt.Key_Z, Qt.ControlModifier): "undo_delete", + (Qt.Key_E, Qt.ControlModifier): "toggle_edited", + (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", + + (Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier): "quick_auto_white_balance", + (Qt.Key_1, Qt.ControlModifier): "zoom_100", + (Qt.Key_2, Qt.ControlModifier): "zoom_200", + (Qt.Key_3, Qt.ControlModifier): "zoom_300", + (Qt.Key_4, Qt.ControlModifier): "zoom_400", + } + + def _call(self, method_name: str): + """ + Try QML root first (to keep footer/UI happy), then controller. + """ + mw = getattr(self.controller, "main_window", None) + if mw is not None and hasattr(mw, method_name): + getattr(mw, method_name)() + return + + if hasattr(self.controller, method_name): + getattr(self.controller, method_name)() + return + + log.warning(f"Keybinder: neither main_window nor controller has '{method_name}'") + + def handle_key_press(self, event): + key = event.key() + text = event.text() + modifiers = event.modifiers() + log.debug(f"Key pressed: {key} ({text!r}) with modifiers {modifiers}") + + # Check for modifier + key combinations + for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): + # Check if required modifier is present in event modifiers + if key == mapped_key and (modifiers & mapped_modifier): + log.debug(f"Matched modifier key: {key} + {mapped_modifier} -> {method_name}") + self._call(method_name) + return True + + # Check for single key presses + method_name = self.key_map.get(key) + if method_name: + self._call(method_name) + return True + + # extra safety for layouts where bracket keycodes are odd + if text == "[": + self._call("begin_new_stack") + return True + if text == "]": + self._call("end_current_stack") + return True + if text == "{": + self._call("begin_new_batch") + return True + if text == "}": + self._call("end_current_batch") + return True + if text == "\\": + self._call("clear_all_batches") + return True + + return False diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index af33565..7e92236 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -1,1144 +1,1144 @@ -"""QML Image Provider and application state bridge.""" - -import logging -import collections -from PySide6.QtCore import QObject, Signal, Property, Slot, Qt -from PySide6.QtGui import QImage -from PySide6.QtQuick import QQuickImageProvider - -from faststack.models import DecodedImage -from faststack.config import config -from faststack.imaging.optional_deps import HAS_OPENCV -from pathlib import Path - -# 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__) - - -class ImageProvider(QQuickImageProvider): - def __init__(self, app_controller): - super().__init__(QQuickImageProvider.ImageType.Image) - self.app_controller = app_controller - self.placeholder = QImage(256, 256, QImage.Format.Format_RGB888) - self.placeholder.fill(Qt.GlobalColor.darkGray) - # Keepalive queue to prevent GC of buffers currently in use by QImage - # Increased to 128 to prevent crashes during rapid scrolling/thrashing where - # QML might hold onto textures slightly longer than the Python GC expects. - self._keepalive = collections.deque(maxlen=128) - - def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: - """Handles image requests from QML.""" - if not id: - return self.placeholder - - try: - # Parse index and optional generation - parts = id.split('/') - index = int(parts[0]) - gen = int(parts[1]) if len(parts) > 1 else None - - # If editor is open, use the background-rendered preview buffer - # BUT only if the requested index matches the currently edited index! - # AND the generation matches (to avoid stale frames during rotation/param changes) - # FIX: If zoomed in, force full-res image instead of low-res preview - - use_editor_preview = ( - self.app_controller.ui_state.isEditorOpen - and index == self.app_controller.current_index - and not self.app_controller.ui_state.isZoomed - and self.app_controller._last_rendered_preview is not None - and getattr(self.app_controller, "_last_rendered_preview_index", None) == index - and (gen is None or getattr(self.app_controller, "_last_rendered_preview_gen", None) == gen) - ) - - image_data = ( - self.app_controller._last_rendered_preview - if use_editor_preview - else self.app_controller.get_decoded_image(index) - ) - - if image_data: - # Handle format being None (from prefetcher) or missing - fmt = getattr(image_data, 'format', None) - if fmt is None: - fmt = QImage.Format.Format_RGB888 - - qimg = QImage( - image_data.buffer, - image_data.width, - image_data.height, - image_data.bytes_per_line, - fmt - ) - - - # Detach from Python buffer to prevent ownership issues and force proper texture upload - # OPTIMIZATION: Only do this expensive copy when serving the live editor preview, - # where we need to detach from the shared memory buffer that might change. - # For standard browsing/prefetch, the buffer is stable enough. - if self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index: - qimg = qimg.copy() - else: - # SAFETY: Keep a reference to the underlying buffer to prevent garbage collection - # while Qt holds the QImage. QImage created from bytes does NOT own the data. - self._keepalive.append(image_data.buffer) - - # Set sRGB color space for proper color management (if available) - # Skip this when using ICC mode - pixels are already in monitor space - color_mode = config.get('color', 'mode', fallback="none").lower() - if HAS_COLOR_SPACE and color_mode != "icc": - try: - # Create sRGB color space using constructor with NamedColorSpace enum - cs = QColorSpace(QColorSpace.NamedColorSpace.SRgb) - qimg.setColorSpace(cs) - log.debug("Applied sRGB color space to image") - except (RuntimeError, ValueError) as e: - log.warning(f"Failed to set color space: {e}") - elif color_mode == "icc": - log.debug("ICC mode: skipping Qt color space (pixels already in monitor space)") - - # Buffer is now safe to release (handled by copy), but original_buffer ref in Python object stays - # We don't need to manually attach original_buffer to qimg anymore since we copied. - return qimg - - except (ValueError, IndexError) as e: - log.error(f"Invalid image ID requested from QML: {id}. Error: {e}") - - return self.placeholder - - -class UIState(QObject): - """Manages the state exposed to the QML user interface.""" - - # Signals - currentIndexChanged = Signal() - imageCountChanged = Signal() - currentImageSourceChanged = Signal() - metadataChanged = Signal() - themeChanged = Signal() - preloadingStateChanged = Signal() - preloadProgressChanged = Signal() - isZoomedChanged = Signal() - statusMessageChanged = Signal() # New signal for status messages - resetZoomPanRequested = Signal() # Signal to tell QML to reset zoom/pan - absoluteZoomRequested = Signal(float) # New: Request absolute zoom level (1.0, 2.0, etc.) - stackSummaryChanged = Signal() # Signal for stack summary updates - filterStringChanged = Signal() # Signal for filter string updates - colorModeChanged = Signal() # Signal for color mode updates - saturationFactorChanged = Signal() # Signal for saturation factor updates - awbModeChanged = Signal() - awbStrengthChanged = Signal() - awbWarmBiasChanged = Signal() - awbTintBiasChanged = Signal() - awbLumaLowerBoundChanged = Signal() - awbLumaUpperBoundChanged = Signal() - awbRgbLowerBoundChanged = Signal() - awbRgbUpperBoundChanged = Signal() - default_directory_changed = Signal(str) - isStackedJpgChanged = Signal() # New signal for isStackedJpg - autoLevelClippingThresholdChanged = Signal(float) - autoLevelStrengthChanged = Signal(float) - autoLevelStrengthAutoChanged = Signal(bool) - # Image Editor Signals - is_editor_open_changed = Signal(bool) - editorImageChanged = Signal() # New signal for when the image loaded in editor changes - is_cropping_changed = Signal(bool) - - is_histogram_visible_changed = Signal(bool) - histogram_data_changed = Signal() - highlightStateChanged = Signal() # New signal for highlight analysis updates - brightness_changed = Signal(float) - contrast_changed = Signal(float) - saturation_changed = Signal(float) - white_balance_by_changed = Signal(float) - white_balance_mg_changed = Signal(float) - aspect_ratio_names_changed = Signal(list) - current_aspect_ratio_index_changed = Signal(int) - current_crop_box_changed = Signal(tuple) # (left, top, right, bottom) normalized to 0-1000 - crop_rotation_changed = Signal(float) - anySliderPressedChanged = Signal(bool) - sharpness_changed = Signal(float) - rotation_changed = Signal(int) - exposure_changed = Signal(float) - highlights_changed = Signal(float) - shadows_changed = Signal(float) - vibrance_changed = Signal(float) - vignette_changed = Signal(float) - blacks_changed = Signal(float) - whites_changed = Signal(float) - clarity_changed = Signal(float) - texture_changed = Signal(float) - - # Debug Cache Signals - debugCacheChanged = Signal(bool) - cacheStatsChanged = Signal(str) - isDecodingChanged = Signal(bool) - debugModeChanged = Signal(bool) # General debug mode signal - isDialogOpenChanged = Signal(bool) # New signal for dialog state - editSourceModeChanged = Signal(str) # Notify when JPEG/RAW mode changes - saveBehaviorMessageChanged = Signal() # Signal for save behavior message updates - - def __init__(self, app_controller): - super().__init__() - self.app_controller = app_controller - self._is_preloading = False - self._preload_progress = 0 - # 1 = light, 0 = dark (controller will overwrite this on startup) - self._theme = 1 - self._status_message = "" # New private variable for status message - # Image Editor State - self._is_editor_open = False - self._is_cropping = False - self._is_histogram_visible = False - self._histogram_data = {} # Will be a dict with 'r', 'g', 'b' arrays - self._brightness = 0.0 - self._contrast = 0.0 - self._saturation = 0.0 - self._white_balance_by = 0.0 - self._white_balance_mg = 0.0 - self._current_crop_box = [0, 0, 1000, 1000] - self._crop_rotation = 0.0 - self._debug_mode = False - self._aspect_ratio_names = [ - "Freeform", - "1:1 (Square)", - "4:5 (Portrait)", - "1.91:1 (Landscape)", - "16:9 (Wide)", - "9:16 (Story)" - ] - self._current_aspect_ratio_index = 0 - self._any_slider_pressed = False - self._sharpness = 0.0 - self._rotation = 0 - self._exposure = 0.0 - self._highlights = 0.0 - self._shadows = 0.0 - self._vibrance = 0.0 - self._vignette = 0.0 - self._blacks = 0.0 - self._whites = 0.0 - self._clarity = 0.0 - self._texture = 0.0 - - # Debug Cache State - self._debug_cache = False - self._cache_stats = "" - self._is_decoding = False - self._is_dialog_open = False - - # Connect to controller's dialog state signal - self.app_controller.dialogStateChanged.connect(self._on_dialog_state_changed) - - # Connect to controller's mode change signal - # We need to ensure the signal exists on controller first (it does, I added it) - if hasattr(self.app_controller, 'editSourceModeChanged'): - self.app_controller.editSourceModeChanged.connect(self.editSourceModeChanged) - self.app_controller.editSourceModeChanged.connect(lambda _: self.saveBehaviorMessageChanged.emit()) - self.app_controller.editSourceModeChanged.connect(lambda _: self.metadataChanged.emit()) # Also update metadata binding if needed - - def _on_dialog_state_changed(self, is_open: bool): - self.isDialogOpen = is_open - - # ---- THEME PROPERTY ---- - @Property(int, notify=themeChanged) - def theme(self): - return self._theme - - @theme.setter - def theme(self, value: int): - value = int(value) - if value == self._theme: - return - self._theme = value - self.themeChanged.emit() - - # ---- ZOOM ---- - @Property(bool, notify=isZoomedChanged) - def isZoomed(self): - return self.app_controller.is_zoomed - - @Slot(bool) - def setZoomed(self, zoomed: bool): - self.app_controller.set_zoomed(zoomed) - - @Slot(float) - def request_absolute_zoom(self, scale): - """Request the UI to set zoom to an absolute scale (1.0 = 100%).""" - self.absoluteZoomRequested.emit(scale) - - # ---- PRELOADING ---- - @Property(bool, notify=preloadingStateChanged) - def isPreloading(self): - return self._is_preloading - - @isPreloading.setter - def isPreloading(self, value): - if self._is_preloading != value: - self._is_preloading = value - self.preloadingStateChanged.emit() - - @Property(int, notify=preloadProgressChanged) - def preloadProgress(self): - return self._preload_progress - - @preloadProgress.setter - def preloadProgress(self, value): - if self._preload_progress != value: - self._preload_progress = value - self.preloadProgressChanged.emit() - - # ---- IMAGE / METADATA ---- - @Property(int, notify=currentIndexChanged) - def currentIndex(self): - return self.app_controller.current_index - - @Property(int, notify=imageCountChanged) - def imageCount(self): - return len(self.app_controller.image_files) - - @Property(str, notify=currentImageSourceChanged) - def currentImageSource(self): - return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" - - @Property(str, notify=metadataChanged) - def currentFilename(self): - if not self.app_controller.image_files: - return "" - return self.app_controller.get_current_metadata().get("filename", "") - - @Property(bool, notify=metadataChanged) - def isStacked(self): - if not self.app_controller.image_files: - return False - return self.app_controller.get_current_metadata().get("stacked", False) - - @Property(str, notify=metadataChanged) - def stackedDate(self): - if not self.app_controller.image_files: - return "" - return self.app_controller.get_current_metadata().get("stacked_date", "") - - @Property(str, notify=metadataChanged) - def stackInfoText(self): - if not self.app_controller.image_files: - return "" - return self.app_controller.get_current_metadata().get("stack_info_text", "") - - @Property(bool, notify=metadataChanged) - def isUploaded(self): - if not self.app_controller.image_files: - return False - return self.app_controller.get_current_metadata().get("uploaded", False) - - @Property(str, notify=metadataChanged) - def uploadedDate(self): - if not self.app_controller.image_files: - return "" - return self.app_controller.get_current_metadata().get("uploaded_date", "") - - @Property(str, notify=metadataChanged) - def batchInfoText(self): - if not self.app_controller.image_files: - return "" - return self.app_controller.get_current_metadata().get("batch_info_text", "") - - @Property(bool, notify=metadataChanged) - def isEdited(self): - if not self.app_controller.image_files: - return False - return self.app_controller.get_current_metadata().get("edited", False) - - @Property(str, notify=metadataChanged) - def editedDate(self): - if not self.app_controller.image_files: - return "" - return self.app_controller.get_current_metadata().get("edited_date", "") - - @Property(bool, notify=metadataChanged) - def isRestacked(self): - if not self.app_controller.image_files: - return False - return self.app_controller.get_current_metadata().get("restacked", False) - - @Property(str, notify=metadataChanged) - def restackedDate(self): - if not self.app_controller.image_files: - return "" - return self.app_controller.get_current_metadata().get("restacked_date", "") - - # --- RAW / True Headroom Support --- - - @Property(bool, notify=metadataChanged) - def hasRaw(self): - if not self.app_controller.image_files or self.app_controller.current_index >= len(self.app_controller.image_files): - return False - return self.app_controller.image_files[self.app_controller.current_index].has_raw - - @Property(bool, notify=metadataChanged) - def hasWorkingTif(self): - if not self.app_controller.image_files or self.app_controller.current_index >= len(self.app_controller.image_files): - return False - return self.app_controller.image_files[self.app_controller.current_index].has_working_tif - - @Slot() - def enableRawEditing(self): - """Switches to RAW editing mode.""" - if hasattr(self.app_controller, 'enable_raw_editing'): - self.app_controller.enable_raw_editing() - - @Property(bool, notify=editSourceModeChanged) - def isRawActive(self): - """Returns True if the editor is in RAW source mode.""" - if hasattr(self.app_controller, 'current_edit_source_mode'): - return self.app_controller.current_edit_source_mode == "raw" - return False - - @Slot(result=bool) - def load_image_for_editing(self): - """Loads the currently viewed image into the editor.""" - return self.app_controller.load_image_for_editing() - - @Slot() - def developRaw(self): - # Legacy support - self.app_controller.develop_raw_for_current_image() - - - @Property(str, notify=stackSummaryChanged) - def stackSummary(self): - if not self.app_controller.stacks: - return "No stacks defined." - summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" - for i, (start, end) in enumerate(self.app_controller.stacks): - count = end - start + 1 - summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" - return summary - - @Property(str, notify=saveBehaviorMessageChanged) - def saveBehaviorMessage(self): - """Returns a string describing what files will be affected by saving.""" - if not hasattr(self.app_controller, 'current_edit_source_mode'): - return "" - - if self.app_controller.current_edit_source_mode == "raw": - return "Editing: RAW (writes working .tif + creates -developed.jpg; original JPG untouched)" - else: - return "Editing: JPEG (will overwrite JPG)" - - @Property(str, notify=statusMessageChanged) - def statusMessage(self): - return self._status_message - - @statusMessage.setter - def statusMessage(self, value: str): - if self._status_message != value: - self._status_message = value - self.statusMessageChanged.emit() - - @Property(str, notify=filterStringChanged) - def filterString(self): - """Returns the current filter string (empty if no filter active).""" - return self.app_controller.get_filter_string() - - @Property(str, notify=colorModeChanged) - def colorMode(self): - """Returns the current color mode.""" - return self.app_controller.get_color_mode() - - @Property(float, notify=saturationFactorChanged) - def saturationFactor(self): - """Returns the current saturation factor.""" - return self.app_controller.get_saturation_factor() - - @Property(str, notify=awbModeChanged) - def awbMode(self): - return self.app_controller.get_awb_mode() - - @awbMode.setter - def awbMode(self, mode: str): - self.app_controller.set_awb_mode(mode) - self.awbModeChanged.emit() - - @Property(float, notify=awbStrengthChanged) - def awbStrength(self): - return self.app_controller.get_awb_strength() - - @awbStrength.setter - def awbStrength(self, value: float): - self.app_controller.set_awb_strength(value) - self.awbStrengthChanged.emit() - - @Property(int, notify=awbWarmBiasChanged) - def awbWarmBias(self): - return self.app_controller.get_awb_warm_bias() - - @awbWarmBias.setter - def awbWarmBias(self, value: int): - self.app_controller.set_awb_warm_bias(value) - self.awbWarmBiasChanged.emit() - - @Property(int, notify=awbTintBiasChanged) - def awbTintBias(self): - return self.app_controller.get_awb_tint_bias() - - @awbTintBias.setter - def awbTintBias(self, value: int): - self.app_controller.set_awb_tint_bias(value) - self.awbTintBiasChanged.emit() - - @Property(int, notify=awbLumaLowerBoundChanged) - def awbLumaLowerBound(self): - return self.app_controller.get_awb_luma_lower_bound() - - @awbLumaLowerBound.setter - def awbLumaLowerBound(self, value: int): - self.app_controller.set_awb_luma_lower_bound(value) - self.awbLumaLowerBoundChanged.emit() - - @Property(int, notify=awbLumaUpperBoundChanged) - def awbLumaUpperBound(self): - return self.app_controller.get_awb_luma_upper_bound() - - @awbLumaUpperBound.setter - def awbLumaUpperBound(self, value: int): - self.app_controller.set_awb_luma_upper_bound(value) - self.awbLumaUpperBoundChanged.emit() - - @Property(int, notify=awbRgbLowerBoundChanged) - def awbRgbLowerBound(self): - return self.app_controller.get_awb_rgb_lower_bound() - - @awbRgbLowerBound.setter - def awbRgbLowerBound(self, value: int): - self.app_controller.set_awb_rgb_lower_bound(value) - self.awbRgbLowerBoundChanged.emit() - - @Property(int, notify=awbRgbUpperBoundChanged) - def awbRgbUpperBound(self): - return self.app_controller.get_awb_rgb_upper_bound() - - @awbRgbUpperBound.setter - def awbRgbUpperBound(self, value: int): - self.app_controller.set_awb_rgb_upper_bound(value) - self.awbRgbUpperBoundChanged.emit() - - @Property(str, constant=True) - def currentDirectory(self): - """Returns the path of the current working directory.""" - return str(self.app_controller.image_dir) - - @Property(bool, notify=metadataChanged) - def isStackedJpg(self): - """Returns True if the current image is a stacked JPG.""" - return self.currentFilename.lower().endswith(" stacked.jpg") - - @Property(bool, constant=True) - def hasOpenCV(self): - """Returns True if OpenCV is available.""" - return HAS_OPENCV - - # --- Slots for QML to call --- - @Slot() - def nextImage(self): - self.app_controller.next_image() - - @Slot() - def prevImage(self): - self.app_controller.prev_image() - - - @Slot() - def launch_helicon(self): - self.app_controller.launch_helicon() - - @Slot() - def clear_all_stacks(self): - self.app_controller.clear_all_stacks() - - @Slot() - def clear_all_batches(self): - self.app_controller.clear_all_batches() - - @Slot(result=str) - def get_helicon_path(self): - return self.app_controller.get_helicon_path() - - @Slot(str) - def set_helicon_path(self, path): - self.app_controller.set_helicon_path(path) - - @Slot(result=str) - def get_photoshop_path(self): - return self.app_controller.get_photoshop_path() - - @Slot(str) - def set_photoshop_path(self, path): - self.app_controller.set_photoshop_path(path) - - @Slot(result=str) - def get_rawtherapee_path(self): - return self.app_controller.get_rawtherapee_path() - - @Slot(str) - def set_rawtherapee_path(self, path): - self.app_controller.set_rawtherapee_path(path) - - @Slot(result=str) - def open_file_dialog(self): - return self.app_controller.open_file_dialog() - - @Slot(str, result=bool) - def check_path_exists(self, path): - return self.app_controller.check_path_exists(path) - - @Slot(result=float) - def get_cache_size(self): - return self.app_controller.get_cache_size() - - @Slot(result=float) - def get_cache_usage_gb(self): - return self.app_controller.get_cache_usage_gb() - - @Slot(float) - def set_cache_size(self, size): - self.app_controller.set_cache_size(size) - - @Slot(result=int) - def get_prefetch_radius(self): - return self.app_controller.get_prefetch_radius() - - @Slot(int) - def set_prefetch_radius(self, radius): - self.app_controller.set_prefetch_radius(radius) - - @Slot(result=int) - def get_theme(self): - # this lets QML ask the controller, but the real binding is uiState.theme - return self.app_controller.get_theme() - - @Slot(int) - def set_theme(self, theme_index): - # delegate to controller so it can save to config - self.app_controller.set_theme(theme_index) - - @Slot(result=str) - def get_default_directory(self): - return self.app_controller.get_default_directory() - - @Slot(str) - def set_default_directory(self, path): - self.app_controller.set_default_directory(path) - - @Slot(result=str) - def get_optimize_for(self): - return self.app_controller.get_optimize_for() - - @Slot(str) - def set_optimize_for(self, optimize_for): - self.app_controller.set_optimize_for(optimize_for) - - @Slot(result=str) - def open_directory_dialog(self): - return self.app_controller.open_directory_dialog() - - @Property(float, notify=autoLevelClippingThresholdChanged) - def autoLevelClippingThreshold(self): - return self.app_controller.get_auto_level_clipping_threshold() - - @autoLevelClippingThreshold.setter - def autoLevelClippingThreshold(self, value): - self.app_controller.set_auto_level_clipping_threshold(value) - self.autoLevelClippingThresholdChanged.emit(value) - - @Property(float, notify=autoLevelStrengthChanged) - def autoLevelStrength(self): - return self.app_controller.get_auto_level_strength() - - @autoLevelStrength.setter - def autoLevelStrength(self, value): - self.app_controller.set_auto_level_strength(value) - self.autoLevelStrengthChanged.emit(value) - - @Property(bool, notify=autoLevelStrengthAutoChanged) - def autoLevelStrengthAuto(self): - return self.app_controller.get_auto_level_strength_auto() - - @autoLevelStrengthAuto.setter - def autoLevelStrengthAuto(self, value): - self.app_controller.set_auto_level_strength_auto(value) - self.autoLevelStrengthAutoChanged.emit(value) - - @Slot() - def open_folder(self): - self.app_controller.open_folder() - - - @Slot() - def preloadAllImages(self): - self.app_controller.preload_all_images() - - @Slot() - def stack_source_raws(self): - self.app_controller.stack_source_raws() - - @Slot(str) - def applyFilter(self, filter_string: str): - """Applies a filter string to the image list.""" - self.app_controller.apply_filter(filter_string) - - @Slot(int, int) - def onDisplaySizeChanged(self, width: int, height: int): - self.app_controller.on_display_size_changed(width, height) - - @Slot() - def resetZoomPan(self): - """Triggers a reset of zoom and pan in QML.""" - self.resetZoomPanRequested.emit() - - # --- Image Editor Properties --- - - @Property(bool, notify=is_editor_open_changed) - def isEditorOpen(self) -> bool: - return self._is_editor_open - - @isEditorOpen.setter - def isEditorOpen(self, new_value: bool): - if self._is_editor_open != new_value: - self._is_editor_open = new_value - self.is_editor_open_changed.emit(new_value) - - @Property(str, notify=editorImageChanged) - def editorFilename(self) -> str: - """Returns the filename of the image currently being edited (may be .tif for developed RAW).""" - editor = self.app_controller.image_editor - fp = getattr(editor, "current_filepath", None) if editor else None - if not fp: - return "" - try: - return Path(fp).name - except Exception: - return "" - - @Property(int, notify=editorImageChanged) - def editorBitDepth(self) -> int: - """Returns the bit depth (8 or 16) of the image currently being edited.""" - editor = self.app_controller.image_editor - if editor: - return editor.bit_depth - return 8 - - @Property(bool, notify=isDialogOpenChanged) - def isDialogOpen(self) -> bool: - return self._is_dialog_open - - @isDialogOpen.setter - def isDialogOpen(self, new_value: bool): - if self._is_dialog_open != new_value: - self._is_dialog_open = new_value - self.isDialogOpenChanged.emit(new_value) - - @Property(bool, notify=anySliderPressedChanged) - def anySliderPressed(self): - return self._any_slider_pressed - - @anySliderPressed.setter - def anySliderPressed(self, value): - if self._any_slider_pressed != value: - self._any_slider_pressed = value - self.anySliderPressedChanged.emit(value) - - @Slot(bool) - def setAnySliderPressed(self, pressed: bool): - self.anySliderPressed = pressed - - @Property(bool, notify=is_cropping_changed) - def isCropping(self) -> bool: - return self._is_cropping - - @isCropping.setter - def isCropping(self, new_value: bool): - if self._is_cropping != new_value: - self._is_cropping = new_value - self.is_cropping_changed.emit(new_value) - - @Property(bool, notify=is_histogram_visible_changed) - def isHistogramVisible(self) -> bool: - return self._is_histogram_visible - - @isHistogramVisible.setter - def isHistogramVisible(self, new_value: bool): - if self._is_histogram_visible != new_value: - self._is_histogram_visible = new_value - self.is_histogram_visible_changed.emit(new_value) - if new_value: - # Update histogram when opened - try: - self.app_controller.update_histogram() - except Exception as e: - log.warning(f"Failed to update histogram: {e}") - - @Slot() - def reset_editor_state(self): - """Resets all editor-related properties to their default values.""" - self.brightness = 0.0 - self.contrast = 0.0 - self.saturation = 0.0 - self.white_balance_by = 0.0 - self.white_balance_mg = 0.0 - self.sharpness = 0.0 - self.rotation = 0 - self.exposure = 0.0 - self.highlights = 0.0 - self.shadows = 0.0 - self.vibrance = 0.0 - self.vignette = 0.0 - self.blacks = 0.0 - self.whites = 0.0 - self.clarity = 0.0 - self.texture = 0.0 - self.cropRotation = 0.0 - self.currentCropBox = (0, 0, 1000, 1000) - self.currentAspectRatioIndex = 0 - - @Property('QVariant', notify=histogram_data_changed) - def histogramData(self): - """Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values.""" - return self._histogram_data - - @histogramData.setter - def histogramData(self, new_value): - if self._histogram_data != new_value: - self._histogram_data = new_value - self.histogram_data_changed.emit() - - @Property('QVariant', notify=highlightStateChanged) - def highlightState(self): - """Returns highlight analysis state for UI display. - - Returns dict with: - - headroom_pct: Fraction of pixels with recoverable data above 1.0 (16-bit sources) - - clipped_pct: Fraction of pixels clipped in the SOURCE image (JPEG flat-top @ 254+) - - near_white_pct: Fraction of pixels currently near white in the processed state. - """ - editor = self.app_controller.image_editor - state = {} - if editor and hasattr(editor, '_last_highlight_state') and editor._last_highlight_state: - # Quick copy under lock to minimize contention - # Using the editor's lock ensures we don't read while it's being written - with editor._lock: - state = dict(editor._last_highlight_state) - - # Normalize for QML robustness: ensure stable keys exist regardless of internal naming - # Normalize for QML robustness: ensure stable keys exist - return { - 'headroom_pct': state.get('headroom_pct', 0.0), - 'source_clipped_pct': state.get('source_clipped_pct', 0.0), - 'current_nearwhite_pct': state.get('current_nearwhite_pct', 0.0) - } - - @Property(float, notify=brightness_changed) - def brightness(self) -> float: - return self._brightness - - @brightness.setter - def brightness(self, new_value: float): - if self._brightness != new_value: - self._brightness = new_value - self.brightness_changed.emit(new_value) - - @Property(float, notify=contrast_changed) - def contrast(self) -> float: - return self._contrast - - @contrast.setter - def contrast(self, new_value: float): - if self._contrast != new_value: - self._contrast = new_value - self.contrast_changed.emit(new_value) - - @Property(float, notify=saturation_changed) - def saturation(self) -> float: - return self._saturation - - @saturation.setter - def saturation(self, new_value: float): - if self._saturation != new_value: - self._saturation = new_value - self.saturation_changed.emit(new_value) - - @Property(float, notify=white_balance_by_changed) - def whiteBalanceBY(self) -> float: - return self._white_balance_by - - @whiteBalanceBY.setter - def whiteBalanceBY(self, new_value: float): - if self._white_balance_by != new_value: - self._white_balance_by = new_value - self.white_balance_by_changed.emit(new_value) - - @Property(float, notify=white_balance_mg_changed) - def whiteBalanceMG(self) -> float: - return self._white_balance_mg - - @whiteBalanceMG.setter - def whiteBalanceMG(self, new_value: float): - if self._white_balance_mg != new_value: - self._white_balance_mg = new_value - self.white_balance_mg_changed.emit(new_value) - - # Snake_case aliases for QML bracket notation access - @Property(float, notify=white_balance_by_changed) - def white_balance_by(self) -> float: - return self._white_balance_by - - @white_balance_by.setter - def white_balance_by(self, new_value: float): - self.whiteBalanceBY = new_value - - @Property(float, notify=white_balance_mg_changed) - def white_balance_mg(self) -> float: - return self._white_balance_mg - - @white_balance_mg.setter - def white_balance_mg(self, new_value: float): - self.whiteBalanceMG = new_value - - @Property('QVariantList', notify=aspect_ratio_names_changed) - def aspectRatioNames(self) -> list: - return self._aspect_ratio_names - - @aspectRatioNames.setter - def aspectRatioNames(self, new_value: list): - if self._aspect_ratio_names != new_value: - self._aspect_ratio_names = new_value - self.aspect_ratio_names_changed.emit(new_value) - - @Property(int, notify=current_aspect_ratio_index_changed) - def currentAspectRatioIndex(self) -> int: - return self._current_aspect_ratio_index - - @currentAspectRatioIndex.setter - def currentAspectRatioIndex(self, new_value: int): - if self._current_aspect_ratio_index != new_value: - self._current_aspect_ratio_index = new_value - self.current_aspect_ratio_index_changed.emit(new_value) - - @Property('QVariant', notify=current_crop_box_changed) - def currentCropBox(self) -> tuple: - # QML will receive this as a list - return self._current_crop_box - - @currentCropBox.setter - def currentCropBox(self, new_value): - # Convert QJSValue or list to tuple if needed - original_value = new_value - try: - if hasattr(new_value, 'toVariant'): - # It's a QJSValue, convert to tuple - variant = new_value.toVariant() - if isinstance(variant, (list, tuple)): - new_value = tuple(variant) - else: - # Try to access elements directly - new_value = (variant[0], variant[1], variant[2], variant[3]) - elif isinstance(new_value, list): - new_value = tuple(new_value) - elif not isinstance(new_value, tuple): - # Try to convert to tuple - new_value = tuple(new_value) - except (TypeError, IndexError, AttributeError) as e: - log.warning( - "UIState.currentCropBox: failed to normalize value %r (type %s): %s", - original_value, - type(original_value), - e, - ) - - # only accept 4-element tuples - if ( - not isinstance(new_value, tuple) - or len(new_value) != 4 - or not all(isinstance(v, (int, float)) for v in new_value) - ): - log.warning("UIState.currentCropBox: ignoring invalid crop box %r", new_value) - return - if self._current_crop_box != new_value: - self._current_crop_box = new_value - self.current_crop_box_changed.emit(new_value) - # Sync with ImageEditor - if hasattr(self.app_controller, 'image_editor') and self.app_controller.image_editor: - self.app_controller.image_editor.set_crop_box(new_value) - - @Property(float, notify=crop_rotation_changed) - def cropRotation(self) -> float: - return self._crop_rotation - - @cropRotation.setter - def cropRotation(self, new_value: float): - if self._crop_rotation != new_value: - self._crop_rotation = new_value - self.crop_rotation_changed.emit(new_value) - - # --- New Properties --- - @Property(float, notify=sharpness_changed) - def sharpness(self) -> float: - return self._sharpness - - @sharpness.setter - def sharpness(self, new_value: float): - if self._sharpness != new_value: - self._sharpness = new_value - self.sharpness_changed.emit(new_value) - - @Property(int, notify=rotation_changed) - def rotation(self) -> int: - return self._rotation - - @rotation.setter - def rotation(self, new_value: int): - if self._rotation != new_value: - self._rotation = new_value - self.rotation_changed.emit(new_value) - - @Property(float, notify=exposure_changed) - def exposure(self) -> float: - return self._exposure - - @exposure.setter - def exposure(self, new_value: float): - if self._exposure != new_value: - self._exposure = new_value - self.exposure_changed.emit(new_value) - - @Property(float, notify=highlights_changed) - def highlights(self) -> float: - return self._highlights - - @highlights.setter - def highlights(self, new_value: float): - if self._highlights != new_value: - self._highlights = new_value - self.highlights_changed.emit(new_value) - - @Property(float, notify=shadows_changed) - def shadows(self) -> float: - return self._shadows - - @shadows.setter - def shadows(self, new_value: float): - if self._shadows != new_value: - self._shadows = new_value - self.shadows_changed.emit(new_value) - - @Property(float, notify=vibrance_changed) - def vibrance(self) -> float: - return self._vibrance - - @vibrance.setter - def vibrance(self, new_value: float): - if self._vibrance != new_value: - self._vibrance = new_value - self.vibrance_changed.emit(new_value) - - @Property(float, notify=vignette_changed) - def vignette(self) -> float: - return self._vignette - - @vignette.setter - def vignette(self, new_value: float): - if self._vignette != new_value: - self._vignette = new_value - self.vignette_changed.emit(new_value) - - @Property(float, notify=blacks_changed) - def blacks(self) -> float: - return self._blacks - - @blacks.setter - def blacks(self, new_value: float): - if self._blacks != new_value: - self._blacks = new_value - self.blacks_changed.emit(new_value) - - @Property(float, notify=whites_changed) - def whites(self) -> float: - return self._whites - - @whites.setter - def whites(self, new_value: float): - if self._whites != new_value: - self._whites = new_value - self.whites_changed.emit(new_value) - - @Property(float, notify=clarity_changed) - def clarity(self) -> float: - return self._clarity - - @clarity.setter - def clarity(self, new_value: float): - if self._clarity != new_value: - self._clarity = new_value - self.clarity_changed.emit(new_value) - - @Property(float, notify=texture_changed) - def texture(self) -> float: - return self._texture - - @texture.setter - def texture(self, new_value: float): - if self._texture != new_value: - self._texture = new_value - self.texture_changed.emit(new_value) - - # --- Debug Cache Properties --- - - @Property(bool, notify=debugCacheChanged) - def debugCache(self) -> bool: - return self._debug_cache - - @debugCache.setter - def debugCache(self, value: bool): - if self._debug_cache != value: - self._debug_cache = value - self.debugCacheChanged.emit(value) - - @Property(str, notify=cacheStatsChanged) - def cacheStats(self) -> str: - return self._cache_stats - - @cacheStats.setter - def cacheStats(self, value: str): - if self._cache_stats != value: - self._cache_stats = value - self.cacheStatsChanged.emit(value) - - @Property(bool, notify=isDecodingChanged) - def isDecoding(self) -> bool: - return self._is_decoding - - @isDecoding.setter - def isDecoding(self, value: bool): - if self._is_decoding != value: - self._is_decoding = value - self.isDecodingChanged.emit(value) - - @Property(bool, notify=debugModeChanged) - def debugMode(self) -> bool: - return self._debug_mode - - @debugMode.setter - def debugMode(self, value: bool): - if self._debug_mode != value: - self._debug_mode = value - self.debugModeChanged.emit(value) - - # --- RAW / Editor Source Logic --- - - +"""QML Image Provider and application state bridge.""" + +import logging +import collections +from PySide6.QtCore import QObject, Signal, Property, Slot, Qt +from PySide6.QtGui import QImage +from PySide6.QtQuick import QQuickImageProvider + +from faststack.models import DecodedImage +from faststack.config import config +from faststack.imaging.optional_deps import HAS_OPENCV +from pathlib import Path + +# 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__) + + +class ImageProvider(QQuickImageProvider): + def __init__(self, app_controller): + super().__init__(QQuickImageProvider.ImageType.Image) + self.app_controller = app_controller + self.placeholder = QImage(256, 256, QImage.Format.Format_RGB888) + self.placeholder.fill(Qt.GlobalColor.darkGray) + # Keepalive queue to prevent GC of buffers currently in use by QImage + # Increased to 128 to prevent crashes during rapid scrolling/thrashing where + # QML might hold onto textures slightly longer than the Python GC expects. + self._keepalive = collections.deque(maxlen=128) + + def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: + """Handles image requests from QML.""" + if not id: + return self.placeholder + + try: + # Parse index and optional generation + parts = id.split('/') + index = int(parts[0]) + gen = int(parts[1]) if len(parts) > 1 else None + + # If editor is open, use the background-rendered preview buffer + # BUT only if the requested index matches the currently edited index! + # AND the generation matches (to avoid stale frames during rotation/param changes) + # FIX: If zoomed in, force full-res image instead of low-res preview + + use_editor_preview = ( + self.app_controller.ui_state.isEditorOpen + and index == self.app_controller.current_index + and not self.app_controller.ui_state.isZoomed + and self.app_controller._last_rendered_preview is not None + and getattr(self.app_controller, "_last_rendered_preview_index", None) == index + and (gen is None or getattr(self.app_controller, "_last_rendered_preview_gen", None) == gen) + ) + + image_data = ( + self.app_controller._last_rendered_preview + if use_editor_preview + else self.app_controller.get_decoded_image(index) + ) + + if image_data: + # Handle format being None (from prefetcher) or missing + fmt = getattr(image_data, 'format', None) + if fmt is None: + fmt = QImage.Format.Format_RGB888 + + qimg = QImage( + image_data.buffer, + image_data.width, + image_data.height, + image_data.bytes_per_line, + fmt + ) + + + # Detach from Python buffer to prevent ownership issues and force proper texture upload + # OPTIMIZATION: Only do this expensive copy when serving the live editor preview, + # where we need to detach from the shared memory buffer that might change. + # For standard browsing/prefetch, the buffer is stable enough. + if self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index: + qimg = qimg.copy() + else: + # SAFETY: Keep a reference to the underlying buffer to prevent garbage collection + # while Qt holds the QImage. QImage created from bytes does NOT own the data. + self._keepalive.append(image_data.buffer) + + # Set sRGB color space for proper color management (if available) + # Skip this when using ICC mode - pixels are already in monitor space + color_mode = config.get('color', 'mode', fallback="none").lower() + if HAS_COLOR_SPACE and color_mode != "icc": + try: + # Create sRGB color space using constructor with NamedColorSpace enum + cs = QColorSpace(QColorSpace.NamedColorSpace.SRgb) + qimg.setColorSpace(cs) + log.debug("Applied sRGB color space to image") + except (RuntimeError, ValueError) as e: + log.warning(f"Failed to set color space: {e}") + elif color_mode == "icc": + log.debug("ICC mode: skipping Qt color space (pixels already in monitor space)") + + # Buffer is now safe to release (handled by copy), but original_buffer ref in Python object stays + # We don't need to manually attach original_buffer to qimg anymore since we copied. + return qimg + + except (ValueError, IndexError) as e: + log.error(f"Invalid image ID requested from QML: {id}. Error: {e}") + + return self.placeholder + + +class UIState(QObject): + """Manages the state exposed to the QML user interface.""" + + # Signals + currentIndexChanged = Signal() + imageCountChanged = Signal() + currentImageSourceChanged = Signal() + metadataChanged = Signal() + themeChanged = Signal() + preloadingStateChanged = Signal() + preloadProgressChanged = Signal() + isZoomedChanged = Signal() + statusMessageChanged = Signal() # New signal for status messages + resetZoomPanRequested = Signal() # Signal to tell QML to reset zoom/pan + absoluteZoomRequested = Signal(float) # New: Request absolute zoom level (1.0, 2.0, etc.) + stackSummaryChanged = Signal() # Signal for stack summary updates + filterStringChanged = Signal() # Signal for filter string updates + colorModeChanged = Signal() # Signal for color mode updates + saturationFactorChanged = Signal() # Signal for saturation factor updates + awbModeChanged = Signal() + awbStrengthChanged = Signal() + awbWarmBiasChanged = Signal() + awbTintBiasChanged = Signal() + awbLumaLowerBoundChanged = Signal() + awbLumaUpperBoundChanged = Signal() + awbRgbLowerBoundChanged = Signal() + awbRgbUpperBoundChanged = Signal() + default_directory_changed = Signal(str) + isStackedJpgChanged = Signal() # New signal for isStackedJpg + autoLevelClippingThresholdChanged = Signal(float) + autoLevelStrengthChanged = Signal(float) + autoLevelStrengthAutoChanged = Signal(bool) + # Image Editor Signals + is_editor_open_changed = Signal(bool) + editorImageChanged = Signal() # New signal for when the image loaded in editor changes + is_cropping_changed = Signal(bool) + + is_histogram_visible_changed = Signal(bool) + histogram_data_changed = Signal() + highlightStateChanged = Signal() # New signal for highlight analysis updates + brightness_changed = Signal(float) + contrast_changed = Signal(float) + saturation_changed = Signal(float) + white_balance_by_changed = Signal(float) + white_balance_mg_changed = Signal(float) + aspect_ratio_names_changed = Signal(list) + current_aspect_ratio_index_changed = Signal(int) + current_crop_box_changed = Signal(tuple) # (left, top, right, bottom) normalized to 0-1000 + crop_rotation_changed = Signal(float) + anySliderPressedChanged = Signal(bool) + sharpness_changed = Signal(float) + rotation_changed = Signal(int) + exposure_changed = Signal(float) + highlights_changed = Signal(float) + shadows_changed = Signal(float) + vibrance_changed = Signal(float) + vignette_changed = Signal(float) + blacks_changed = Signal(float) + whites_changed = Signal(float) + clarity_changed = Signal(float) + texture_changed = Signal(float) + + # Debug Cache Signals + debugCacheChanged = Signal(bool) + cacheStatsChanged = Signal(str) + isDecodingChanged = Signal(bool) + debugModeChanged = Signal(bool) # General debug mode signal + isDialogOpenChanged = Signal(bool) # New signal for dialog state + editSourceModeChanged = Signal(str) # Notify when JPEG/RAW mode changes + saveBehaviorMessageChanged = Signal() # Signal for save behavior message updates + + def __init__(self, app_controller): + super().__init__() + self.app_controller = app_controller + self._is_preloading = False + self._preload_progress = 0 + # 1 = light, 0 = dark (controller will overwrite this on startup) + self._theme = 1 + self._status_message = "" # New private variable for status message + # Image Editor State + self._is_editor_open = False + self._is_cropping = False + self._is_histogram_visible = False + self._histogram_data = {} # Will be a dict with 'r', 'g', 'b' arrays + self._brightness = 0.0 + self._contrast = 0.0 + self._saturation = 0.0 + self._white_balance_by = 0.0 + self._white_balance_mg = 0.0 + self._current_crop_box = [0, 0, 1000, 1000] + self._crop_rotation = 0.0 + self._debug_mode = False + self._aspect_ratio_names = [ + "Freeform", + "1:1 (Square)", + "4:5 (Portrait)", + "1.91:1 (Landscape)", + "16:9 (Wide)", + "9:16 (Story)" + ] + self._current_aspect_ratio_index = 0 + self._any_slider_pressed = False + self._sharpness = 0.0 + self._rotation = 0 + self._exposure = 0.0 + self._highlights = 0.0 + self._shadows = 0.0 + self._vibrance = 0.0 + self._vignette = 0.0 + self._blacks = 0.0 + self._whites = 0.0 + self._clarity = 0.0 + self._texture = 0.0 + + # Debug Cache State + self._debug_cache = False + self._cache_stats = "" + self._is_decoding = False + self._is_dialog_open = False + + # Connect to controller's dialog state signal + self.app_controller.dialogStateChanged.connect(self._on_dialog_state_changed) + + # Connect to controller's mode change signal + # We need to ensure the signal exists on controller first (it does, I added it) + if hasattr(self.app_controller, 'editSourceModeChanged'): + self.app_controller.editSourceModeChanged.connect(self.editSourceModeChanged) + self.app_controller.editSourceModeChanged.connect(lambda _: self.saveBehaviorMessageChanged.emit()) + self.app_controller.editSourceModeChanged.connect(lambda _: self.metadataChanged.emit()) # Also update metadata binding if needed + + def _on_dialog_state_changed(self, is_open: bool): + self.isDialogOpen = is_open + + # ---- THEME PROPERTY ---- + @Property(int, notify=themeChanged) + def theme(self): + return self._theme + + @theme.setter + def theme(self, value: int): + value = int(value) + if value == self._theme: + return + self._theme = value + self.themeChanged.emit() + + # ---- ZOOM ---- + @Property(bool, notify=isZoomedChanged) + def isZoomed(self): + return self.app_controller.is_zoomed + + @Slot(bool) + def setZoomed(self, zoomed: bool): + self.app_controller.set_zoomed(zoomed) + + @Slot(float) + def request_absolute_zoom(self, scale): + """Request the UI to set zoom to an absolute scale (1.0 = 100%).""" + self.absoluteZoomRequested.emit(scale) + + # ---- PRELOADING ---- + @Property(bool, notify=preloadingStateChanged) + def isPreloading(self): + return self._is_preloading + + @isPreloading.setter + def isPreloading(self, value): + if self._is_preloading != value: + self._is_preloading = value + self.preloadingStateChanged.emit() + + @Property(int, notify=preloadProgressChanged) + def preloadProgress(self): + return self._preload_progress + + @preloadProgress.setter + def preloadProgress(self, value): + if self._preload_progress != value: + self._preload_progress = value + self.preloadProgressChanged.emit() + + # ---- IMAGE / METADATA ---- + @Property(int, notify=currentIndexChanged) + def currentIndex(self): + return self.app_controller.current_index + + @Property(int, notify=imageCountChanged) + def imageCount(self): + return len(self.app_controller.image_files) + + @Property(str, notify=currentImageSourceChanged) + def currentImageSource(self): + return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" + + @Property(str, notify=metadataChanged) + def currentFilename(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("filename", "") + + @Property(bool, notify=metadataChanged) + def isStacked(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("stacked", False) + + @Property(str, notify=metadataChanged) + def stackedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("stacked_date", "") + + @Property(str, notify=metadataChanged) + def stackInfoText(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("stack_info_text", "") + + @Property(bool, notify=metadataChanged) + def isUploaded(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("uploaded", False) + + @Property(str, notify=metadataChanged) + def uploadedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("uploaded_date", "") + + @Property(str, notify=metadataChanged) + def batchInfoText(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("batch_info_text", "") + + @Property(bool, notify=metadataChanged) + def isEdited(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("edited", False) + + @Property(str, notify=metadataChanged) + def editedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("edited_date", "") + + @Property(bool, notify=metadataChanged) + def isRestacked(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("restacked", False) + + @Property(str, notify=metadataChanged) + def restackedDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("restacked_date", "") + + # --- RAW / True Headroom Support --- + + @Property(bool, notify=metadataChanged) + def hasRaw(self): + if not self.app_controller.image_files or self.app_controller.current_index >= len(self.app_controller.image_files): + return False + return self.app_controller.image_files[self.app_controller.current_index].has_raw + + @Property(bool, notify=metadataChanged) + def hasWorkingTif(self): + if not self.app_controller.image_files or self.app_controller.current_index >= len(self.app_controller.image_files): + return False + return self.app_controller.image_files[self.app_controller.current_index].has_working_tif + + @Slot() + def enableRawEditing(self): + """Switches to RAW editing mode.""" + if hasattr(self.app_controller, 'enable_raw_editing'): + self.app_controller.enable_raw_editing() + + @Property(bool, notify=editSourceModeChanged) + def isRawActive(self): + """Returns True if the editor is in RAW source mode.""" + if hasattr(self.app_controller, 'current_edit_source_mode'): + return self.app_controller.current_edit_source_mode == "raw" + return False + + @Slot(result=bool) + def load_image_for_editing(self): + """Loads the currently viewed image into the editor.""" + return self.app_controller.load_image_for_editing() + + @Slot() + def developRaw(self): + # Legacy support + self.app_controller.develop_raw_for_current_image() + + + @Property(str, notify=stackSummaryChanged) + def stackSummary(self): + if not self.app_controller.stacks: + return "No stacks defined." + summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" + for i, (start, end) in enumerate(self.app_controller.stacks): + count = end - start + 1 + summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" + return summary + + @Property(str, notify=saveBehaviorMessageChanged) + def saveBehaviorMessage(self): + """Returns a string describing what files will be affected by saving.""" + if not hasattr(self.app_controller, 'current_edit_source_mode'): + return "" + + if self.app_controller.current_edit_source_mode == "raw": + return "Editing: RAW (writes working .tif + creates -developed.jpg; original JPG untouched)" + else: + return "Editing: JPEG (will overwrite JPG)" + + @Property(str, notify=statusMessageChanged) + def statusMessage(self): + return self._status_message + + @statusMessage.setter + def statusMessage(self, value: str): + if self._status_message != value: + self._status_message = value + self.statusMessageChanged.emit() + + @Property(str, notify=filterStringChanged) + def filterString(self): + """Returns the current filter string (empty if no filter active).""" + return self.app_controller.get_filter_string() + + @Property(str, notify=colorModeChanged) + def colorMode(self): + """Returns the current color mode.""" + return self.app_controller.get_color_mode() + + @Property(float, notify=saturationFactorChanged) + def saturationFactor(self): + """Returns the current saturation factor.""" + return self.app_controller.get_saturation_factor() + + @Property(str, notify=awbModeChanged) + def awbMode(self): + return self.app_controller.get_awb_mode() + + @awbMode.setter + def awbMode(self, mode: str): + self.app_controller.set_awb_mode(mode) + self.awbModeChanged.emit() + + @Property(float, notify=awbStrengthChanged) + def awbStrength(self): + return self.app_controller.get_awb_strength() + + @awbStrength.setter + def awbStrength(self, value: float): + self.app_controller.set_awb_strength(value) + self.awbStrengthChanged.emit() + + @Property(int, notify=awbWarmBiasChanged) + def awbWarmBias(self): + return self.app_controller.get_awb_warm_bias() + + @awbWarmBias.setter + def awbWarmBias(self, value: int): + self.app_controller.set_awb_warm_bias(value) + self.awbWarmBiasChanged.emit() + + @Property(int, notify=awbTintBiasChanged) + def awbTintBias(self): + return self.app_controller.get_awb_tint_bias() + + @awbTintBias.setter + def awbTintBias(self, value: int): + self.app_controller.set_awb_tint_bias(value) + self.awbTintBiasChanged.emit() + + @Property(int, notify=awbLumaLowerBoundChanged) + def awbLumaLowerBound(self): + return self.app_controller.get_awb_luma_lower_bound() + + @awbLumaLowerBound.setter + def awbLumaLowerBound(self, value: int): + self.app_controller.set_awb_luma_lower_bound(value) + self.awbLumaLowerBoundChanged.emit() + + @Property(int, notify=awbLumaUpperBoundChanged) + def awbLumaUpperBound(self): + return self.app_controller.get_awb_luma_upper_bound() + + @awbLumaUpperBound.setter + def awbLumaUpperBound(self, value: int): + self.app_controller.set_awb_luma_upper_bound(value) + self.awbLumaUpperBoundChanged.emit() + + @Property(int, notify=awbRgbLowerBoundChanged) + def awbRgbLowerBound(self): + return self.app_controller.get_awb_rgb_lower_bound() + + @awbRgbLowerBound.setter + def awbRgbLowerBound(self, value: int): + self.app_controller.set_awb_rgb_lower_bound(value) + self.awbRgbLowerBoundChanged.emit() + + @Property(int, notify=awbRgbUpperBoundChanged) + def awbRgbUpperBound(self): + return self.app_controller.get_awb_rgb_upper_bound() + + @awbRgbUpperBound.setter + def awbRgbUpperBound(self, value: int): + self.app_controller.set_awb_rgb_upper_bound(value) + self.awbRgbUpperBoundChanged.emit() + + @Property(str, constant=True) + def currentDirectory(self): + """Returns the path of the current working directory.""" + return str(self.app_controller.image_dir) + + @Property(bool, notify=metadataChanged) + def isStackedJpg(self): + """Returns True if the current image is a stacked JPG.""" + return self.currentFilename.lower().endswith(" stacked.jpg") + + @Property(bool, constant=True) + def hasOpenCV(self): + """Returns True if OpenCV is available.""" + return HAS_OPENCV + + # --- Slots for QML to call --- + @Slot() + def nextImage(self): + self.app_controller.next_image() + + @Slot() + def prevImage(self): + self.app_controller.prev_image() + + + @Slot() + def launch_helicon(self): + self.app_controller.launch_helicon() + + @Slot() + def clear_all_stacks(self): + self.app_controller.clear_all_stacks() + + @Slot() + def clear_all_batches(self): + self.app_controller.clear_all_batches() + + @Slot(result=str) + def get_helicon_path(self): + return self.app_controller.get_helicon_path() + + @Slot(str) + def set_helicon_path(self, path): + self.app_controller.set_helicon_path(path) + + @Slot(result=str) + def get_photoshop_path(self): + return self.app_controller.get_photoshop_path() + + @Slot(str) + def set_photoshop_path(self, path): + self.app_controller.set_photoshop_path(path) + + @Slot(result=str) + def get_rawtherapee_path(self): + return self.app_controller.get_rawtherapee_path() + + @Slot(str) + def set_rawtherapee_path(self, path): + self.app_controller.set_rawtherapee_path(path) + + @Slot(result=str) + def open_file_dialog(self): + return self.app_controller.open_file_dialog() + + @Slot(str, result=bool) + def check_path_exists(self, path): + return self.app_controller.check_path_exists(path) + + @Slot(result=float) + def get_cache_size(self): + return self.app_controller.get_cache_size() + + @Slot(result=float) + def get_cache_usage_gb(self): + return self.app_controller.get_cache_usage_gb() + + @Slot(float) + def set_cache_size(self, size): + self.app_controller.set_cache_size(size) + + @Slot(result=int) + def get_prefetch_radius(self): + return self.app_controller.get_prefetch_radius() + + @Slot(int) + def set_prefetch_radius(self, radius): + self.app_controller.set_prefetch_radius(radius) + + @Slot(result=int) + def get_theme(self): + # this lets QML ask the controller, but the real binding is uiState.theme + return self.app_controller.get_theme() + + @Slot(int) + def set_theme(self, theme_index): + # delegate to controller so it can save to config + self.app_controller.set_theme(theme_index) + + @Slot(result=str) + def get_default_directory(self): + return self.app_controller.get_default_directory() + + @Slot(str) + def set_default_directory(self, path): + self.app_controller.set_default_directory(path) + + @Slot(result=str) + def get_optimize_for(self): + return self.app_controller.get_optimize_for() + + @Slot(str) + def set_optimize_for(self, optimize_for): + self.app_controller.set_optimize_for(optimize_for) + + @Slot(result=str) + def open_directory_dialog(self): + return self.app_controller.open_directory_dialog() + + @Property(float, notify=autoLevelClippingThresholdChanged) + def autoLevelClippingThreshold(self): + return self.app_controller.get_auto_level_clipping_threshold() + + @autoLevelClippingThreshold.setter + def autoLevelClippingThreshold(self, value): + self.app_controller.set_auto_level_clipping_threshold(value) + self.autoLevelClippingThresholdChanged.emit(value) + + @Property(float, notify=autoLevelStrengthChanged) + def autoLevelStrength(self): + return self.app_controller.get_auto_level_strength() + + @autoLevelStrength.setter + def autoLevelStrength(self, value): + self.app_controller.set_auto_level_strength(value) + self.autoLevelStrengthChanged.emit(value) + + @Property(bool, notify=autoLevelStrengthAutoChanged) + def autoLevelStrengthAuto(self): + return self.app_controller.get_auto_level_strength_auto() + + @autoLevelStrengthAuto.setter + def autoLevelStrengthAuto(self, value): + self.app_controller.set_auto_level_strength_auto(value) + self.autoLevelStrengthAutoChanged.emit(value) + + @Slot() + def open_folder(self): + self.app_controller.open_folder() + + + @Slot() + def preloadAllImages(self): + self.app_controller.preload_all_images() + + @Slot() + def stack_source_raws(self): + self.app_controller.stack_source_raws() + + @Slot(str) + def applyFilter(self, filter_string: str): + """Applies a filter string to the image list.""" + self.app_controller.apply_filter(filter_string) + + @Slot(int, int) + def onDisplaySizeChanged(self, width: int, height: int): + self.app_controller.on_display_size_changed(width, height) + + @Slot() + def resetZoomPan(self): + """Triggers a reset of zoom and pan in QML.""" + self.resetZoomPanRequested.emit() + + # --- Image Editor Properties --- + + @Property(bool, notify=is_editor_open_changed) + def isEditorOpen(self) -> bool: + return self._is_editor_open + + @isEditorOpen.setter + def isEditorOpen(self, new_value: bool): + if self._is_editor_open != new_value: + self._is_editor_open = new_value + self.is_editor_open_changed.emit(new_value) + + @Property(str, notify=editorImageChanged) + def editorFilename(self) -> str: + """Returns the filename of the image currently being edited (may be .tif for developed RAW).""" + editor = self.app_controller.image_editor + fp = getattr(editor, "current_filepath", None) if editor else None + if not fp: + return "" + try: + return Path(fp).name + except Exception: + return "" + + @Property(int, notify=editorImageChanged) + def editorBitDepth(self) -> int: + """Returns the bit depth (8 or 16) of the image currently being edited.""" + editor = self.app_controller.image_editor + if editor: + return editor.bit_depth + return 8 + + @Property(bool, notify=isDialogOpenChanged) + def isDialogOpen(self) -> bool: + return self._is_dialog_open + + @isDialogOpen.setter + def isDialogOpen(self, new_value: bool): + if self._is_dialog_open != new_value: + self._is_dialog_open = new_value + self.isDialogOpenChanged.emit(new_value) + + @Property(bool, notify=anySliderPressedChanged) + def anySliderPressed(self): + return self._any_slider_pressed + + @anySliderPressed.setter + def anySliderPressed(self, value): + if self._any_slider_pressed != value: + self._any_slider_pressed = value + self.anySliderPressedChanged.emit(value) + + @Slot(bool) + def setAnySliderPressed(self, pressed: bool): + self.anySliderPressed = pressed + + @Property(bool, notify=is_cropping_changed) + def isCropping(self) -> bool: + return self._is_cropping + + @isCropping.setter + def isCropping(self, new_value: bool): + if self._is_cropping != new_value: + self._is_cropping = new_value + self.is_cropping_changed.emit(new_value) + + @Property(bool, notify=is_histogram_visible_changed) + def isHistogramVisible(self) -> bool: + return self._is_histogram_visible + + @isHistogramVisible.setter + def isHistogramVisible(self, new_value: bool): + if self._is_histogram_visible != new_value: + self._is_histogram_visible = new_value + self.is_histogram_visible_changed.emit(new_value) + if new_value: + # Update histogram when opened + try: + self.app_controller.update_histogram() + except Exception as e: + log.warning(f"Failed to update histogram: {e}") + + @Slot() + def reset_editor_state(self): + """Resets all editor-related properties to their default values.""" + self.brightness = 0.0 + self.contrast = 0.0 + self.saturation = 0.0 + self.white_balance_by = 0.0 + self.white_balance_mg = 0.0 + self.sharpness = 0.0 + self.rotation = 0 + self.exposure = 0.0 + self.highlights = 0.0 + self.shadows = 0.0 + self.vibrance = 0.0 + self.vignette = 0.0 + self.blacks = 0.0 + self.whites = 0.0 + self.clarity = 0.0 + self.texture = 0.0 + self.cropRotation = 0.0 + self.currentCropBox = (0, 0, 1000, 1000) + self.currentAspectRatioIndex = 0 + + @Property('QVariant', notify=histogram_data_changed) + def histogramData(self): + """Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values.""" + return self._histogram_data + + @histogramData.setter + def histogramData(self, new_value): + if self._histogram_data != new_value: + self._histogram_data = new_value + self.histogram_data_changed.emit() + + @Property('QVariant', notify=highlightStateChanged) + def highlightState(self): + """Returns highlight analysis state for UI display. + + Returns dict with: + - headroom_pct: Fraction of pixels with recoverable data above 1.0 (16-bit sources) + - source_clipped_pct: Fraction of pixels clipped in the SOURCE image (JPEG flat-top @ 254+) + - current_nearwhite_pct: Fraction of pixels currently near white in the processed state. + """ + editor = self.app_controller.image_editor + state = {} + if editor and hasattr(editor, '_last_highlight_state') and editor._last_highlight_state: + # Quick copy under lock to minimize contention + # Using the editor's lock ensures we don't read while it's being written + with editor._lock: + state = dict(editor._last_highlight_state) + + # Normalize for QML robustness: ensure stable keys exist regardless of internal naming + # Normalize for QML robustness: ensure stable keys exist + return { + 'headroom_pct': state.get('headroom_pct', 0.0), + 'source_clipped_pct': state.get('source_clipped_pct', 0.0), + 'current_nearwhite_pct': state.get('current_nearwhite_pct', 0.0) + } + + @Property(float, notify=brightness_changed) + def brightness(self) -> float: + return self._brightness + + @brightness.setter + def brightness(self, new_value: float): + if self._brightness != new_value: + self._brightness = new_value + self.brightness_changed.emit(new_value) + + @Property(float, notify=contrast_changed) + def contrast(self) -> float: + return self._contrast + + @contrast.setter + def contrast(self, new_value: float): + if self._contrast != new_value: + self._contrast = new_value + self.contrast_changed.emit(new_value) + + @Property(float, notify=saturation_changed) + def saturation(self) -> float: + return self._saturation + + @saturation.setter + def saturation(self, new_value: float): + if self._saturation != new_value: + self._saturation = new_value + self.saturation_changed.emit(new_value) + + @Property(float, notify=white_balance_by_changed) + def whiteBalanceBY(self) -> float: + return self._white_balance_by + + @whiteBalanceBY.setter + def whiteBalanceBY(self, new_value: float): + if self._white_balance_by != new_value: + self._white_balance_by = new_value + self.white_balance_by_changed.emit(new_value) + + @Property(float, notify=white_balance_mg_changed) + def whiteBalanceMG(self) -> float: + return self._white_balance_mg + + @whiteBalanceMG.setter + def whiteBalanceMG(self, new_value: float): + if self._white_balance_mg != new_value: + self._white_balance_mg = new_value + self.white_balance_mg_changed.emit(new_value) + + # Snake_case aliases for QML bracket notation access + @Property(float, notify=white_balance_by_changed) + def white_balance_by(self) -> float: + return self._white_balance_by + + @white_balance_by.setter + def white_balance_by(self, new_value: float): + self.whiteBalanceBY = new_value + + @Property(float, notify=white_balance_mg_changed) + def white_balance_mg(self) -> float: + return self._white_balance_mg + + @white_balance_mg.setter + def white_balance_mg(self, new_value: float): + self.whiteBalanceMG = new_value + + @Property('QVariantList', notify=aspect_ratio_names_changed) + def aspectRatioNames(self) -> list: + return self._aspect_ratio_names + + @aspectRatioNames.setter + def aspectRatioNames(self, new_value: list): + if self._aspect_ratio_names != new_value: + self._aspect_ratio_names = new_value + self.aspect_ratio_names_changed.emit(new_value) + + @Property(int, notify=current_aspect_ratio_index_changed) + def currentAspectRatioIndex(self) -> int: + return self._current_aspect_ratio_index + + @currentAspectRatioIndex.setter + def currentAspectRatioIndex(self, new_value: int): + if self._current_aspect_ratio_index != new_value: + self._current_aspect_ratio_index = new_value + self.current_aspect_ratio_index_changed.emit(new_value) + + @Property('QVariant', notify=current_crop_box_changed) + def currentCropBox(self) -> tuple: + # QML will receive this as a list + return self._current_crop_box + + @currentCropBox.setter + def currentCropBox(self, new_value): + # Convert QJSValue or list to tuple if needed + original_value = new_value + try: + if hasattr(new_value, 'toVariant'): + # It's a QJSValue, convert to tuple + variant = new_value.toVariant() + if isinstance(variant, (list, tuple)): + new_value = tuple(variant) + else: + # Try to access elements directly + new_value = (variant[0], variant[1], variant[2], variant[3]) + elif isinstance(new_value, list): + new_value = tuple(new_value) + elif not isinstance(new_value, tuple): + # Try to convert to tuple + new_value = tuple(new_value) + except (TypeError, IndexError, AttributeError) as e: + log.warning( + "UIState.currentCropBox: failed to normalize value %r (type %s): %s", + original_value, + type(original_value), + e, + ) + + # only accept 4-element tuples + if ( + not isinstance(new_value, tuple) + or len(new_value) != 4 + or not all(isinstance(v, (int, float)) for v in new_value) + ): + log.warning("UIState.currentCropBox: ignoring invalid crop box %r", new_value) + return + if self._current_crop_box != new_value: + self._current_crop_box = new_value + self.current_crop_box_changed.emit(new_value) + # Sync with ImageEditor + if hasattr(self.app_controller, 'image_editor') and self.app_controller.image_editor: + self.app_controller.image_editor.set_crop_box(new_value) + + @Property(float, notify=crop_rotation_changed) + def cropRotation(self) -> float: + return self._crop_rotation + + @cropRotation.setter + def cropRotation(self, new_value: float): + if self._crop_rotation != new_value: + self._crop_rotation = new_value + self.crop_rotation_changed.emit(new_value) + + # --- New Properties --- + @Property(float, notify=sharpness_changed) + def sharpness(self) -> float: + return self._sharpness + + @sharpness.setter + def sharpness(self, new_value: float): + if self._sharpness != new_value: + self._sharpness = new_value + self.sharpness_changed.emit(new_value) + + @Property(int, notify=rotation_changed) + def rotation(self) -> int: + return self._rotation + + @rotation.setter + def rotation(self, new_value: int): + if self._rotation != new_value: + self._rotation = new_value + self.rotation_changed.emit(new_value) + + @Property(float, notify=exposure_changed) + def exposure(self) -> float: + return self._exposure + + @exposure.setter + def exposure(self, new_value: float): + if self._exposure != new_value: + self._exposure = new_value + self.exposure_changed.emit(new_value) + + @Property(float, notify=highlights_changed) + def highlights(self) -> float: + return self._highlights + + @highlights.setter + def highlights(self, new_value: float): + if self._highlights != new_value: + self._highlights = new_value + self.highlights_changed.emit(new_value) + + @Property(float, notify=shadows_changed) + def shadows(self) -> float: + return self._shadows + + @shadows.setter + def shadows(self, new_value: float): + if self._shadows != new_value: + self._shadows = new_value + self.shadows_changed.emit(new_value) + + @Property(float, notify=vibrance_changed) + def vibrance(self) -> float: + return self._vibrance + + @vibrance.setter + def vibrance(self, new_value: float): + if self._vibrance != new_value: + self._vibrance = new_value + self.vibrance_changed.emit(new_value) + + @Property(float, notify=vignette_changed) + def vignette(self) -> float: + return self._vignette + + @vignette.setter + def vignette(self, new_value: float): + if self._vignette != new_value: + self._vignette = new_value + self.vignette_changed.emit(new_value) + + @Property(float, notify=blacks_changed) + def blacks(self) -> float: + return self._blacks + + @blacks.setter + def blacks(self, new_value: float): + if self._blacks != new_value: + self._blacks = new_value + self.blacks_changed.emit(new_value) + + @Property(float, notify=whites_changed) + def whites(self) -> float: + return self._whites + + @whites.setter + def whites(self, new_value: float): + if self._whites != new_value: + self._whites = new_value + self.whites_changed.emit(new_value) + + @Property(float, notify=clarity_changed) + def clarity(self) -> float: + return self._clarity + + @clarity.setter + def clarity(self, new_value: float): + if self._clarity != new_value: + self._clarity = new_value + self.clarity_changed.emit(new_value) + + @Property(float, notify=texture_changed) + def texture(self) -> float: + return self._texture + + @texture.setter + def texture(self, new_value: float): + if self._texture != new_value: + self._texture = new_value + self.texture_changed.emit(new_value) + + # --- Debug Cache Properties --- + + @Property(bool, notify=debugCacheChanged) + def debugCache(self) -> bool: + return self._debug_cache + + @debugCache.setter + def debugCache(self, value: bool): + if self._debug_cache != value: + self._debug_cache = value + self.debugCacheChanged.emit(value) + + @Property(str, notify=cacheStatsChanged) + def cacheStats(self) -> str: + return self._cache_stats + + @cacheStats.setter + def cacheStats(self, value: str): + if self._cache_stats != value: + self._cache_stats = value + self.cacheStatsChanged.emit(value) + + @Property(bool, notify=isDecodingChanged) + def isDecoding(self) -> bool: + return self._is_decoding + + @isDecoding.setter + def isDecoding(self, value: bool): + if self._is_decoding != value: + self._is_decoding = value + self.isDecodingChanged.emit(value) + + @Property(bool, notify=debugModeChanged) + def debugMode(self) -> bool: + return self._debug_mode + + @debugMode.setter + def debugMode(self, value: bool): + if self._debug_mode != value: + self._debug_mode = value + self.debugModeChanged.emit(value) + + # --- RAW / Editor Source Logic --- + + diff --git a/faststack/verify_wb.py b/faststack/verify_wb.py index 866925e..96d352d 100644 --- a/faststack/verify_wb.py +++ b/faststack/verify_wb.py @@ -1,66 +1,66 @@ - -import numpy as np -from PIL import Image -from faststack.imaging.editor import ImageEditor -import os - -def test_white_balance(): - editor = ImageEditor() - - # 1. Test Black Preservation - # Create a purely black image - black_img = Image.new('RGB', (100, 100), (0, 0, 0)) - black_path = "test_black.jpg" - black_img.save(black_path) - - editor.load_image(black_path) - - # Apply strong temperature and tint - editor.set_edit_param('white_balance_by', 1.0) # Max Warm - editor.set_edit_param('white_balance_mg', 1.0) # Max Magenta - - # Get processed image - # We need to access the internal method or use save, but let's use _apply_edits directly for testing - # editor.original_image is loaded. - processed_img = editor._apply_edits(editor.original_image.copy()) - arr = np.array(processed_img) - - # Check max value - should still be 0 or very close to it - max_val = arr.max() - print(f"Black Image Max Value after WB: {max_val}") - - if max_val > 0: - print("FAIL: Black level not preserved!") - else: - print("PASS: Black level preserved.") - - # 2. Test Grey Shift - # Create a mid-grey image - grey_img = Image.new('RGB', (100, 100), (128, 128, 128)) - grey_path = "test_grey.jpg" - grey_img.save(grey_path) - - editor.load_image(grey_path) - editor.set_edit_param('white_balance_by', 0.5) # Warm - # r_gain = 1 + 0.25 = 1.25 -> 128 * 1.25 = 160 - # b_gain = 1 - 0.25 = 0.75 -> 128 * 0.75 = 96 - - processed_img = editor._apply_edits(editor.original_image.copy()) - arr = np.array(processed_img) - r, g, b = arr[0,0] - print(f"Grey Image RGB after Warm shift: R={r}, G={g}, B={b}") - - if r > 128 and b < 128: - print("PASS: Grey shifted warm correctly.") - else: - print("FAIL: Grey did not shift as expected.") - - # Cleanup - for path in [black_path, grey_path]: - try: - os.remove(path) - except OSError: - pass # File may not exist or be locked - -if __name__ == "__main__": - test_white_balance() + +import numpy as np +from PIL import Image +from faststack.imaging.editor import ImageEditor +import os + +def test_white_balance(): + editor = ImageEditor() + + # 1. Test Black Preservation + # Create a purely black image + black_img = Image.new('RGB', (100, 100), (0, 0, 0)) + black_path = "test_black.jpg" + black_img.save(black_path) + + editor.load_image(black_path) + + # Apply strong temperature and tint + editor.set_edit_param('white_balance_by', 1.0) # Max Warm + editor.set_edit_param('white_balance_mg', 1.0) # Max Magenta + + # Get processed image + # We need to access the internal method or use save, but let's use _apply_edits directly for testing + # editor.original_image is loaded. + processed_img = editor._apply_edits(editor.original_image.copy()) + arr = np.array(processed_img) + + # Check max value - should still be 0 or very close to it + max_val = arr.max() + print(f"Black Image Max Value after WB: {max_val}") + + if max_val > 0: + print("FAIL: Black level not preserved!") + else: + print("PASS: Black level preserved.") + + # 2. Test Grey Shift + # Create a mid-grey image + grey_img = Image.new('RGB', (100, 100), (128, 128, 128)) + grey_path = "test_grey.jpg" + grey_img.save(grey_path) + + editor.load_image(grey_path) + editor.set_edit_param('white_balance_by', 0.5) # Warm + # r_gain = 1 + 0.25 = 1.25 -> 128 * 1.25 = 160 + # b_gain = 1 - 0.25 = 0.75 -> 128 * 0.75 = 96 + + processed_img = editor._apply_edits(editor.original_image.copy()) + arr = np.array(processed_img) + r, g, b = arr[0,0] + print(f"Grey Image RGB after Warm shift: R={r}, G={g}, B={b}") + + if r > 128 and b < 128: + print("PASS: Grey shifted warm correctly.") + else: + print("FAIL: Grey did not shift as expected.") + + # Cleanup + for path in [black_path, grey_path]: + try: + os.remove(path) + except OSError: + pass # File may not exist or be locked + +if __name__ == "__main__": + test_white_balance() diff --git a/inspect_app.py b/inspect_app.py index e0fa2c6..4622bfc 100644 --- a/inspect_app.py +++ b/inspect_app.py @@ -1,14 +1,14 @@ - -from faststack.app import AppController -import inspect - -methods = inspect.getmembers(AppController, predicate=inspect.isfunction) -print("Methods found:") -found = False -for name, method in methods: - if 'auto_level' in name: - print(f" {name}") - found = True - -if not found: - print("No auto_level methods found.") + +from faststack.app import AppController +import inspect + +methods = inspect.getmembers(AppController, predicate=inspect.isfunction) +print("Methods found:") +found = False +for name, _ in methods: + if 'auto_level' in name: + print(f" {name}") + found = True + +if not found: + print("No auto_level methods found.") diff --git a/pyproject.toml b/pyproject.toml index b2c395f..abc614b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,59 +1,59 @@ - -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "faststack" -version = "1.5.2" -authors = [ - { name="Alan Rockefeller"}, -] -description = "Ultra-fast JPG Viewer for Focus Stacking Selection" -readme = "README.md" -requires-python = ">=3.11,<3.14" -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: Microsoft :: Windows", - "Operating System :: MacOS", - "Operating System :: POSIX :: Linux", -] -dependencies = [ - "PySide6>=6.0,<7.0", - "PyTurboJPEG>=1.8,<2.0", - "numpy>=2.0,<3.0", - "cachetools>=5.0,<6.0", - "watchdog>=4.0,<5.0", - "Pillow>=10.0,<11.0", - "opencv-python>=4.0,<5.0", -] - -[project.optional-dependencies] - -dev = [ - "pytest>=8.0,<9.0", - "build", - "twine", -] - -[project.scripts] -faststack = "faststack.app:cli" - -[tool.setuptools] -include-package-data = true - -[tool.setuptools.packages.find] -where = ["."] -include = ["faststack*"] - -[tool.setuptools.package-data] -faststack = ["qml/**/*"] - - -[tool.pytest.ini_options] -testpaths = ["faststack/tests"] -python_files = ["test_*.py"] -addopts = "-p no:cacheprovider -p no:doctest --basetemp=./var/pytest-temp" -norecursedirs = ["var", ".venv", "cache", "faststack.egg-info", "__pycache__"] - + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "faststack" +version = "1.5.2" +authors = [ + { name="Alan Rockefeller"}, +] +description = "Ultra-fast JPG Viewer for Focus Stacking Selection" +readme = "README.md" +requires-python = ">=3.11,<3.14" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", +] +dependencies = [ + "PySide6>=6.0,<7.0", + "PyTurboJPEG>=1.8,<2.0", + "numpy>=2.0,<3.0", + "cachetools>=5.0,<6.0", + "watchdog>=4.0,<5.0", + "Pillow>=10.0,<11.0", + "opencv-python>=4.0,<5.0", +] + +[project.optional-dependencies] + +dev = [ + "pytest>=8.0,<9.0", + "build", + "twine", +] + +[project.scripts] +faststack = "faststack.app:cli" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["faststack*"] + +[tool.setuptools.package-data] +faststack = ["qml/**/*"] + + +[tool.pytest.ini_options] +testpaths = ["faststack/tests"] +python_files = ["test_*.py"] +addopts = "-p no:cacheprovider -p no:doctest --basetemp=./var/pytest-temp" +norecursedirs = ["var", ".venv", "cache", "faststack.egg-info", "__pycache__"] + diff --git a/repro_crash.py b/repro_crash.py index 44973fa..bb70c5f 100644 --- a/repro_crash.py +++ b/repro_crash.py @@ -1,40 +1,40 @@ - -import sys -import unittest -from unittest.mock import MagicMock, patch -import numpy as np - -# Mock modules before importing editor -sys.modules['cv2'] = MagicMock() -sys.modules['PIL'] = MagicMock() -sys.modules['PySide6.QtGui'] = MagicMock() - -# Now import the class -from faststack.imaging.editor import ImageEditor - -class TestCrash(unittest.TestCase): - def test_imread_none_crash(self): - """ - Simulate cv2.imread returning None and see if it crashes. - """ - editor = ImageEditor() - editor.original_image = MagicMock() # Pillow image mock - editor.original_image.convert.return_value = np.zeros((100, 100, 3), dtype=np.uint8) - - # Mock cv2.imread to return None - sys.modules['cv2'].imread.return_value = None - sys.modules['cv2'].IMREAD_UNCHANGED = -1 - - # Path must exist for the check at the start of load_image, - # or we mock Path.exists - with patch('pathlib.Path.exists', return_value=True): - try: - print("Attempting to load image with mocks...") - success = editor.load_image("dummy_path.jpg") - print(f"Load result: {success}") - except Exception as e: - print(f"CRASHED: {e}") - raise e - -if __name__ == '__main__': - unittest.main() +import sys +import unittest +from unittest.mock import MagicMock, patch +import numpy as np + +# Mock modules before importing editor +# Note: These mocks remain in sys.modules for the test to use +sys.modules['cv2'] = MagicMock() +sys.modules['PIL'] = MagicMock() +sys.modules['PySide6.QtGui'] = MagicMock() + +# Now import the class +from faststack.imaging.editor import ImageEditor + +class TestCrash(unittest.TestCase): + def test_imread_none_crash(self): + """ + Simulate cv2.imread returning None and see if it crashes. + """ + editor = ImageEditor() + editor.original_image = MagicMock() # Pillow image mock + editor.original_image.convert.return_value = np.zeros((100, 100, 3), dtype=np.uint8) + + # Mock cv2.imread to return None + sys.modules['cv2'].imread.return_value = None + sys.modules['cv2'].IMREAD_UNCHANGED = -1 + + # Path must exist for the check at the start of load_image, + # or we mock Path.exists + with patch('pathlib.Path.exists', return_value=True): + try: + print("Attempting to load image with mocks...") + success = editor.load_image("dummy_path.jpg") + print(f"Load result: {success}") + except Exception as e: + print(f"CRASHED: {e}") + raise e + +if __name__ == '__main__': + unittest.main() diff --git a/reproduce_bug.py b/reproduce_bug.py index 354abde..a1e7546 100644 --- a/reproduce_bug.py +++ b/reproduce_bug.py @@ -1,67 +1,67 @@ - -import os -import time -import shutil -from pathlib import Path -from faststack.io.indexer import find_images - -def test_refresh_logic(): - # Setup test dir - test_dir = Path("./test_images_refresh") - if test_dir.exists(): - shutil.rmtree(test_dir) - test_dir.mkdir() - - # Create main image - img_path = test_dir / "test.jpg" - img_path.touch() - - # Set mtime to T0 - t0 = time.time() - 100 - os.utime(img_path, (t0, t0)) - - # Initial Scan - images = find_images(test_dir) - print(f"Initial images: {[i.path.name for i in images]}") - - current_index = 0 - original_path = images[current_index].path - print(f"Current selection: {original_path.name} (Index {current_index})") - - # Simulate Auto-Levels Save - # 1. Create Backup (preserves mtime T0) - backup_path = test_dir / "test-backup.jpg" - shutil.copy2(img_path, backup_path) - - # 2. Save Main (update mtime to T1) - t1 = time.time() - img_path.touch() # Updates mtime - - # Refresh - images = find_images(test_dir) - print(f"Refreshed images: {[i.path.name for i in images]}") - # Expect: [test-backup.jpg, test.jpg] due to T0 < T1 - - # Selection Logic - new_index = -1 - for i, img_file in enumerate(images): - if img_file.path == original_path: - new_index = i - break - - print(f"Old Index: {current_index}") - print(f"New Index found: {new_index}") - - if new_index == -1: - print("FAIL: Did not find original path in refreshed list.") - # If we failed to find, current_index stays 0 - # Index 0 is now 'test-backup.jpg' - print(f"Effective selection would remain index {current_index}: {images[current_index].path.name}") - else: - print(f"Selected: {images[new_index].path.name} (Index {new_index})") - - # Cleanup - shutil.rmtree(test_dir) - -if __name__ == "__main__": - test_refresh_logic() + +import os +import time +import shutil +from pathlib import Path +from faststack.io.indexer import find_images + +def test_refresh_logic(): + # Setup test dir + test_dir = Path("./test_images_refresh") + if test_dir.exists(): + shutil.rmtree(test_dir) + test_dir.mkdir() + + # Create main image + img_path = test_dir / "test.jpg" + img_path.touch() + + # Set mtime to T0 + t0 = time.time() - 100 + os.utime(img_path, (t0, t0)) + + # Initial Scan + images = find_images(test_dir) + print(f"Initial images: {[i.path.name for i in images]}") + + current_index = 0 + original_path = images[current_index].path + print(f"Current selection: {original_path.name} (Index {current_index})") + + # Simulate Auto-Levels Save + # 1. Create Backup (preserves mtime T0) + backup_path = test_dir / "test-backup.jpg" + shutil.copy2(img_path, backup_path) + + # 2. Save Main (update mtime to T1) + t1 = time.time() + img_path.touch() # Updates mtime + + # Refresh + images = find_images(test_dir) + print(f"Refreshed images: {[i.path.name for i in images]}") + # Expect: [test-backup.jpg, test.jpg] due to T0 < T1 + + # Selection Logic + new_index = -1 + for i, img_file in enumerate(images): + if img_file.path == original_path: + new_index = i + break + + print(f"Old Index: {current_index}") + print(f"New Index found: {new_index}") + + if new_index == -1: + print("FAIL: Did not find original path in refreshed list.") + # If we failed to find, current_index stays 0 + # Index 0 is now 'test-backup.jpg' + print(f"Effective selection would remain index {current_index}: {images[current_index].path.name}") + else: + print(f"Selected: {images[new_index].path.name} (Index {new_index})") + + # Cleanup + shutil.rmtree(test_dir) + +if __name__ == "__main__": + test_refresh_logic() diff --git a/reproduce_bug_case.py b/reproduce_bug_case.py index afc761e..e4f280c 100644 --- a/reproduce_bug_case.py +++ b/reproduce_bug_case.py @@ -1,17 +1,17 @@ - -from pathlib import Path - -def test_path_equality(): - p1 = Path("c:/code/faststack/test.jpg") - p2 = Path("C:/code/faststack/test.jpg") - - print(f"p1: {p1}") - print(f"p2: {p2}") - print(f"p1 == p2: {p1 == p2}") - - p3 = Path("c:\\code\\faststack\\test.jpg") - print(f"p3: {p3}") - print(f"p1 == p3: {p1 == p3}") - -if __name__ == "__main__": - test_path_equality() + +from pathlib import Path + +def test_path_equality(): + p1 = Path("c:/code/faststack/test.jpg") + p2 = Path("C:/code/faststack/test.jpg") + + print(f"p1: {p1}") + print(f"p2: {p2}") + print(f"p1 == p2: {p1 == p2}") + + p3 = Path("c:\\code\\faststack\\test.jpg") + print(f"p3: {p3}") + print(f"p1 == p3: {p1 == p3}") + +if __name__ == "__main__": + test_path_equality() diff --git a/reproduce_config_issue.py b/reproduce_config_issue.py index d5b86b9..7608c00 100644 --- a/reproduce_config_issue.py +++ b/reproduce_config_issue.py @@ -1,64 +1,64 @@ - -import sys -import os -from pathlib import Path -import configparser - -# Update sys.path to include the project root -sys.path.append(r"c:\code\faststack") - -# Mock logging setup to avoid creating real logs/directories -import faststack.logging_setup -import faststack.config - -def test_config_persistence(): - print("Testing config persistence...") - - # Use a temporary file for testing - test_config_dir = Path("c:/code/faststack/test_config_dir") - test_config_dir.mkdir(exist_ok=True) - - # Monkeypatch get_app_data_dir to use local dir - faststack.config.get_app_data_dir = lambda: test_config_dir - - # 1. Initialize config (should create defaults) - app_config = faststack.config.AppConfig() - print(f"Config path: {app_config.config_path}") - - # Verify default - initial_val = app_config.get('core', 'auto_level_threshold') - print(f"Initial value: {initial_val}") - if initial_val != "0.1": - print("FAIL: Default value unexpected") - - # 2. Modify value - new_val = "0.05" - print(f"Setting value to: {new_val}") - app_config.set('core', 'auto_level_threshold', new_val) - app_config.save() - - # 3. Reload config from disk directly to verify file content - raw_config = configparser.ConfigParser() - raw_config.read(app_config.config_path) - file_val = raw_config.get('core', 'auto_level_threshold') - print(f"Value in file: {file_val}") - - # 4. Re-initialize AppConfig (simulate app restart) - # We must clear the global instance or create a new one to force reload - # AppConfig.__init__ calls self.load() - app_config_2 = faststack.config.AppConfig() - loaded_val = app_config_2.get('core', 'auto_level_threshold') - print(f"Loaded value: {loaded_val}") - - if loaded_val == new_val: - print("SUCCESS: Value persisted correctly") - else: - print(f"FAIL: Value did not persist. Got {loaded_val}, expected {new_val}") - - # Clean up - if (test_config_dir / "faststack.ini").exists(): - (test_config_dir / "faststack.ini").unlink() - test_config_dir.rmdir() - -if __name__ == "__main__": - test_config_persistence() + +import sys +import os +from pathlib import Path +import configparser + +# Update sys.path to include the project root +sys.path.append(r"c:\code\faststack") + +# Mock logging setup to avoid creating real logs/directories +import faststack.logging_setup +import faststack.config + +def test_config_persistence(): + print("Testing config persistence...") + + # Use a temporary file for testing + test_config_dir = Path("c:/code/faststack/test_config_dir") + test_config_dir.mkdir(exist_ok=True) + + # Monkeypatch get_app_data_dir to use local dir + faststack.config.get_app_data_dir = lambda: test_config_dir + + # 1. Initialize config (should create defaults) + app_config = faststack.config.AppConfig() + print(f"Config path: {app_config.config_path}") + + # Verify default + initial_val = app_config.get('core', 'auto_level_threshold') + print(f"Initial value: {initial_val}") + if initial_val != "0.1": + print("FAIL: Default value unexpected") + + # 2. Modify value + new_val = "0.05" + print(f"Setting value to: {new_val}") + app_config.set('core', 'auto_level_threshold', new_val) + app_config.save() + + # 3. Reload config from disk directly to verify file content + raw_config = configparser.ConfigParser() + raw_config.read(app_config.config_path) + file_val = raw_config.get('core', 'auto_level_threshold') + print(f"Value in file: {file_val}") + + # 4. Re-initialize AppConfig (simulate app restart) + # We must clear the global instance or create a new one to force reload + # AppConfig.__init__ calls self.load() + app_config_2 = faststack.config.AppConfig() + loaded_val = app_config_2.get('core', 'auto_level_threshold') + print(f"Loaded value: {loaded_val}") + + if loaded_val == new_val: + print("SUCCESS: Value persisted correctly") + else: + print(f"FAIL: Value did not persist. Got {loaded_val}, expected {new_val}") + + # Clean up + if (test_config_dir / "faststack.ini").exists(): + (test_config_dir / "faststack.ini").unlink() + test_config_dir.rmdir() + +if __name__ == "__main__": + test_config_persistence() diff --git a/reproduce_issue.py b/reproduce_issue.py index ab8314c..9198551 100644 --- a/reproduce_issue.py +++ b/reproduce_issue.py @@ -1,53 +1,53 @@ -import os -import pathlib -import sys - -def reproduction_step(): - base_dir = pathlib.Path("test_deletion_repro") - base_dir.mkdir(exist_ok=True) - - recycle_bin = base_dir / "recycle_bin" - recycle_bin.mkdir(exist_ok=True) - - file_name = "test_image.jpg" - source_file = base_dir / file_name - dest_file = recycle_bin / file_name - - # Clean up previous run - if source_file.exists(): source_file.unlink() - if dest_file.exists(): dest_file.unlink() - - # 1. Simulate state: File exists in BOTH source and recycle bin - source_file.touch() - dest_file.touch() - - print(f"Created {source_file} and {dest_file}") - - # 2. Try rename (Current Code) - try: - print("Attempting rename (should fail on Windows)...") - source_file.rename(dest_file) - print("SUCCESS: Rename worked (unexpected on Windows if dest exists)") - except FileExistsError: - print("CAUGHT EXPECTED ERROR: FileExistsError during rename") - except OSError as e: - print(f"CAUGHT OTHER ERROR: {type(e).__name__}: {e}") - - # Reset for fix test - if not source_file.exists(): source_file.touch() - if not dest_file.exists(): dest_file.touch() - - # 3. Try replace (Proposed Fix) - try: - print("Attempting replace (should succeed)...") - source_file.replace(dest_file) - print("SUCCESS: Replace worked") - if not source_file.exists() and dest_file.exists(): - print("Verified: Source is gone, dest exists.") - else: - print("Validation FAILED: File states not correct.") - except Exception as e: - print(f"FAILED: Replace raised {type(e).__name__}: {e}") - -if __name__ == "__main__": - reproduction_step() +import os +import pathlib +import sys + +def reproduction_step(): + base_dir = pathlib.Path("test_deletion_repro") + base_dir.mkdir(exist_ok=True) + + recycle_bin = base_dir / "recycle_bin" + recycle_bin.mkdir(exist_ok=True) + + file_name = "test_image.jpg" + source_file = base_dir / file_name + dest_file = recycle_bin / file_name + + # Clean up previous run + if source_file.exists(): source_file.unlink() + if dest_file.exists(): dest_file.unlink() + + # 1. Simulate state: File exists in BOTH source and recycle bin + source_file.touch() + dest_file.touch() + + print(f"Created {source_file} and {dest_file}") + + # 2. Try rename (Current Code) + try: + print("Attempting rename (should fail on Windows)...") + source_file.rename(dest_file) + print("SUCCESS: Rename worked (unexpected on Windows if dest exists)") + except FileExistsError: + print("CAUGHT EXPECTED ERROR: FileExistsError during rename") + except OSError as e: + print(f"CAUGHT OTHER ERROR: {type(e).__name__}: {e}") + + # Reset for fix test + if not source_file.exists(): source_file.touch() + if not dest_file.exists(): dest_file.touch() + + # 3. Try replace (Proposed Fix) + try: + print("Attempting replace (should succeed)...") + source_file.replace(dest_file) + print("SUCCESS: Replace worked") + if not source_file.exists() and dest_file.exists(): + print("Verified: Source is gone, dest exists.") + else: + print("Validation FAILED: File states not correct.") + except Exception as e: + print(f"FAILED: Replace raised {type(e).__name__}: {e}") + +if __name__ == "__main__": + reproduction_step() diff --git a/reproduce_mmap_error.py b/reproduce_mmap_error.py index 67e5b49..7e03a32 100644 --- a/reproduce_mmap_error.py +++ b/reproduce_mmap_error.py @@ -1,32 +1,32 @@ - -import mmap -import os -import tempfile - -def reproduce(): - with tempfile.NamedTemporaryFile(delete=False) as f: - f.close() - path = f.name - - print(f"Created empty file: {path}") - try: - with open(path, "rb") as f: - # excessive logic to match the app code pattern - # "with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped:" - try: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - print("Mapped successfully (unexpected for empty file)") - except ValueError as e: - print(f"Caught expected error: {e}") - if "cannot mmap an empty file" in str(e): - print("VERIFIED: Reproduction successful.") - else: - print("VERIFIED: Reproduction successful (different message).") - - except Exception as e: - print(f"Caught unexpected top level error: {e}") - finally: - os.unlink(path) - -if __name__ == "__main__": - reproduce() + +import mmap +import os +import tempfile + +def reproduce(): + with tempfile.NamedTemporaryFile(delete=False) as f: + f.close() + path = f.name + + print(f"Created empty file: {path}") + try: + with open(path, "rb") as f: + # excessive logic to match the app code pattern + # "with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped:" + try: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + print("Mapped successfully (unexpected for empty file)") + except ValueError as e: + print(f"Caught expected error: {e}") + if "cannot mmap an empty file" in str(e): + print("VERIFIED: Reproduction successful.") + else: + print("VERIFIED: Reproduction successful (different message).") + + except Exception as e: + print(f"Caught unexpected top level error: {e}") + finally: + os.unlink(path) + +if __name__ == "__main__": + reproduce() diff --git a/requirements.txt b/requirements.txt index 4016687..8688bc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -PyTurboJPEG==1.* -numpy==2.* -cachetools==5.* -watchdog==4.* -Pillow==10.* # fallback decode; keep it - +PyTurboJPEG==1.* +numpy==2.* +cachetools==5.* +watchdog==4.* +Pillow==10.* # fallback decode; keep it + diff --git a/scripts/smoke_verify.py b/scripts/smoke_verify.py index 51d650f..924a699 100644 --- a/scripts/smoke_verify.py +++ b/scripts/smoke_verify.py @@ -1,72 +1,72 @@ -import sys -import importlib.resources -import argparse -from pathlib import Path - -def check_imports(): - print("Checking imports...") - try: - import faststack - import faststack.ui - import faststack.io - import faststack.imaging - import faststack.app - print(" [OK] Imports successful") - except ImportError as e: - print(f" [FAIL] Import failed: {e}") - return False - return True - -def check_cli(): - print("Checking CLI entry point...") - try: - from faststack.app import cli - if not callable(cli): - print(" [FAIL] faststack.app.cli is not callable") - return False - print(" [OK] faststack.app.cli found") - except ImportError: - print(" [FAIL] Could not import faststack.app.cli") - return False - except Exception as e: - print(f" [FAIL] Error checking CLI: {e}") - return False - return True - -def check_assets(): - print("Checking assets (QML files)...") - try: - # For Python 3.9+ standard library importlib.resources - # We look for any .qml file in faststack package - qml_files = list(importlib.resources.files('faststack').rglob('*.qml')) - count = len(qml_files) - if count > 0: - print(f" [OK] Found {count} QML files") - for p in qml_files[:3]: - print(f" - {p.name}") - else: - print(" [FAIL] No QML files found in package resources!") - print(" (Did you include package_data in pyproject.toml / MANIFEST.in?)") - return False - except Exception as e: - print(f" [FAIL] Asset check failed: {e}") - return False - return True - -def main(): - print("=== FastStack Smoke Verification ===") - print(f"Python: {sys.version}") - - if not check_imports(): - sys.exit(1) - - if not check_cli(): - sys.exit(1) - - if not check_assets(): - sys.exit(1) - - print("\n[SUCCESS] faststack package seems healthy.") - -if __name__ == "__main__": - main() +import sys +import importlib.resources +import argparse +from pathlib import Path + +def check_imports(): + print("Checking imports...") + try: + import faststack + import faststack.ui + import faststack.io + import faststack.imaging + import faststack.app + print(" [OK] Imports successful") + except ImportError as e: + print(f" [FAIL] Import failed: {e}") + return False + return True + +def check_cli(): + print("Checking CLI entry point...") + try: + from faststack.app import cli + if not callable(cli): + print(" [FAIL] faststack.app.cli is not callable") + return False + print(" [OK] faststack.app.cli found") + except ImportError: + print(" [FAIL] Could not import faststack.app.cli") + return False + except Exception as e: + print(f" [FAIL] Error checking CLI: {e}") + return False + return True + +def check_assets(): + print("Checking assets (QML files)...") + try: + # For Python 3.9+ standard library importlib.resources + # We look for any .qml file in faststack package + qml_files = list(importlib.resources.files('faststack').rglob('*.qml')) + count = len(qml_files) + if count > 0: + print(f" [OK] Found {count} QML files") + for p in qml_files[:3]: + print(f" - {p.name}") + else: + print(" [FAIL] No QML files found in package resources!") + print(" (Did you include package_data in pyproject.toml / MANIFEST.in?)") + return False + except Exception as e: + print(f" [FAIL] Asset check failed: {e}") + return False + return True + +def main(): + print("=== FastStack Smoke Verification ===") + print(f"Python: {sys.version}") + + if not check_imports(): + sys.exit(1) + + if not check_cli(): + sys.exit(1) + + if not check_assets(): + sys.exit(1) + + print("\n[SUCCESS] faststack package seems healthy.") + +if __name__ == "__main__": + main() diff --git a/test_cachetools_api.py b/test_cachetools_api.py index b3b0c03..0a34e74 100644 --- a/test_cachetools_api.py +++ b/test_cachetools_api.py @@ -1,16 +1,16 @@ -"""Quick test to check cachetools.LRUCache API.""" -from cachetools import LRUCache - -# Create a basic LRUCache -cache = LRUCache(maxsize=100) - -# Check if maxsize is a property or method -print(f"Type of maxsize: {type(cache.maxsize)}") -print(f"maxsize value: {cache.maxsize}") - -# Check if we can access the internal attribute -if hasattr(cache, '_Cache__maxsize'): - print(f"Internal _Cache__maxsize: {cache._Cache__maxsize}") - -# List all attributes -print(f"\nAll cache attributes: {[attr for attr in dir(cache) if not attr.startswith('_')]}") +"""Quick test to check cachetools.LRUCache API.""" +from cachetools import LRUCache + +# Create a basic LRUCache +cache = LRUCache(maxsize=100) + +# Check if maxsize is a property or method +print(f"Type of maxsize: {type(cache.maxsize)}") +print(f"maxsize value: {cache.maxsize}") + +# Check if we can access the internal attribute +if hasattr(cache, '_Cache__maxsize'): + print(f"Internal _Cache__maxsize: {cache._Cache__maxsize}") + +# List all attributes +print(f"\nAll cache attributes: {[attr for attr in dir(cache) if not attr.startswith('_')]}") diff --git a/test_max_bytes.py b/test_max_bytes.py index 0f517eb..5a945de 100644 --- a/test_max_bytes.py +++ b/test_max_bytes.py @@ -1,36 +1,36 @@ -"""Quick test to verify ByteLRUCache.max_bytes works correctly.""" -from faststack.imaging.cache import ByteLRUCache - -class MockItem: - def __init__(self, size: int): - self._size = size - - def __sizeof__(self) -> int: - return self._size - -# Test 1: Initialize cache -cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) -print(f"Initial max_bytes: {cache.max_bytes}") -assert cache.max_bytes == 1000, "Initial max_bytes should be 1000" - -# Test 2: Add items -cache["a"] = MockItem(50) -cache["b"] = MockItem(40) -print(f"Current size: {cache.currsize}, Max bytes: {cache.max_bytes}") -assert cache.currsize == 90, "Current size should be 90" - -# Test 3: Change max_bytes and verify eviction works -cache.max_bytes = 80 -print(f"New max_bytes: {cache.max_bytes}") -assert cache.max_bytes == 80, "max_bytes should be updated to 80" - -# Test 4: Add an item that triggers eviction -cache["c"] = MockItem(50) -print(f"After eviction - Current size: {cache.currsize}, Items: {list(cache.keys())}") - -# "a" should have been evicted (LRU) -assert "a" not in cache, "Item 'a' should have been evicted" -assert "b" in cache or "c" in cache, "At least one of 'b' or 'c' should be in cache" -assert cache.currsize <= cache.max_bytes, f"Current size {cache.currsize} should be <= max_bytes {cache.max_bytes}" - -print("\n✓ All tests passed! ByteLRUCache.max_bytes works correctly.") +"""Quick test to verify ByteLRUCache.max_bytes works correctly.""" +from faststack.imaging.cache import ByteLRUCache + +class MockItem: + def __init__(self, size: int): + self._size = size + + def __sizeof__(self) -> int: + return self._size + +# Test 1: Initialize cache +cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) +print(f"Initial max_bytes: {cache.max_bytes}") +assert cache.max_bytes == 1000, "Initial max_bytes should be 1000" + +# Test 2: Add items +cache["a"] = MockItem(50) +cache["b"] = MockItem(40) +print(f"Current size: {cache.currsize}, Max bytes: {cache.max_bytes}") +assert cache.currsize == 90, "Current size should be 90" + +# Test 3: Change max_bytes and verify eviction works +cache.max_bytes = 80 +print(f"New max_bytes: {cache.max_bytes}") +assert cache.max_bytes == 80, "max_bytes should be updated to 80" + +# Test 4: Add an item that triggers eviction +cache["c"] = MockItem(50) +print(f"After eviction - Current size: {cache.currsize}, Items: {list(cache.keys())}") + +# "a" should have been evicted (LRU) +assert "a" not in cache, "Item 'a' should have been evicted" +assert "b" in cache or "c" in cache, "At least one of 'b' or 'c' should be in cache" +assert cache.currsize <= cache.max_bytes, f"Current size {cache.currsize} should be <= max_bytes {cache.max_bytes}" + +print("\n✓ All tests passed! ByteLRUCache.max_bytes works correctly.") diff --git a/tests/debug_import.py b/tests/debug_import.py index 27de51a..847c7ce 100644 --- a/tests/debug_import.py +++ b/tests/debug_import.py @@ -1,23 +1,23 @@ -import sys -import os -import traceback - -# Add project root to path -# We are running from faststack/faststack, so root is .. -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -print(f"Sys Path: {sys.path[0]}") - -try: - print("Attempting import faststack.app...") - import faststack.app - print("Import faststack.app success!") - - print("Attempting import AppController...") - from faststack.app import AppController - print("Import AppController success!") - - print("Attributes:") - print(f"get_active_edit_path: {hasattr(AppController, 'get_active_edit_path')}") -except Exception: - print("Import FAILED:") - traceback.print_exc() +import sys +import os +import traceback + +# Add project root to path +# We are running from faststack/faststack, so root is .. +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +print(f"Sys Path: {sys.path[0]}") + +try: + print("Attempting import faststack.app...") + import faststack.app + print("Import faststack.app success!") + + print("Attempting import AppController...") + from faststack.app import AppController + print("Import AppController success!") + + print("Attributes:") + print(f"get_active_edit_path: {hasattr(AppController, 'get_active_edit_path')}") +except Exception: + print("Import FAILED:") + traceback.print_exc() diff --git a/tests/repro_exif_fix.py b/tests/repro_exif_fix.py index 4109234..ee54cd3 100644 --- a/tests/repro_exif_fix.py +++ b/tests/repro_exif_fix.py @@ -1,57 +1,57 @@ - -from PIL import Image, ExifTags -import io - -def test_exif_sanitization(): - # 1. Create a dummy image with EXIF orientation = 6 (Rotated 90 CW) - # We can't easily "create" raw EXIF bytes without saving, - # so we'll save a temp, change it, then load it. - - img = Image.new('RGB', (100, 100), color='red') - exif = img.getexif() - exif[ExifTags.Base.Orientation] = 6 # Simulate rotated - - buf = io.BytesIO() - img.save(buf, format='JPEG', exif=exif.tobytes()) - buf.seek(0) - - # 2. Load it back (this simulates self.original_image) - original_image = Image.open(buf) - print(f"Original Orientation: {original_image.getexif().get(ExifTags.Base.Orientation)}") - - # 3. Simulate processing (we have a new image to save, but want metadata from original) - # In Editor code: existing logic takes original_image.info.get('exif') - # Proposed logic: take original_image.getexif(), mod it, tobytes() - - new_img = Image.new('RGB', (100, 100), color='blue') # The "edited" image - - # Proposed Fix Logic: - exif_obj = original_image.getexif() - if exif_obj: - exif_obj[ExifTags.Base.Orientation] = 1 - try: - exif_bytes = exif_obj.tobytes() - print("Successfully serialized modified EXIF.") - except Exception as e: - print(f"Failed to serialize: {e}") - exif_bytes = original_image.info.get('exif') # Fallback? - else: - exif_bytes = original_image.info.get('exif') - - # Save - out_buf = io.BytesIO() - new_img.save(out_buf, format='JPEG', exif=exif_bytes) - out_buf.seek(0) - - # 4. Verify result - result_img = Image.open(out_buf) - res_orientation = result_img.getexif().get(ExifTags.Base.Orientation) - print(f"Result Orientation: {res_orientation}") - - if res_orientation == 1: - print("PASS: Orientation sanitized.") - else: - print("FAIL: Orientation NOT sanitized.") - -if __name__ == "__main__": - test_exif_sanitization() + +from PIL import Image, ExifTags +import io + +def test_exif_sanitization(): + # 1. Create a dummy image with EXIF orientation = 6 (Rotated 90 CW) + # We can't easily "create" raw EXIF bytes without saving, + # so we'll save a temp, change it, then load it. + + img = Image.new('RGB', (100, 100), color='red') + exif = img.getexif() + exif[ExifTags.Base.Orientation] = 6 # Simulate rotated + + buf = io.BytesIO() + img.save(buf, format='JPEG', exif=exif.tobytes()) + buf.seek(0) + + # 2. Load it back (this simulates self.original_image) + original_image = Image.open(buf) + print(f"Original Orientation: {original_image.getexif().get(ExifTags.Base.Orientation)}") + + # 3. Simulate processing (we have a new image to save, but want metadata from original) + # In Editor code: existing logic takes original_image.info.get('exif') + # Proposed logic: take original_image.getexif(), mod it, tobytes() + + new_img = Image.new('RGB', (100, 100), color='blue') # The "edited" image + + # Proposed Fix Logic: + exif_obj = original_image.getexif() + if exif_obj: + exif_obj[ExifTags.Base.Orientation] = 1 + try: + exif_bytes = exif_obj.tobytes() + print("Successfully serialized modified EXIF.") + except Exception as e: + print(f"Failed to serialize: {e}") + exif_bytes = original_image.info.get('exif') # Fallback? + else: + exif_bytes = original_image.info.get('exif') + + # Save + out_buf = io.BytesIO() + new_img.save(out_buf, format='JPEG', exif=exif_bytes) + out_buf.seek(0) + + # 4. Verify result + result_img = Image.open(out_buf) + res_orientation = result_img.getexif().get(ExifTags.Base.Orientation) + print(f"Result Orientation: {res_orientation}") + + if res_orientation == 1: + print("PASS: Orientation sanitized.") + else: + print("FAIL: Orientation NOT sanitized.") + +if __name__ == "__main__": + test_exif_sanitization() diff --git a/tests/test_highlights_v2.py b/tests/test_highlights_v2.py index 8524c4f..ffda9c3 100644 --- a/tests/test_highlights_v2.py +++ b/tests/test_highlights_v2.py @@ -1,67 +1,68 @@ -import unittest -import numpy as np -from faststack.imaging.editor import ImageEditor, _apply_headroom_shoulder -from faststack.ui.provider import UIState -from faststack.app import AppController -from PySide6.QtCore import QObject, Signal - -class MockAppController(QObject): - def __init__(self): - super().__init__() - self.image_editor = ImageEditor() - self.ui_state = None # Circular ref handle manually - -class TestHighlightsV2(unittest.TestCase): - def test_shoulder_asymptote(self): - """Verify the new shoulder asymptotes to 1.0 + steepness.""" - x = np.array([1.0, 2.0, 10.0, 100.0], dtype=np.float32) - steepness = 0.05 - out = _apply_headroom_shoulder(x, steepness=steepness) - - # At 1.0, should be 1.0 - self.assertAlmostEqual(out[0], 1.0, places=5) - - # Above 1.0, should be < 1.0 + steepness - self.assertTrue(np.all(out[1:] < 1.0 + steepness)) - - # Monotonicity - self.assertTrue(out[1] > out[0]) - self.assertTrue(out[2] > out[1]) - - # Asymptote check: at very large x, should be close to 1.05 - self.assertAlmostEqual(out[-1], 1.0 + steepness, delta=0.001) - - def test_analysis_decoupling(self): - """Verify analysis runs before adjustments and is cached.""" - editor = ImageEditor() - # Create a linear image with some headroom - linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2 - # sRGB mock indicating some clipping (e.g. 255) - srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255 - - # Run with highlights=-0.5 - editor._apply_highlights_shadows(linear, highlights=-0.5, shadows=0.0, srgb_u8=srgb) - - # Check cache - self.assertIsNotNone(editor._last_highlight_state) - self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9) - self.assertGreater(editor._last_highlight_state['clipped_pct'], 0.9) - - def test_robust_ceiling(self): - """Verify headroom ceiling handles hot pixels.""" - editor = ImageEditor() - linear = np.ones((100, 100, 3), dtype=np.float32) * 1.1 # Moderate headroom - # Add a single hot pixel - linear[50, 50, :] = 1000.0 - - # Use highlights recovery - # This triggers the robust percentile logic - out = editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) - - # Check that we didn't explode or crash - self.assertTrue(np.isfinite(out).all()) - # The hot pixel should be compressed but not NaN - self.assertLess(out[50, 50, 0], 1000.0) - -if __name__ == '__main__': - unittest.main() +import unittest +import numpy as np +from faststack.imaging.editor import ImageEditor +from faststack.imaging.math_utils import _apply_headroom_shoulder +from faststack.ui.provider import UIState +from faststack.app import AppController +from PySide6.QtCore import QObject, Signal + +class MockAppController(QObject): + def __init__(self): + super().__init__() + self.image_editor = ImageEditor() + self.ui_state = None # Circular ref handle manually + +class TestHighlightsV2(unittest.TestCase): + def test_shoulder_asymptote(self): + """Verify the new shoulder asymptotes to 1.0 + steepness.""" + x = np.array([1.0, 2.0, 10.0, 100.0], dtype=np.float32) + steepness = 0.05 + out = _apply_headroom_shoulder(x, steepness=steepness) + + # At 1.0, should be 1.0 + self.assertAlmostEqual(out[0], 1.0, places=5) + + # Above 1.0, should be < 1.0 + steepness + self.assertTrue(np.all(out[1:] < 1.0 + steepness)) + + # Monotonicity + self.assertTrue(out[1] > out[0]) + self.assertTrue(out[2] > out[1]) + + # Asymptote check: at very large x, should be close to 1.05 + self.assertAlmostEqual(out[-1], 1.0 + steepness, delta=0.001) + + def test_analysis_decoupling(self): + """Verify analysis runs before adjustments and is cached.""" + editor = ImageEditor() + # Create a linear image with some headroom + linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2 + # sRGB mock indicating some clipping (e.g. 255) + srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255 + + # Run with highlights=-0.5 + editor._apply_highlights_shadows(linear, highlights=-0.5, shadows=0.0, srgb_u8=srgb) + + # Check cache + self.assertIsNotNone(editor._last_highlight_state) + self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9) + self.assertGreater(editor._last_highlight_state['clipped_pct'], 0.9) + + def test_robust_ceiling(self): + """Verify headroom ceiling handles hot pixels.""" + editor = ImageEditor() + linear = np.ones((100, 100, 3), dtype=np.float32) * 1.1 # Moderate headroom + # Add a single hot pixel + linear[50, 50, :] = 1000.0 + + # Use highlights recovery + # This triggers the robust percentile logic + out = editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) + + # Check that we didn't explode or crash + self.assertTrue(np.isfinite(out).all()) + # The hot pixel should be compressed but not NaN + self.assertLess(out[50, 50, 0], 1000.0) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_prefetch_concurrency.py b/tests/test_prefetch_concurrency.py index 5c0e704..f2fac87 100644 --- a/tests/test_prefetch_concurrency.py +++ b/tests/test_prefetch_concurrency.py @@ -1,129 +1,129 @@ - -import threading -import time -import pytest -import numpy as np -from pathlib import Path -from concurrent.futures import Future - -from faststack.imaging.prefetch import Prefetcher -from faststack.models import ImageFile - -# Mock objects to isolate Prefetcher logic -class MockImageFile: - def __init__(self, index): - self.path = Path(f"/mock/image_{index}.jpg") - -def mock_get_display_info(): - return 1920, 1080, 1 - -def mock_cache_put(key, value): - pass - -@pytest.fixture -def prefetcher(): - image_files = [MockImageFile(i) for i in range(100)] - # Use a small radius to force more activity - p = Prefetcher(image_files, mock_cache_put, prefetch_radius=5, get_display_info=mock_get_display_info, debug=False) - - # Mock the internal decode method to avoid actual I/O and processing - # We just return a dummy result after a tiny sleep - def mock_decode_and_cache(*args, **kwargs): - time.sleep(0.0001) # fast sleep - return Path("/mock/image_x.jpg"), 1 - - p._decode_and_cache = mock_decode_and_cache - - yield p - p.shutdown() - -def test_prefetch_concurrency(prefetcher): - """ - Stress test for race conditions in Prefetcher. - Simulates concurrent navigation (update_prefetch), cancellation (cancel_all), - and file list updates (set_image_files). - """ - - # Configuration - num_loops = 5000 - num_threads = 4 - - # Shared state for error tracking - errors = [] - - # Barrier to synchronize start - barrier = threading.Barrier(num_threads) - - stop_event = threading.Event() - - def worker_update(): - try: - barrier.wait() - for i in range(num_loops): - if stop_event.is_set(): break - # Randomly jump around - idx = i % 100 - prefetcher.update_prefetch(idx, is_navigation=True, direction=1) - except Exception as e: - errors.append(e) - stop_event.set() - - def worker_cancel(): - try: - barrier.wait() - for i in range(num_loops): - if stop_event.is_set(): break - if i % 10 == 0: # Cancel less frequently - prefetcher.cancel_all() - except Exception as e: - errors.append(e) - stop_event.set() - - def worker_set_files(): - try: - barrier.wait() - # Generate two lists to toggle between - list1 = [MockImageFile(i) for i in range(100)] - list2 = [MockImageFile(i) for i in range(50)] # Different size - - for i in range(num_loops): - if stop_event.is_set(): break - if i % 100 == 0: # Reload files occasionally - new_list = list2 if i % 200 == 0 else list1 - prefetcher.set_image_files(new_list) - except Exception as e: - errors.append(e) - stop_event.set() - - # Create threads - threads = [ - threading.Thread(target=worker_update), - threading.Thread(target=worker_update), # Two updaters - threading.Thread(target=worker_cancel), - threading.Thread(target=worker_set_files) - ] - - # Start threads - for t in threads: - t.start() - - # Wait for completion - for t in threads: - t.join() - - # Assertions - if errors: - pytest.fail(f"Exceptions occurred in worker threads: {errors}") - - # Verify internal consistency - with prefetcher._futures_lock: - # Check that scheduled matches generation (basic check) - for gen, scheduled_set in prefetcher._scheduled.items(): - if gen > prefetcher.generation: - pytest.fail(f"Found scheduled set for future generation {gen} > {prefetcher.generation}") - - # Check futures dict consistency - # It's hard to assert exact size since threads stopped at random times, - # but we can check if keys in futures are valid integers roughly - assert isinstance(prefetcher.futures, dict) - + +import threading +import time +import pytest +import numpy as np +from pathlib import Path +from concurrent.futures import Future + +from faststack.imaging.prefetch import Prefetcher +from faststack.models import ImageFile + +# Mock objects to isolate Prefetcher logic +class MockImageFile: + def __init__(self, index): + self.path = Path(f"/mock/image_{index}.jpg") + +def mock_get_display_info(): + return 1920, 1080, 1 + +def mock_cache_put(key, value): + pass + +@pytest.fixture +def prefetcher(): + image_files = [MockImageFile(i) for i in range(100)] + # Use a small radius to force more activity + p = Prefetcher(image_files, mock_cache_put, prefetch_radius=5, get_display_info=mock_get_display_info, debug=False) + + # Mock the internal decode method to avoid actual I/O and processing + # We just return a dummy result after a tiny sleep + def mock_decode_and_cache(*args, **kwargs): + time.sleep(0.0001) # fast sleep + return Path("/mock/image_x.jpg"), 1 + + p._decode_and_cache = mock_decode_and_cache + + yield p + p.shutdown() + +def test_prefetch_concurrency(prefetcher): + """ + Stress test for race conditions in Prefetcher. + Simulates concurrent navigation (update_prefetch), cancellation (cancel_all), + and file list updates (set_image_files). + """ + + # Configuration + num_loops = 5000 + num_threads = 4 + + # Shared state for error tracking + errors = [] + + # Barrier to synchronize start + barrier = threading.Barrier(num_threads) + + stop_event = threading.Event() + + def worker_update(): + try: + barrier.wait() + for i in range(num_loops): + if stop_event.is_set(): break + # Randomly jump around + idx = i % 100 + prefetcher.update_prefetch(idx, is_navigation=True, direction=1) + except Exception as e: + errors.append(e) + stop_event.set() + + def worker_cancel(): + try: + barrier.wait() + for i in range(num_loops): + if stop_event.is_set(): break + if i % 10 == 0: # Cancel less frequently + prefetcher.cancel_all() + except Exception as e: + errors.append(e) + stop_event.set() + + def worker_set_files(): + try: + barrier.wait() + # Generate two lists to toggle between + list1 = [MockImageFile(i) for i in range(100)] + list2 = [MockImageFile(i) for i in range(50)] # Different size + + for i in range(num_loops): + if stop_event.is_set(): break + if i % 100 == 0: # Reload files occasionally + new_list = list2 if i % 200 == 0 else list1 + prefetcher.set_image_files(new_list) + except Exception as e: + errors.append(e) + stop_event.set() + + # Create threads + threads = [ + threading.Thread(target=worker_update), + threading.Thread(target=worker_update), # Two updaters + threading.Thread(target=worker_cancel), + threading.Thread(target=worker_set_files) + ] + + # Start threads + for t in threads: + t.start() + + # Wait for completion + for t in threads: + t.join() + + # Assertions + if errors: + pytest.fail(f"Exceptions occurred in worker threads: {errors}") + + # Verify internal consistency + with prefetcher._futures_lock: + # Check that scheduled matches generation (basic check) + for gen, scheduled_set in prefetcher._scheduled.items(): + if gen > prefetcher.generation: + pytest.fail(f"Found scheduled set for future generation {gen} > {prefetcher.generation}") + + # Check futures dict consistency + # It's hard to assert exact size since threads stopped at random times, + # but we can check if keys in futures are valid integers roughly + assert isinstance(prefetcher.futures, dict) + diff --git a/tests/verify_manual.py b/tests/verify_manual.py index dc80d74..fcf49a1 100644 --- a/tests/verify_manual.py +++ b/tests/verify_manual.py @@ -1,111 +1,109 @@ -import sys -import os -from pathlib import Path -from unittest.mock import MagicMock, patch - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -try: - from faststack.app import AppController - print("Imported AppController") -except Exception as e: - print(f"Failed to import AppController: {e}") - sys.exit(1) - -class DummyController: - def __init__(self): - self.current_edit_source_mode = "jpeg" - self.image_files = [] - self.current_index = 0 - self.ui_state = MagicMock() - self.ui_state.isHistogramVisible = False - self.editSourceModeChanged = MagicMock() - - # Copy methods - get_active_edit_path = AppController.get_active_edit_path - is_valid_working_tif = AppController.is_valid_working_tif - _set_current_index = AppController._set_current_index - enable_raw_editing = AppController.enable_raw_editing - - def sync_ui_state(self): pass - def _reset_crop_settings(self): pass - def _do_prefetch(self, *args, **kwargs): pass - def update_histogram(self): pass - def load_image_for_editing(self): pass - def _develop_raw_backend(self): pass - - -def log(msg): - with open("verify_result.txt", "a") as f: - f.write(msg + "\n") - -def run_checks(): - # Clear log - with open("verify_result.txt", "w") as f: - f.write("Starting Verification\n") - - controller = DummyController() - - # Setup data - img_jpg = MagicMock() - img_jpg.path = Path("test.jpg") - img_jpg.path.suffix = ".jpg" - img_jpg.raw_pair = Path("test.CR2") - img_jpg.working_tif_path = Path("test.tif") - img_jpg.has_working_tif = False - - img_raw = MagicMock() - img_raw.path = Path("orphan.CR2") - img_raw.path.suffix = ".CR2" - img_raw.raw_pair = None - - controller.image_files = [img_jpg, img_raw] - - log("--- Test 1: Default Mode ---") - controller.current_index = 0 - path = controller.get_active_edit_path(0) - if path == Path("test.jpg") and controller.current_edit_source_mode == "jpeg": - log("PASS") - else: - log(f"FAIL: path={path}, mode={controller.current_edit_source_mode}") - - log("--- Test 2: Enable RAW (trigger dev) ---") - controller._develop_raw_backend = MagicMock() - controller.enable_raw_editing() - if controller.current_edit_source_mode == "raw": - log("PASS: Mode switched") - else: - log(f"FAIL: Mode not switched") - controller._develop_raw_backend.assert_called_once() - log("PASS: Dev triggered") - - log("--- Test 3: Valid TIFF ---") - img_jpg.has_working_tif = True - with patch.object(controller, 'is_valid_working_tif', return_value=True): - controller.load_image_for_editing = MagicMock() - controller._develop_raw_backend = MagicMock() - controller.current_edit_source_mode = "jpeg" # Reset - controller.enable_raw_editing() - - if controller.current_edit_source_mode == "raw" and controller.get_active_edit_path(0) == Path("test.tif"): - log("PASS: Mode raw, Returns TIFF") - else: - log(f"FAIL: returns {controller.get_active_edit_path(0)}") - - controller._develop_raw_backend.assert_not_called() - log("PASS: No dev triggered") - - log("--- Test 4: RAW Only ---") - # Mock RAW_EXTENSIONS import - # Note: Logic in app.py uses local import: from faststack.io.indexer import RAW_EXTENSIONS - # Patching faststack.io.indexer.RAW_EXTENSIONS works if module is already loaded or loads fresh. - # Since we imported AppController (which imports indexer), it is loaded. - with patch('faststack.io.indexer.RAW_EXTENSIONS', {'.CR2'}): - # We also need to patch JPG_EXTENSIONS maybe? No, defaults are fine. - controller._set_current_index(1) - if controller.current_edit_source_mode == "raw": - log("PASS: Auto raw mode") - else: - log(f"FAIL: Mode is {controller.current_edit_source_mode}") - -run_checks() +import sys +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +try: + from faststack.app import AppController + print("Imported AppController") +except Exception as e: + print(f"Failed to import AppController: {e}") + sys.exit(1) + +class DummyController: + def __init__(self): + self.current_edit_source_mode = "jpeg" + self.image_files = [] + self.current_index = 0 + self.ui_state = MagicMock() + self.ui_state.isHistogramVisible = False + self.editSourceModeChanged = MagicMock() + + # Copy methods + get_active_edit_path = AppController.get_active_edit_path + is_valid_working_tif = AppController.is_valid_working_tif + _set_current_index = AppController._set_current_index + enable_raw_editing = AppController.enable_raw_editing + + def sync_ui_state(self): pass + def _reset_crop_settings(self): pass + def _do_prefetch(self, *args, **kwargs): pass + def update_histogram(self): pass + def load_image_for_editing(self): pass + def _develop_raw_backend(self): pass + + +def log(msg): + with open("verify_result.txt", "a") as f: + f.write(msg + "\n") + +def run_checks(): + # Clear log + with open("verify_result.txt", "w") as f: + f.write("Starting Verification\n") + + controller = DummyController() + + # Setup data + img_jpg = MagicMock() + img_jpg.path = Path("test.jpg") # suffix is derived from Path, not assigned + img_jpg.raw_pair = Path("test.CR2") + img_jpg.working_tif_path = Path("test.tif") + img_jpg.has_working_tif = False + + img_raw = MagicMock() + img_raw.path = Path("orphan.CR2") # suffix is derived from Path, not assigned + img_raw.raw_pair = None + + controller.image_files = [img_jpg, img_raw] + + log("--- Test 1: Default Mode ---") + controller.current_index = 0 + path = controller.get_active_edit_path(0) + if path == Path("test.jpg") and controller.current_edit_source_mode == "jpeg": + log("PASS") + else: + log(f"FAIL: path={path}, mode={controller.current_edit_source_mode}") + + log("--- Test 2: Enable RAW (trigger dev) ---") + controller._develop_raw_backend = MagicMock() + controller.enable_raw_editing() + if controller.current_edit_source_mode == "raw": + log("PASS: Mode switched") + else: + log(f"FAIL: Mode not switched") + controller._develop_raw_backend.assert_called_once() + log("PASS: Dev triggered") + + log("--- Test 3: Valid TIFF ---") + img_jpg.has_working_tif = True + with patch.object(controller, 'is_valid_working_tif', return_value=True): + controller.load_image_for_editing = MagicMock() + controller._develop_raw_backend = MagicMock() + controller.current_edit_source_mode = "jpeg" # Reset + controller.enable_raw_editing() + + if controller.current_edit_source_mode == "raw" and controller.get_active_edit_path(0) == Path("test.tif"): + log("PASS: Mode raw, Returns TIFF") + else: + log(f"FAIL: returns {controller.get_active_edit_path(0)}") + + controller._develop_raw_backend.assert_not_called() + log("PASS: No dev triggered") + + log("--- Test 4: RAW Only ---") + # Mock RAW_EXTENSIONS import + # Note: Logic in app.py uses local import: from faststack.io.indexer import RAW_EXTENSIONS + # Patching faststack.io.indexer.RAW_EXTENSIONS works if module is already loaded or loads fresh. + # Since we imported AppController (which imports indexer), it is loaded. + with patch('faststack.io.indexer.RAW_EXTENSIONS', {'.CR2'}): + # We also need to patch JPG_EXTENSIONS maybe? No, defaults are fine. + controller._set_current_index(1) + if controller.current_edit_source_mode == "raw": + log("PASS: Auto raw mode") + else: + log(f"FAIL: Mode is {controller.current_edit_source_mode}") + +run_checks() diff --git a/tests/verify_raw_mode.py b/tests/verify_raw_mode.py index ad2b63a..9d5a8ea 100644 --- a/tests/verify_raw_mode.py +++ b/tests/verify_raw_mode.py @@ -1,173 +1,167 @@ -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch -import sys -import os - -# Add project root to path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -# Mock things we don't want to import fully or that need QObject -# We need to test AppController methods, but AppController inherits QObject. -# To test logic without full Qt app, we can either: -# 1. Use QTest (requires PySide6 installed and GUI context) -# 2. Extract logic or subclass AppController with mocked QObject? -# Let's try to minimal import. - -# Assuming we can instantiate AppController or minimal subclass -# But AppController creates threads and QObjects in __init__. -# Better to mock AppController's state and just test the methods if possible. -# But methods are bound to 'self'. -# We can create a dummy class that looks like AppController for these methods. - -class DummyController: - def __init__(self): - self.current_edit_source_mode = "jpeg" - self.image_files = [] - self.current_index = 0 - self.ui_state = MagicMock() - self.ui_state.isHistogramVisible = False - - def __init__(self): - self.current_edit_source_mode = "jpeg" - self.image_files = [] - self.current_index = 0 - self.ui_state = MagicMock() - self.ui_state.isHistogramVisible = False - - # Copy methods to test - try: - from faststack.app import AppController - get_active_edit_path = AppController.get_active_edit_path - is_valid_working_tif = AppController.is_valid_working_tif - _set_current_index = AppController._set_current_index - enable_raw_editing = AppController.enable_raw_editing - except Exception as e: - print(f"CRITICAL ERROR importing AppController: {e}") - import traceback - traceback.print_exc() - # Define dummy placeholders to prevent AttributeError during test collection/execution - get_active_edit_path = lambda *args: None - is_valid_working_tif = lambda *args: False - _set_current_index = lambda *args: None - enable_raw_editing = lambda *args: None - - - def sync_ui_state(self): - pass - - def _reset_crop_settings(self): - pass - - def _do_prefetch(self, *args, **kwargs): - pass - - def update_histogram(self): - pass - - def load_image_for_editing(self): - pass - - def _develop_raw_backend(self): - pass - -class TestRawMode(unittest.TestCase): - def setUp(self): - self.controller = DummyController() - - # Create mock image files - self.img_jpg = MagicMock() - self.img_jpg.path = Path("test.jpg") - self.img_jpg.path.suffix = ".jpg" - self.img_jpg.raw_pair = Path("test.CR2") - self.img_jpg.working_tif_path = Path("test.tif") - self.img_jpg.has_working_tif = False # Initially false - - self.img_raw_only = MagicMock() - self.img_raw_only.path = Path("orphan.CR2") - self.img_raw_only.path.suffix = ".CR2" - self.img_raw_only.raw_pair = None - - self.controller.image_files = [self.img_jpg, self.img_raw_only] - - def test_default_mode(self): - """Test 1: Default mode should be JPEG.""" - self.controller.current_index = 0 - path = self.controller.get_active_edit_path(0) - self.assertEqual(path, Path("test.jpg")) - self.assertEqual(self.controller.current_edit_source_mode, "jpeg") - - def test_switch_to_raw_with_development(self): - """Test 2: Enabling RAW should switch mode and trigger develop if no TIFF.""" - self.controller.current_index = 0 - - # Mock _develop_raw_backend - self.controller._develop_raw_backend = MagicMock() - - self.controller.enable_raw_editing() - - self.assertEqual(self.controller.current_edit_source_mode, "raw") - self.controller._develop_raw_backend.assert_called_once() - - # Path check: even if we switch mode, if TIFF is invalid, get_active_edit_path might return RAW path? - # Logic says: if mode=raw, check valid TIFF, else return raw_pair. - # So it should return the RAW file if TIFF not ready. - path = self.controller.get_active_edit_path(0) - self.assertEqual(path, Path("test.CR2")) - - def test_switch_to_raw_with_existing_tiff(self): - """Test 3: Enabling RAW should load TIFF if valid.""" - self.controller.current_index = 0 - self.img_jpg.has_working_tif = True - - # Mock is_valid_working_tif to return True - with patch.object(self.controller, 'is_valid_working_tif', return_value=True): - self.controller.load_image_for_editing = MagicMock() - self.controller.enable_raw_editing() - - self.assertEqual(self.controller.current_edit_source_mode, "raw") - # Should NOT develop - self.controller._develop_raw_backend = MagicMock() - self.controller._develop_raw_backend.assert_not_called() - # Should load immediately - self.controller.load_image_for_editing.assert_called_once() - - # Helper should return TIF - path = self.controller.get_active_edit_path(0) - self.assertEqual(path, Path("test.tif")) - - def test_raw_only_case(self): - """Test 4: Opening RAW-only files should force RAW mode.""" - # Navigate to index 1 (RAW only) - # Using _set_current_index logic - - # Need to mock the logic in _set_current_index... - # Wait, I copied _set_current_index to DummyController. - # But it requires `from faststack.io.indexer import RAW_EXTENSIONS`. - # I need to mock that import or ensure it works. - - with patch('faststack.io.indexer.RAW_EXTENSIONS', {'.CR2', '.ARW'}): - self.controller._set_current_index(1) - - self.assertEqual(self.controller.current_index, 1) - self.assertEqual(self.controller.current_edit_source_mode, "raw") - - path = self.controller.get_active_edit_path(1) - self.assertEqual(path, Path("orphan.CR2")) - - def test_navigation_resets_mode(self): - """Test 5: Navigating to a normal pair should reset mode to JPEG.""" - # First set raw mode on index 0 - self.controller.current_index = 0 - self.controller.current_edit_source_mode = "raw" - - # Navigate to index 0 again via _set_current_index (like jumping or reloading) - # Or pretend we have another image. Let's make index 0 a normal pair. - - with patch('faststack.io.indexer.RAW_EXTENSIONS', {'.CR2', '.ARW'}): - self.controller._set_current_index(0) - - self.assertEqual(self.controller.current_edit_source_mode, "jpeg") - -if __name__ == '__main__': - unittest.main() +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Mock things we don't want to import fully or that need QObject +# We need to test AppController methods, but AppController inherits QObject. +# To test logic without full Qt app, we can either: +# 1. Use QTest (requires PySide6 installed and GUI context) +# 2. Extract logic or subclass AppController with mocked QObject? +# Let's try to minimal import. + +# Assuming we can instantiate AppController or minimal subclass +# But AppController creates threads and QObjects in __init__. +# Better to mock AppController's state and just test the methods if possible. +# But methods are bound to 'self'. +# We can create a dummy class that looks like AppController for these methods. + +class DummyController: + def __init__(self): + self.current_edit_source_mode = "jpeg" + self.image_files = [] + self.current_index = 0 + self.ui_state = MagicMock() + self.ui_state.isHistogramVisible = False + self.editSourceModeChanged = MagicMock() # Signal stub + + # Copy methods to test + try: + from faststack.app import AppController + get_active_edit_path = AppController.get_active_edit_path + is_valid_working_tif = AppController.is_valid_working_tif + _set_current_index = AppController._set_current_index + enable_raw_editing = AppController.enable_raw_editing + except Exception as e: + print(f"CRITICAL ERROR importing AppController: {e}") + import traceback + traceback.print_exc() + # Define dummy placeholders to prevent AttributeError during test collection/execution + get_active_edit_path = lambda *args: None + is_valid_working_tif = lambda *args: False + _set_current_index = lambda *args: None + enable_raw_editing = lambda *args: None + + + def sync_ui_state(self): + pass + + def _reset_crop_settings(self): + pass + + def _do_prefetch(self, *args, **kwargs): + pass + + def update_histogram(self): + pass + + def load_image_for_editing(self): + pass + + def _develop_raw_backend(self): + pass + +class TestRawMode(unittest.TestCase): + def setUp(self): + self.controller = DummyController() + + # Create mock image files + self.img_jpg = MagicMock() + self.img_jpg.path = Path("test.jpg") # suffix is derived from Path, not assigned + self.img_jpg.raw_pair = Path("test.CR2") + self.img_jpg.working_tif_path = Path("test.tif") + self.img_jpg.has_working_tif = False # Initially false + + self.img_raw_only = MagicMock() + self.img_raw_only.path = Path("orphan.CR2") # suffix is derived from Path, not assigned + self.img_raw_only.raw_pair = None + + self.controller.image_files = [self.img_jpg, self.img_raw_only] + + def test_default_mode(self): + """Test 1: Default mode should be JPEG.""" + self.controller.current_index = 0 + path = self.controller.get_active_edit_path(0) + self.assertEqual(path, Path("test.jpg")) + self.assertEqual(self.controller.current_edit_source_mode, "jpeg") + + def test_switch_to_raw_with_development(self): + """Test 2: Enabling RAW should switch mode and trigger develop if no TIFF.""" + self.controller.current_index = 0 + + # Mock _develop_raw_backend + self.controller._develop_raw_backend = MagicMock() + + self.controller.enable_raw_editing() + + self.assertEqual(self.controller.current_edit_source_mode, "raw") + self.controller._develop_raw_backend.assert_called_once() + + # Path check: even if we switch mode, if TIFF is invalid, get_active_edit_path might return RAW path? + # Logic says: if mode=raw, check valid TIFF, else return raw_pair. + # So it should return the RAW file if TIFF not ready. + path = self.controller.get_active_edit_path(0) + self.assertEqual(path, Path("test.CR2")) + + def test_switch_to_raw_with_existing_tiff(self): + """Test 3: Enabling RAW should load TIFF if valid.""" + self.controller.current_index = 0 + self.img_jpg.has_working_tif = True + + # Mock is_valid_working_tif to return True + with patch.object(self.controller, 'is_valid_working_tif', return_value=True): + # Create mocks BEFORE calling enable_raw_editing + self.controller.load_image_for_editing = MagicMock() + self.controller._develop_raw_backend = MagicMock() + + self.controller.enable_raw_editing() + + self.assertEqual(self.controller.current_edit_source_mode, "raw") + # Should NOT develop (mock was set up before the call) + self.controller._develop_raw_backend.assert_not_called() + # Should load immediately + self.controller.load_image_for_editing.assert_called_once() + + # Helper should return TIF + path = self.controller.get_active_edit_path(0) + self.assertEqual(path, Path("test.tif")) + + def test_raw_only_case(self): + """Test 4: Opening RAW-only files should force RAW mode.""" + # Navigate to index 1 (RAW only) + # Using _set_current_index logic + + # Need to mock the logic in _set_current_index... + # Wait, I copied _set_current_index to DummyController. + # But it requires `from faststack.io.indexer import RAW_EXTENSIONS`. + # I need to mock that import or ensure it works. + + with patch('faststack.io.indexer.RAW_EXTENSIONS', {'.CR2', '.ARW'}): + self.controller._set_current_index(1) + + self.assertEqual(self.controller.current_index, 1) + self.assertEqual(self.controller.current_edit_source_mode, "raw") + + path = self.controller.get_active_edit_path(1) + self.assertEqual(path, Path("orphan.CR2")) + + def test_navigation_resets_mode(self): + """Test 5: Navigating to a normal pair should reset mode to JPEG.""" + # First set raw mode on index 0 + self.controller.current_index = 0 + self.controller.current_edit_source_mode = "raw" + + # Navigate to index 0 again via _set_current_index (like jumping or reloading) + # Or pretend we have another image. Let's make index 0 a normal pair. + + with patch('faststack.io.indexer.RAW_EXTENSIONS', {'.CR2', '.ARW'}): + self.controller._set_current_index(0) + + self.assertEqual(self.controller.current_edit_source_mode, "jpeg") + +if __name__ == '__main__': + unittest.main() diff --git a/verify_fix.py b/verify_fix.py index 24c7f63..1a81c4a 100644 --- a/verify_fix.py +++ b/verify_fix.py @@ -1,71 +1,71 @@ - -import os -import sys -import logging -from pathlib import Path -import tempfile - -# Add project root to path -sys.path.insert(0, os.getcwd()) - -# Mock Qt if needed, but prefetch.py handles it. -# However, faststack.models might import Qt? -# Let's check imports if it fails. - -try: - from faststack.models import ImageFile - from faststack.imaging.prefetch import Prefetcher -except ImportError as e: - print(f"ImportError: {e}") - # Maybe need dependencies installed? - # Assuming environment is set up. - sys.exit(1) - -# Verify the fix -def verify(): - # Setup - with tempfile.NamedTemporaryFile(delete=False) as f: - f.close() - path = f.name - - print(f"Created empty file: {path}") - - try: - # Create dummy ImageFile - img_file = ImageFile(path=Path(path), name="empty.jpg", size=0, modified=0) - - def mock_cache_put(key, val): - pass - - def mock_get_info(): - return 100, 100, 1 - - # Instantiate Prefetcher - # It creates a thread pool, so we should shut it down. - prefetcher = Prefetcher([], mock_cache_put, 1, mock_get_info, debug=True) - - try: - # Call _decode_and_cache - # It checks self.generation (initially 0) against passed generation - print("Calling _decode_and_cache...") - result = prefetcher._decode_and_cache(img_file, 0, 0, 100, 100, 1) - - if result is None: - print("SUCCESS: Returned None for empty file (graceful failure).") - else: - print(f"FAILURE: Returned {result}") - finally: - prefetcher.shutdown() - - except Exception as e: - print(f"FAILED with exception: {e}") - import traceback - traceback.print_exc() - finally: - if os.path.exists(path): - os.unlink(path) - -if __name__ == "__main__": - # Configure logging to see the warning - logging.basicConfig(level=logging.INFO) - verify() + +import os +import sys +import logging +from pathlib import Path +import tempfile + +# Add project root to path +sys.path.insert(0, os.getcwd()) + +# Mock Qt if needed, but prefetch.py handles it. +# However, faststack.models might import Qt? +# Let's check imports if it fails. + +try: + from faststack.models import ImageFile + from faststack.imaging.prefetch import Prefetcher +except ImportError as e: + print(f"ImportError: {e}") + # Maybe need dependencies installed? + # Assuming environment is set up. + sys.exit(1) + +# Verify the fix +def verify(): + # Setup + with tempfile.NamedTemporaryFile(delete=False) as f: + f.close() + path = f.name + + print(f"Created empty file: {path}") + + try: + # Create dummy ImageFile + img_file = ImageFile(path=Path(path), name="empty.jpg", size=0, modified=0) + + def mock_cache_put(key, val): + pass + + def mock_get_info(): + return 100, 100, 1 + + # Instantiate Prefetcher + # It creates a thread pool, so we should shut it down. + prefetcher = Prefetcher([], mock_cache_put, 1, mock_get_info, debug=True) + + try: + # Call _decode_and_cache + # It checks self.generation (initially 0) against passed generation + print("Calling _decode_and_cache...") + result = prefetcher._decode_and_cache(img_file, 0, 0, 100, 100, 1) + + if result is None: + print("SUCCESS: Returned None for empty file (graceful failure).") + else: + print(f"FAILURE: Returned {result}") + finally: + prefetcher.shutdown() + + except Exception as e: + print(f"FAILED with exception: {e}") + import traceback + traceback.print_exc() + finally: + if os.path.exists(path): + os.unlink(path) + +if __name__ == "__main__": + # Configure logging to see the warning + logging.basicConfig(level=logging.INFO) + verify() diff --git a/verify_fix_auto_levels.py b/verify_fix_auto_levels.py index 68d5b6c..09476c3 100644 --- a/verify_fix_auto_levels.py +++ b/verify_fix_auto_levels.py @@ -1,99 +1,99 @@ - -import os -import time -import shutil -from pathlib import Path -from faststack.io.indexer import find_images - -def verify_fix_logic(): - # Setup test dir - test_dir = Path("./verify_auto_levels") - if test_dir.exists(): - shutil.rmtree(test_dir) - test_dir.mkdir() - - # Create main image - img_name = "test_image.jpg" - img_path = test_dir / img_name - img_path.touch() - - # Set mtime to T0 - t0 = time.time() - 100 - os.utime(img_path, (t0, t0)) - - # Initial Scan - images = find_images(test_dir) - # Simulate App State - current_index = 0 - # User selects this - selected_image = images[current_index] - - print(f"Initial: {[i.path.name for i in images]}") - print(f"Selected: {selected_image.path.name} (Index {current_index})") - - # --- SIMULATE AUTO LEVELS --- - - # 1. Create Backup (preserves mtime T0) - # The backup naming logic in create_backup_file is: filename-backup.jpg - # Since 'test_image.jpg' -> 'test_image-backup.jpg' - backup_name = "test_image-backup.jpg" - backup_path = test_dir / backup_name - shutil.copy2(img_path, backup_path) - # Ensure backup has T0 - os.utime(backup_path, (t0, t0)) - - # 2. Save Main (update mtime to T1) - t1 = time.time() - img_path.touch() # Updates mtime - - # --- SIMULATE APP REFRESH & SELECTION (The Fix Logic) --- - saved_path = img_path # The file we just saved to - - # Refresh - images = find_images(test_dir) - print(f"Refreshed: {[i.path.name for i in images]}") - # Expected order: - # test_image-backup.jpg (T0) - # test_image.jpg (T1) - # So index 0 is backup, index 1 is edited - - # FIX LOGIC: - new_index = -1 - target_path = Path(saved_path).resolve() - target_name = Path(saved_path).name - - for i, img_file in enumerate(images): - # The app now uses .name matching - if img_file.path.name == target_name: - new_index = i - break - - - # CHECK RESULTS - if new_index == -1: - print("FAIL: Count not find saved image in list.") - exit(1) - - selected_in_ui = images[new_index] - print(f"UI Selected: {selected_in_ui.path.name} (Index {new_index})") - - if selected_in_ui.path.name != img_name: - print(f"FAIL: Selected image {selected_in_ui.path.name} is NOT the edited image {img_name}") - exit(1) - - # Verify previous image is backup - if new_index > 0: - prev_image = images[new_index - 1] - print(f"Previous Image (Left Arrow): {prev_image.path.name}") - if prev_image.path.name != backup_name: - print(f"WARNING: Previous image is not the expected backup. Found: {prev_image.path.name}") - else: - print("WARNING: No previous image found. Backup should be roughly before edited image.") - - print("SUCCESS: Fix verified.") - - # Cleanup - shutil.rmtree(test_dir) - -if __name__ == "__main__": - verify_fix_logic() + +import os +import time +import shutil +from pathlib import Path +from faststack.io.indexer import find_images + +def verify_fix_logic(): + # Setup test dir + test_dir = Path("./verify_auto_levels") + if test_dir.exists(): + shutil.rmtree(test_dir) + test_dir.mkdir() + + # Create main image + img_name = "test_image.jpg" + img_path = test_dir / img_name + img_path.touch() + + # Set mtime to T0 + t0 = time.time() - 100 + os.utime(img_path, (t0, t0)) + + # Initial Scan + images = find_images(test_dir) + # Simulate App State + current_index = 0 + # User selects this + selected_image = images[current_index] + + print(f"Initial: {[i.path.name for i in images]}") + print(f"Selected: {selected_image.path.name} (Index {current_index})") + + # --- SIMULATE AUTO LEVELS --- + + # 1. Create Backup (preserves mtime T0) + # The backup naming logic in create_backup_file is: filename-backup.jpg + # Since 'test_image.jpg' -> 'test_image-backup.jpg' + backup_name = "test_image-backup.jpg" + backup_path = test_dir / backup_name + shutil.copy2(img_path, backup_path) + # Ensure backup has T0 + os.utime(backup_path, (t0, t0)) + + # 2. Save Main (update mtime to T1) + t1 = time.time() + img_path.touch() # Updates mtime + + # --- SIMULATE APP REFRESH & SELECTION (The Fix Logic) --- + saved_path = img_path # The file we just saved to + + # Refresh + images = find_images(test_dir) + print(f"Refreshed: {[i.path.name for i in images]}") + # Expected order: + # test_image-backup.jpg (T0) + # test_image.jpg (T1) + # So index 0 is backup, index 1 is edited + + # FIX LOGIC: + new_index = -1 + target_path = Path(saved_path).resolve() + target_name = Path(saved_path).name + + for i, img_file in enumerate(images): + # The app now uses .name matching + if img_file.path.name == target_name: + new_index = i + break + + + # CHECK RESULTS + if new_index == -1: + print("FAIL: Count not find saved image in list.") + exit(1) + + selected_in_ui = images[new_index] + print(f"UI Selected: {selected_in_ui.path.name} (Index {new_index})") + + if selected_in_ui.path.name != img_name: + print(f"FAIL: Selected image {selected_in_ui.path.name} is NOT the edited image {img_name}") + exit(1) + + # Verify previous image is backup + if new_index > 0: + prev_image = images[new_index - 1] + print(f"Previous Image (Left Arrow): {prev_image.path.name}") + if prev_image.path.name != backup_name: + print(f"WARNING: Previous image is not the expected backup. Found: {prev_image.path.name}") + else: + print("WARNING: No previous image found. Backup should be roughly before edited image.") + + print("SUCCESS: Fix verified.") + + # Cleanup + shutil.rmtree(test_dir) + +if __name__ == "__main__": + verify_fix_logic() diff --git a/verify_fix_simple.py b/verify_fix_simple.py index a62f183..871ddb5 100644 --- a/verify_fix_simple.py +++ b/verify_fix_simple.py @@ -1,37 +1,37 @@ - -import mmap -import os -import tempfile - -def verify(): - # Setup - with tempfile.NamedTemporaryFile(delete=False) as f: - f.close() - path = f.name - - print(f"Created empty file: {path}") - - try: - # Verify the logic I added to prefetch.py - # Logic: - # if os.path.getsize(image_file.path) == 0: - # log.warning("Skipping empty image file: %s", image_file.path) - # return None - - if os.path.getsize(path) == 0: - print("SUCCESS: Skipped empty file due to size check.") - else: - # If we didn't skip, this would fail - with open(path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - print("Mapped successfully") - print("FAILURE: Should have skipped but didn't (or mmap worked unexpected)") - - except Exception as e: - print(f"FAILED with exception: {e}") - finally: - if os.path.exists(path): - os.unlink(path) - -if __name__ == "__main__": - verify() + +import mmap +import os +import tempfile + +def verify(): + # Setup + with tempfile.NamedTemporaryFile(delete=False) as f: + f.close() + path = f.name + + print(f"Created empty file: {path}") + + try: + # Verify the logic I added to prefetch.py + # Logic: + # if os.path.getsize(image_file.path) == 0: + # log.warning("Skipping empty image file: %s", image_file.path) + # return None + + if os.path.getsize(path) == 0: + print("SUCCESS: Skipped empty file due to size check.") + else: + # If we didn't skip, this would fail + with open(path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + print("Mapped successfully") + print("FAILURE: Should have skipped but didn't (or mmap worked unexpected)") + + except Exception as e: + print(f"FAILED with exception: {e}") + finally: + if os.path.exists(path): + os.unlink(path) + +if __name__ == "__main__": + verify()