From 62a1f55a80f5430daccf6b325b64eadacece5003 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Mon, 1 Dec 2025 16:32:09 -0800 Subject: [PATCH 1/9] Release v1.4 - faster image display, histogram, batch delete --- faststack/ChangeLog.md | 6 + faststack/README.md | 5 +- faststack/faststack/app.py | 264 +++++++++++++++--- faststack/faststack/config.py | 3 +- faststack/faststack/debug_output.txt | 13 + faststack/faststack/imaging/cache.py | 2 + faststack/faststack/imaging/jpeg.py | 24 +- faststack/faststack/imaging/metadata.py | 195 +++++++++++++ faststack/faststack/imaging/prefetch.py | 28 +- faststack/faststack/qml/Components.qml | 2 +- faststack/faststack/qml/DeleteBatchDialog.qml | 122 ++++++++ faststack/faststack/qml/ExifDialog.qml | 101 +++++++ faststack/faststack/qml/Main.qml | 62 +++- faststack/faststack/tests/benchmark_decode.py | 40 +++ .../tests/benchmark_decode_bilinear.py | 84 ++++++ faststack/faststack/tests/check_turbo.py | 11 + faststack/faststack/tests/debug_metadata.py | 60 ++++ faststack/faststack/tests/test_metadata.py | 108 +++++++ faststack/faststack/tests/test_output.txt | Bin 0 -> 2712 bytes .../faststack/tests/test_prefetch_logic.py | 67 +++++ faststack/faststack/ui/keystrokes.py | 1 + faststack/faststack/ui/provider.py | 62 ++-- faststack/pyproject.toml | 4 +- 23 files changed, 1180 insertions(+), 84 deletions(-) create mode 100644 faststack/faststack/debug_output.txt create mode 100644 faststack/faststack/imaging/metadata.py create mode 100644 faststack/faststack/qml/DeleteBatchDialog.qml create mode 100644 faststack/faststack/qml/ExifDialog.qml create mode 100644 faststack/faststack/tests/benchmark_decode.py create mode 100644 faststack/faststack/tests/benchmark_decode_bilinear.py create mode 100644 faststack/faststack/tests/check_turbo.py create mode 100644 faststack/faststack/tests/debug_metadata.py create mode 100644 faststack/faststack/tests/test_metadata.py create mode 100644 faststack/faststack/tests/test_output.txt create mode 100644 faststack/faststack/tests/test_prefetch_logic.py diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 7200306..0107390 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -2,6 +2,12 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. +## [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. + ## [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. diff --git a/faststack/README.md b/faststack/README.md index 25fe589..af32470 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 1.3 - November 23, 2025 +# Version 1.4 - December 1, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. @@ -26,6 +26,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **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 & Usage @@ -56,11 +57,11 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - 'Ctrl+S': Toggle stacked flag - `Enter`: Launch Helicon Focus with selected RAWs - `P`: Edit in Photoshop (uses RAW file if available) +- `O`: Toggle crop mode (cr(O)p hotkey; Enter to crop, Esc to cancel) - `Delete` / `Backspace`: Move image to recycle bin - `Ctrl+Z`: Undo last action (delete or auto white balance) - `A`: Quick auto white balance (saves automatically) - `E`: Toggle Image Editor -- 'O': Crop image - `Ctrl+C`: Copy image path to clipboard - `Ctrl+0`: Reset zoom and pan - `C`: Clear all stacks diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 269b029..7185719 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -25,7 +25,8 @@ Slot, QMimeData, Qt, - QPoint + QPoint, + QCoreApplication ) from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox from PySide6.QtQml import QQmlApplicationEngine @@ -45,6 +46,7 @@ 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 from faststack.io.indexer import RAW_EXTENSIONS @@ -76,7 +78,7 @@ class ProgressReporter(QObject): progress_updated = Signal(int) finished = Signal() - def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): + def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: bool = False): super().__init__() self.image_dir = image_dir self.image_files: List[ImageFile] = [] # Filtered list for display @@ -85,6 +87,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self.ui_refresh_generation = 0 self.main_window: Optional[QObject] = None self.engine = engine + self.debug_cache = debug_cache # New debug_cache flag self.display_width = 0 self.display_height = 0 @@ -107,6 +110,8 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): size_of=get_decoded_image_size, on_evict=self._on_cache_evict ) + self.image_cache.hits = 0 # Initialize cache hit counter + self.image_cache.misses = 0 # Initialize cache miss counter self.prefetcher = Prefetcher( image_files=self.image_files, cache_put=self.image_cache.__setitem__, @@ -120,7 +125,10 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): # -- UI State -- self.ui_state = UIState(self) self.ui_state.theme = self.get_theme() + self.ui_state.debugCache = self.debug_cache self.keybinder = Keybinder(self) + self.ui_state.debugCache = self.debug_cache # Pass debug_cache state to UI + self.ui_state.isDecoding = False # Initialize decoding indicator # -- Stacking State -- self.stack_start_index: Optional[int] = None @@ -357,47 +365,63 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: _, _, display_gen = self.get_display_info() cache_key = f"{index}_{display_gen}" - # Check cache first + # 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 + # 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) - # 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 future: - try: - # Wait for decode to complete (blocking but fast for JPEGs) - result = future.result(timeout=5.0) # 5 second timeout as safety - if result: - decoded_index, decoded_display_gen = result - cache_key = f"{decoded_index}_{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 - except concurrent.futures.TimeoutError: - log.exception("Timeout decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except concurrent.futures.CancelledError: - log.warning("Decode cancelled for index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except Exception as e: - log.exception("Error decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image + # 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 future: + try: + # Wait for decode to complete (blocking but fast for JPEGs) + result = future.result(timeout=5.0) # 5 second timeout as safety + if result: + decoded_index, decoded_display_gen = result + cache_key = f"{decoded_index}_{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 + except concurrent.futures.TimeoutError: + log.exception("Timeout decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except concurrent.futures.CancelledError: + log.warning("Decode cancelled for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except Exception as e: + log.exception("Error decoding image at index %d", index) + 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 @@ -473,6 +497,20 @@ def show_jump_to_image_dialog(self): 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): @@ -864,7 +902,7 @@ def toggle_stack_membership(self): break if stack_idx_backward == -1 and stack_idx_forward == -1: - # This case should be covered by `if not self.stacks`, but as a fallback. + # 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) @@ -910,7 +948,7 @@ def toggle_stack_membership(self): def launch_helicon(self): - """Launches Helicon Focus with selected files (RAW preferred, JPG fallback) or stacks.""" + """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 @@ -1358,14 +1396,48 @@ def _finish_preloading(self): 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.""" + """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 - image_file = self.image_files[self.current_index] + # 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 _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 + + image_file = self.image_files[index] jpg_path = image_file.path raw_path = image_file.raw_pair @@ -1399,8 +1471,99 @@ def delete_current_image(self): self.delete_history.append((jpg_path, raw_path)) self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - - # Refresh image list and move to next image + except OSError as e: + self.update_status_message(f"Delete failed: {e}") + log.exception("Failed to delete image") + return + + # Refresh image list and move to next image + self.refresh_image_list() + if self.image_files: + # Stay at same index (which now shows the next image) + self.current_index = min(self.current_index, len(self.image_files) - 1) + # 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() + + @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) + + # 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: + if jpg_path.exists(): + dest = self.recycle_bin_dir / jpg_path.name + jpg_path.rename(dest) + log.info("Moved %s to recycle bin", jpg_path.name) + + if raw_path and raw_path.exists(): + dest = self.recycle_bin_dir / raw_path.name + raw_path.rename(dest) + log.info("Moved %s to recycle bin", raw_path.name) + + # Add to delete history + self.delete_history.append((jpg_path, raw_path)) + self.undo_history.append(("delete", (jpg_path, raw_path), 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: # Stay at same index (which now shows the next image) @@ -1412,9 +1575,10 @@ def delete_current_image(self): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - except OSError as e: - self.update_status_message(f"Delete failed: {e}") - log.exception("Failed to delete image") + 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.") @Slot() def undo_delete(self): @@ -2620,7 +2784,16 @@ def is_stacked(self) -> bool: meta = self.sidecar.get_metadata(stem) return meta.stacked -def main(image_dir: str = "", debug: bool = False): + 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 @@ -2634,7 +2807,7 @@ def main(image_dir: str = "", debug: bool = False): os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") - app = QApplication(sys.argv) # Moved here + app = QApplication(sys.argv) # QApplication is correct for desktop apps with widgets if debug: log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) @@ -2665,7 +2838,7 @@ def main(image_dir: str = "", debug: bool = False): # 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) + 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) @@ -2705,8 +2878,9 @@ def cli(): 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) + main(image_dir=args.image_dir, debug=args.debug, debug_cache=args.debugcache) if __name__ == "__main__": cli() diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index bd3b31b..480a28b 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -29,7 +29,8 @@ "monitor_icc_path": "", # For 'icc' mode: path to monitor ICC profile }, "awb": { - "mode": "lab", # "lab" or "rgb" "strength": "0.7", + "mode": "lab", # "lab" or "rgb" + "strength": "0.7", "warm_bias": "6", "luma_lower_bound": "30", "luma_upper_bound": "220", diff --git a/faststack/faststack/debug_output.txt b/faststack/faststack/debug_output.txt new file mode 100644 index 0000000..0d5afaf --- /dev/null +++ b/faststack/faststack/debug_output.txt @@ -0,0 +1,13 @@ +Starting debug test... +Calling get_exif_data... +Result Summary: { + "Date Taken": "2023:01:01 12:00:00", + "Camera": "Canon EOS R5", + "Lens": "RF 24-70mm F2.8L IS USM", + "ISO": "100", + "Aperture": "f/2.8", + "Shutter Speed": "1/200s", + "Focal Length": "50mm" +} +Result Full Keys: ['DateTimeOriginal', 'Make', 'Model', 'LensModel', 'ISOSpeedRatings', 'FNumber', 'ExposureTime', 'FocalLength'] +Test PASSED diff --git a/faststack/faststack/imaging/cache.py b/faststack/faststack/imaging/cache.py index 1e74d54..883cf65 100644 --- a/faststack/faststack/imaging/cache.py +++ b/faststack/faststack/imaging/cache.py @@ -12,6 +12,8 @@ class ByteLRUCache(LRUCache): def __init__(self, max_bytes: int, size_of: Callable[[Any], int] = len, on_evict: Callable[[], None] = None): super().__init__(maxsize=max_bytes, getsizeof=size_of) self.on_evict = on_evict + self.hits = 0 + self.misses = 0 log.info(f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity.") def __setitem__(self, key, value): diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index 97f54d9..1173b5b 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -28,13 +28,17 @@ log.info("PyTurboJPEG is available. Using it for JPEG decoding.") -def decode_jpeg_rgb(jpeg_bytes: bytes) -> Optional[np.ndarray]: +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 - return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=0) + 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 @@ -108,11 +112,11 @@ def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Opti def decode_jpeg_resized( - jpeg_bytes: bytes, width: int, height: int + 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) + return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct) if TURBO_AVAILABLE and jpeg_decoder: try: @@ -130,18 +134,24 @@ def decode_jpeg_resized( 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=0 # Proper color space handling + 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) - img.thumbnail((width, height), Image.Resampling.LANCZOS) + # Use BILINEAR for speed + img.thumbnail((width, height), Image.Resampling.BILINEAR) return np.array(img) return decoded except Exception as e: @@ -158,7 +168,7 @@ def decode_jpeg_resized( if scale_factor_ratio > 4: resampling = Image.Resampling.BILINEAR # Much faster else: - resampling = Image.Resampling.LANCZOS # Higher quality + resampling = Image.Resampling.BILINEAR # Changed from LANCZOS to BILINEAR for speed img.thumbnail((width, height), resampling) return np.array(img.convert("RGB")) diff --git a/faststack/faststack/imaging/metadata.py b/faststack/faststack/imaging/metadata.py new file mode 100644 index 0000000..39997cc --- /dev/null +++ b/faststack/faststack/imaging/metadata.py @@ -0,0 +1,195 @@ + +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) + exif = img._getexif() + if not exif: + return {"summary": {}, "full": {}} + except Exception as e: + 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/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 5c3927e..67c5ad8 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -257,12 +257,26 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op 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 # 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()): - if task_index != index and not future.done() and future.cancel(): + # 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: @@ -302,7 +316,8 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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 - buffer = decode_jpeg_resized(mmapped, display_width, display_height) + # Use fast_dct=True for speed + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) t_after_read = time.perf_counter() if buffer is None: return None @@ -364,7 +379,8 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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! - buffer = decode_jpeg_resized(mmapped, display_width, display_height) + # Use fast_dct=True for speed + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) t_after_fallback_read = time.perf_counter() if buffer is None: return None @@ -387,7 +403,8 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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! - buffer = decode_jpeg_resized(mmapped, display_width, display_height) + # Use fast_dct=True for speed + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) t_after_read = time.perf_counter() if buffer is None: return None @@ -409,7 +426,8 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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 - buffer = decode_jpeg_resized(mmapped, display_width, display_height) + # Use fast_dct=True for speed + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) t_after_read = time.perf_counter() if buffer is None: return None diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index b0f02ef..d999a35 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -519,7 +519,7 @@ Item { Text { text: "Aspect Ratio" font.bold: true - color: "white" + color: aspectRatioWindow.isDark ? "white" : "black" font.pixelSize: 12 } diff --git a/faststack/faststack/qml/DeleteBatchDialog.qml b/faststack/faststack/qml/DeleteBatchDialog.qml new file mode 100644 index 0000000..e7bb0aa --- /dev/null +++ b/faststack/faststack/qml/DeleteBatchDialog.qml @@ -0,0 +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: "white" + 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/faststack/qml/ExifDialog.qml b/faststack/faststack/qml/ExifDialog.qml new file mode 100644 index 0000000..3f1aa4e --- /dev/null +++ b/faststack/faststack/qml/ExifDialog.qml @@ -0,0 +1,101 @@ +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 + controller.dialog_opened() + } + + onClosed: { + controller.dialog_closed() + } + + contentItem: ColumnLayout { + spacing: 10 + + 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/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 88429cf..6b7a1a6 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -35,6 +35,12 @@ ApplicationWindow { } } + function openExifDialog(data) { + exifDialog.summaryData = data.summary + exifDialog.fullData = data.full + exifDialog.open() + } + Connections { target: uiState function onThemeChanged() { @@ -766,6 +772,22 @@ ApplicationWindow { color: "transparent" } + Label { + text: uiState ? uiState.cacheStats : "" + color: "#00FFFF" // Cyan + font.family: "Monospace" + visible: uiState ? uiState.debugCache : false + Layout.rightMargin: 10 + } + + 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" @@ -833,7 +855,8 @@ ApplicationWindow { "Navigation:
" + "  J / Right Arrow: Next Image
" + "  K / Left Arrow: Previous Image
" + - "  G: Jump to Image Number

" + + "  G: Jump to Image Number
" + + "  I: Show EXIF Data

" + "Viewing:
" + "  Mouse Wheel: Zoom in/out
" + "  Left-click + Drag: Pan image
" + @@ -911,6 +934,12 @@ ApplicationWindow { textColor: root.currentTextColor maxImageCount: uiState ? uiState.imageCount : 0 } + + DeleteBatchDialog { + id: deleteBatchDialog + backgroundColor: root.currentBackgroundColor + textColor: root.currentTextColor + } HistogramWindow { id: histogramWindow @@ -933,4 +962,35 @@ ApplicationWindow { 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/faststack/tests/benchmark_decode.py b/faststack/faststack/tests/benchmark_decode.py new file mode 100644 index 0000000..19ead5a --- /dev/null +++ b/faststack/faststack/tests/benchmark_decode.py @@ -0,0 +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() diff --git a/faststack/faststack/tests/benchmark_decode_bilinear.py b/faststack/faststack/tests/benchmark_decode_bilinear.py new file mode 100644 index 0000000..12cb58a --- /dev/null +++ b/faststack/faststack/tests/benchmark_decode_bilinear.py @@ -0,0 +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() diff --git a/faststack/faststack/tests/check_turbo.py b/faststack/faststack/tests/check_turbo.py new file mode 100644 index 0000000..1fb0afc --- /dev/null +++ b/faststack/faststack/tests/check_turbo.py @@ -0,0 +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") diff --git a/faststack/faststack/tests/debug_metadata.py b/faststack/faststack/tests/debug_metadata.py new file mode 100644 index 0000000..d2c0dea --- /dev/null +++ b/faststack/faststack/tests/debug_metadata.py @@ -0,0 +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() diff --git a/faststack/faststack/tests/test_metadata.py b/faststack/faststack/tests/test_metadata.py new file mode 100644 index 0000000..9618651 --- /dev/null +++ b/faststack/faststack/tests/test_metadata.py @@ -0,0 +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("N8oZS4q(xfFpt^W^d#rE3ve^LptdEar0qCrWvFXQ z*X+vPaVPzT=;91r!Zc?oi#_75I^@i=D{ZW?->`NHZo(R1FFsk>4jy!|GLtT7sZ8VTNZ{SSb{(|4< zpfkq?bxE}?w4?JuG241nSYbZ-($g!rSCJ;v2W{u<_~(i3w#2m?@?;(gm#-MTNr?xv}#v(@XHnyM;& zXuB=h+YNc>G2a&}drj}zcw!3kr}u0;DTR}w|Id}*H4rkR?EI^9MKRo0ZB6I*!7GkF wy{`9y{?&y(PQ3dy8B?ydJ!f7EtE 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) diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 323992e..5ee0f59 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,9 +5,9 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.3" +version = "1.4" authors = [ - { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, + { name="Alan Rockefeller", email="alanrockefeller at gmail" }, ] description = "Ultra-fast JPG Viewer for Focus Stacking Selection" readme = "README.md" From 874fe264403ff8a1244152a6b8fb7654b3f36a4a Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Mon, 1 Dec 2025 22:14:12 -0500 Subject: [PATCH 2/9] Release v1.4 - zoom bugs fixed, sort of --- faststack/ChangeLog.md | 2 + faststack/faststack/app.py | 66 ++++++++- faststack/faststack/config.py | 1 + faststack/faststack/imaging/prefetch.py | 49 +++++-- faststack/faststack/qml/Components.qml | 148 ++++++++++++++++++-- faststack/faststack/qml/HistogramWindow.qml | 8 ++ faststack/faststack/qml/Main.qml | 34 ++++- faststack/faststack/qml/SettingsDialog.qml | 14 ++ faststack/faststack/ui/provider.py | 10 +- 9 files changed, 309 insertions(+), 23 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 0107390..1c7ad7b 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -7,6 +7,8 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better docum - 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.A +- Added a setting that switches between image display optimized for speed or quality. ## [1.3.0] - 2025-11-23 diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 7185719..3eef1f7 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -1242,6 +1242,22 @@ def get_default_directory(self): 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() def open_directory_dialog(self): dialog = QFileDialog() @@ -2174,8 +2190,16 @@ def toggle_histogram(self): log.info("Histogram window closed") @Slot() - def update_histogram(self): - """Update histogram data from current image.""" + @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): + """Update histogram data from current image. + + 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 + """ if not self.image_files or self.current_index >= len(self.image_files): return @@ -2197,6 +2221,44 @@ def update_histogram(self): arr = np.frombuffer(decoded.buffer, dtype=np.uint8) arr = arr.reshape((decoded.height, decoded.width, 3)) + # If zoomed in, calculate visible region and only use that portion + if zoom > 1.1 and self.ui_state.isZoomed: + # Calculate visible region in image coordinates + # When zoomed, the visible area is the original image size divided by zoom + # The pan_x/pan_y are in screen coordinates relative to transform origin (center) + # image_scale is the scale factor of displayed image vs original + + # Visible size in original image coordinates + visible_width = decoded.width / zoom + visible_height = decoded.height / zoom + + # Center of visible region in image coordinates + # pan_x/pan_y are screen pixel offsets, convert to image pixels + # Account for image_scale: if image is scaled down for display, pan needs scaling too + center_x = decoded.width / 2 + center_y = decoded.height / 2 + + # Convert pan from screen pixels to image pixels + # If image_scale < 1, the image is displayed smaller, so pan needs to be scaled up + 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 + + # The visible center in image coordinates (accounting for pan) + visible_center_x = center_x - (pan_x_image / zoom) + visible_center_y = center_y - (pan_y_image / zoom) + + # Calculate bounds + 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)) + + # Ensure we have valid bounds + if visible_x_end > visible_x_start and visible_y_end > visible_y_start: + # Extract only the visible portion + arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] + log.debug(f"Histogram: Using zoomed region {visible_x_start},{visible_y_start} to {visible_x_end},{visible_y_end} (zoom={zoom:.2f}, pan=({pan_x:.1f},{pan_y:.1f}))") + # --- New Histogram Logic --- bins = 256 value_range = (0, 256) diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 480a28b..55a19ab 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -14,6 +14,7 @@ "prefetch_radius": "4", "theme": "dark", "default_directory": "", + "optimize_for": "speed", # "speed" or "quality" }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 67c5ad8..08fb4cb 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -302,8 +302,11 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, return None try: - # Get current color management mode + # 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 # Option C: Full ICC pipeline - Use TurboJPEG for decode, Pillow only for ICC conversion if color_mode == "icc": @@ -316,8 +319,15 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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 - # Use fast_dct=True for speed - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) + if use_resized: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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 @@ -379,8 +389,15 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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! - # Use fast_dct=True for speed - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) + if use_resized: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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 @@ -403,8 +420,15 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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! - # Use fast_dct=True for speed - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) + if use_resized: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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 @@ -426,8 +450,15 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, 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 - # Use fast_dct=True for speed - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=True) + if use_resized: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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 diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index d999a35..59c08e7 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -27,8 +27,10 @@ Item { source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" fillMode: Image.PreserveAspectFit cache: false // We do our own caching in Python - smooth: uiState && !uiState.anySliderPressed - mipmap: uiState && !uiState.anySliderPressed + smooth: uiState && !uiState.anySliderPressed && !isZooming + mipmap: uiState && !uiState.anySliderPressed && !isZooming + + property bool isZooming: false Component.onCompleted: { if (width > 0 && height > 0) { @@ -55,6 +57,26 @@ Item { } else if (scaleTransform.xScale <= 1.0 && uiState.isZoomed) { uiState.setZoomed(false); } + + // Update histogram with zoom/pan info if histogram is visible + if (uiState && uiState.isHistogramVisible && controller) { + var zoom = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + // Calculate image scale (painted size vs actual size) + var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 + controller.update_histogram(zoom, panX, panY, imageScale) + } + } + + function updateHistogramWithZoom() { + if (uiState && uiState.isHistogramVisible && controller) { + var zoom = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 + controller.update_histogram(zoom, panX, panY, imageScale) + } } transform: [ @@ -62,11 +84,19 @@ Item { id: scaleTransform origin.x: mainImage.width / 2 origin.y: mainImage.height / 2 - onXScaleChanged: mainImage.updateZoomState() - onYScaleChanged: mainImage.updateZoomState() + onXScaleChanged: { + mainImage.updateZoomState() + mainImage.updateHistogramWithZoom() + } + onYScaleChanged: { + mainImage.updateZoomState() + mainImage.updateHistogramWithZoom() + } }, Translate { id: panTransform + onXChanged: mainImage.updateHistogramWithZoom() + onYChanged: mainImage.updateHistogramWithZoom() } ] } @@ -284,13 +314,111 @@ Item { } } - // Wheel for zoom + // Wheel for zoom - zooms in towards cursor, zooms out towards center onWheel: function(wheel) { - // A real implementation would be more complex, zooming - // into the cursor position. - var scaleFactor = wheel.angleDelta.y > 0 ? 1.2 : 1 / 1.2; - scaleTransform.xScale *= scaleFactor; - scaleTransform.yScale *= scaleFactor; + // 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 = scaleTransform.xScale + var newScale = oldScale * scaleFactor + newScale = Math.max(0.1, Math.min(20.0, newScale)) + + // Get the image's painted (displayed) bounds + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var centerX = mainImage.width / 2 + var centerY = mainImage.height / 2 + + if (isZoomingIn) { + // Zoom in: zoom towards cursor position + var mouseX = wheel.x + var mouseY = wheel.y + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + + // Calculate the point in the image that's under the cursor + var pointInImageX = mouseX - imgX + var pointInImageY = mouseY - imgY + + // Only zoom towards cursor if cursor is over the image + if (pointInImageX >= 0 && pointInImageX <= imgWidth && + pointInImageY >= 0 && pointInImageY <= imgHeight) { + + // Calculate offset from image center in screen coordinates + var centerOffsetX = pointInImageX - imgWidth / 2 + var centerOffsetY = pointInImageY - imgHeight / 2 + + // The current screen position of a point is: (imgPoint * oldScale) + oldPan + center + // We want to find what's currently under the cursor and keep it there + // Instead of dividing by oldScale (which loses precision), work with scaled values + + // Calculate what the scaled image point currently is (before zoom) + // This is: (centerOffset - pan) which represents (imgPoint * oldScale) + var scaledImagePointX = centerOffsetX - panTransform.x + var scaledImagePointY = centerOffsetY - panTransform.y + + // Adjust the scale origin to the cursor position + scaleTransform.origin.x = mouseX + scaleTransform.origin.y = mouseY + + // Apply the new scale first + scaleTransform.xScale = newScale + scaleTransform.yScale = newScale + + // After zoom, the scaled image point becomes: scaledImagePoint * (newScale / oldScale) + // We want it to stay at the same screen position, so: + // newPan = centerOffset - (scaledImagePoint * newScale / oldScale) + // Use scaleRatio to avoid precision loss from repeated division + var scaleRatio = newScale / oldScale + var newPanX = centerOffsetX - (scaledImagePointX * scaleRatio) + var newPanY = centerOffsetY - (scaledImagePointY * scaleRatio) + + // Apply the adjusted pan + panTransform.x = newPanX + panTransform.y = newPanY + } else { + // If cursor is outside image, zoom from center + scaleTransform.origin.x = centerX + scaleTransform.origin.y = centerY + scaleTransform.xScale = newScale + scaleTransform.yScale = newScale + } + } else { + // Zoom out: always zoom towards center of screen + scaleTransform.origin.x = centerX + scaleTransform.origin.y = centerY + + // When zooming out, we need to adjust pan to keep the center visible + // The pan values are in screen coordinates, but they represent image-space offsets + // When scale changes, we need to scale the pan proportionally to maintain + // the same visual position relative to the center + var scaleRatio = newScale / oldScale + + // Adjust pan to keep the center point fixed + // If we're zooming out (scaleRatio < 1), pan should be reduced proportionally + panTransform.x = panTransform.x * scaleRatio + panTransform.y = panTransform.y * scaleRatio + + // Apply the new scale + scaleTransform.xScale = newScale + scaleTransform.yScale = newScale + } + + // 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) { diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index d81618d..4bd2453 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -25,6 +25,14 @@ Window { } function onCurrentImageSourceChanged() { if (histogramWindow.visible && controller) { + // Get zoom/pan info from main image view + var zoom = 1.0 + var panX = 0.0 + var panY = 0.0 + var imageScale = 1.0 + + // Try to get zoom/pan from Components (if accessible) + // For now, just call without params - Components will handle it controller.update_histogram() } } diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 6b7a1a6..911f3db 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -261,6 +261,7 @@ ApplicationWindow { settingsDialog.prefetchRadius = uiState.get_prefetch_radius() settingsDialog.theme = uiState.theme settingsDialog.defaultDirectory = uiState.get_default_directory() + settingsDialog.optimizeFor = uiState.get_optimize_for() settingsDialog.awbMode = uiState.awbMode settingsDialog.awbStrength = uiState.awbStrength settingsDialog.awbWarmBias = uiState.awbWarmBias @@ -781,11 +782,42 @@ ApplicationWindow { } Label { - text: uiState ? uiState.cacheStats : "" + id: cacheUsageLabel + text: uiState ? `Cache: ${cacheUsageValue.toFixed(2)} GB` : "" color: "#00FFFF" // Cyan font.family: "Monospace" visible: uiState ? uiState.debugCache : false Layout.rightMargin: 10 + property real cacheUsageValue: 0.0 + + Connections { + target: uiState + function onDebugCacheChanged() { + if (uiState && uiState.debugCache) { + cacheUsageTimer.running = true + } else { + cacheUsageTimer.running = false + } + } + } + + Component.onCompleted: { + if (uiState && uiState.debugCache) { + cacheUsageTimer.running = true + } + } + + Timer { + id: cacheUsageTimer + interval: 1000 + repeat: true + running: false + onTriggered: { + if (uiState) { + cacheUsageLabel.cacheUsageValue = uiState.get_cache_usage_gb() + } + } + } } // Saturation slider (only visible in saturation mode) diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index 2fa074a..bc7f68c 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -29,6 +29,7 @@ Dialog { cacheSizeField.text = settingsDialog.cacheSize.toFixed(1) heliconPathField.text = settingsDialog.heliconPath photoshopPathField.text = settingsDialog.photoshopPath + optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor) } property string heliconPath: "" @@ -37,6 +38,7 @@ Dialog { property int theme: 0 property string defaultDirectory: "" property string photoshopPath: "" + property string optimizeFor: "speed" property string awbMode: "lab" property double awbStrength: 0.7 @@ -54,6 +56,7 @@ Dialog { uiState.set_prefetch_radius(prefetchRadius) uiState.set_theme(theme) uiState.set_default_directory(defaultDirectory) + uiState.set_optimize_for(optimizeFor) uiState.awbMode = awbMode uiState.awbStrength = awbStrength @@ -200,6 +203,17 @@ Dialog { if (path) defaultDirectoryField.text = path } } + + // Optimize For + Label { text: "Optimize For:" } + ComboBox { + id: optimizeForComboBox + model: ["speed", "quality"] + currentIndex: model.indexOf(settingsDialog.optimizeFor) + onCurrentIndexChanged: settingsDialog.optimizeFor = model[currentIndex] + Layout.fillWidth: true + } + Label {} // Placeholder } GridLayout { diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 902a390..c85b033 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -458,11 +458,19 @@ def set_theme(self, 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() From ccdf35449c22a4b2c75eb80449c90661bc691fe6 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 2 Dec 2025 15:53:10 -0800 Subject: [PATCH 3/9] Release v1.4 - Histogram now toggles, delete doesn't jump forwards as many images as you delete --- faststack/faststack/AGENTS.md | 19 +++ faststack/faststack/app.py | 37 +++++- faststack/faststack/imaging/editor.py | 15 +++ faststack/faststack/qml/Components.qml | 136 ++++++++++++++++---- faststack/faststack/qml/HistogramWindow.qml | 16 ++- faststack/faststack/qml/Main.qml | 38 ------ faststack/faststack/tests/test_editor.py | 27 ++++ 7 files changed, 222 insertions(+), 66 deletions(-) create mode 100644 faststack/faststack/AGENTS.md create mode 100644 faststack/faststack/tests/test_editor.py diff --git a/faststack/faststack/AGENTS.md b/faststack/faststack/AGENTS.md new file mode 100644 index 0000000..0cd63fe --- /dev/null +++ b/faststack/faststack/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Guidelines + +## Project Structure & Module Organization +`app.py` is the PySide6 entrypoint that coordinates image indexing, caching, and the QML screens supplied in `qml/`. Supporting modules are grouped by role: `imaging/` manages decode workflow, metadata, editing, and ICC caches; `io/` hosts directory watchers, Helicon Focus integration, executable checks, and RAW indexing; `ui/` contains providers and keystroke maps shared with QML. Configuration defaults live in `config.py`, persisted session data in `faststack.json`, and targeted unit tests plus decode benchmarks in `tests/`. + +## Build, Test, and Development Commands +Run `python -m faststack.app ` from the repo root to open the client; add `--debug` or `--debugcache` when you need verbose timings or cache telemetry. Execute `pytest tests/` before every PR, or focus on one area (for example `pytest tests/test_metadata.py -k gps`). Benchmarks in `tests/benchmark_decode*.py` can be invoked directly with `python` to gauge decoder changes without launching the UI. + +## Coding Style & Naming Conventions +Use four-space indentation, trailing commas for multi-line structures, and descriptive snake_case identifiers; CamelCase is reserved for classes and Qt signal names to match existing QML bindings. Type hints are expected (see `models.py` and `AppController`), and new modules should expose a top-level `log = logging.getLogger(__name__)`. Format with Black and lint with Ruff before opening a pull request. + +## Testing Guidelines +Cover every new branch in `imaging/` or `io/` with focused `pytest` cases located beside the existing `test_*.py` files. Mock filesystem and Qt dependencies so tests stay deterministic, and mark anything that performs long disk scans as `pytest.mark.slow`. When adding heuristics that affect performance, pair the change with an updated benchmark script or a note explaining how to validate throughput. + +## Commit & Pull Request Guidelines +Commit subjects follow the observed pattern `Release vX.Y - short result`; use the same ` - ` style even for smaller changes, and keep bodies succinct bullet lists of rationale or testing. Pull requests need: a one- or two-sentence summary, reproduction or validation steps (including `pytest` output), and screenshots/GIFs whenever UI behavior changes. Link issue IDs or TODO references directly so reviewers can trace intent, and never request review until `pytest tests/` passes locally. + +## Security & Configuration Tips +Avoid committing personal state from `faststack.json`, local benchmarking output, or debug captures. Document any new INI key in `config.py`, prefer relative paths, and treat drag-and-drop helpers like `make_hdrop` as security-sensitive—validate all user-supplied paths before invoking external tools such as Helicon Focus. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 3eef1f7..03e4265 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -1453,6 +1453,7 @@ def _delete_single_image(self, index: int): 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 @@ -1495,8 +1496,7 @@ def _delete_single_image(self, index: int): # Refresh image list and move to next image self.refresh_image_list() if self.image_files: - # Stay at same index (which now shows the next image) - self.current_index = min(self.current_index, len(self.image_files) - 1) + 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() @@ -1504,6 +1504,20 @@ def _delete_single_image(self, index: int): 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.""" @@ -1533,7 +1547,12 @@ def delete_batch_images(self): # 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) - + + previous_index = self.current_index + preserved_path = None + if self.image_files and self.current_index not in indices_to_delete: + preserved_path = self.image_files[self.current_index].path + # Create recycle bin if it doesn't exist try: self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) @@ -1582,8 +1601,7 @@ def delete_batch_images(self): # Refresh image list self.refresh_image_list() if self.image_files: - # Stay at same index (which now shows the next image) - self.current_index = min(self.current_index, len(self.image_files) - 1) + self._reposition_after_delete(preserved_path, previous_index) # Clear cache and invalidate display generation to force image reload self.display_generation += 1 self.image_cache.clear() @@ -2521,6 +2539,11 @@ def execute_crop(self): # Create backup original_path = Path(filepath) + + # Preserve original file modification time + original_mtime = original_path.stat().st_mtime + original_atime = original_path.stat().st_atime + backup_path = create_backup_file(original_path) if backup_path is None: self.update_status_message("Failed to create backup") @@ -2547,6 +2570,10 @@ def execute_crop(self): log.warning(f"Could not save with original format settings: {e}") cropped_img.save(original_path) + # Restore original modification and access times to preserve file position in sorted list + import os + os.utime(original_path, (original_atime, original_mtime)) + # Track for undo import time timestamp = time.time() diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 2575a9e..babc675 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -302,6 +302,11 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: final_img = self._apply_edits(final_img) original_path = self.current_filepath + try: + original_stat = original_path.stat() + except OSError as e: + print(f"Warning: Unable to read timestamps for {original_path}: {e}") + original_stat = None # Use the reusable backup function backup_path = create_backup_file(original_path) @@ -360,11 +365,21 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: # Reraise so the outer except logs and returns None raise + if original_stat is not None: + self._restore_file_times(original_path, original_stat) + return original_path, backup_path except Exception as e: print(f"Failed to save edited image or backup: {e}") return None + def _restore_file_times(self, path: Path, original_stat: os.stat_result) -> None: + """Best-effort restoration of access/modify timestamps after saving.""" + try: + os.utime(path, (original_stat.st_atime, original_stat.st_mtime)) + except OSError as e: + print(f"Warning: Unable to restore timestamps for {path}: {e}") + # Dictionary of ratios for QML dropdown ASPECT_RATIOS = [{"name": name, "ratio": ratio} for name, ratio in INSTAGRAM_RATIOS.items()] diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 59c08e7..85955b6 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -79,6 +79,9 @@ Item { } } + property alias scaleTransform: scaleTransform + property alias panTransform: panTransform + transform: [ Scale { id: scaleTransform @@ -87,16 +90,24 @@ Item { onXScaleChanged: { mainImage.updateZoomState() mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() } onYScaleChanged: { mainImage.updateZoomState() mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() } }, Translate { id: panTransform - onXChanged: mainImage.updateHistogramWithZoom() - onYChanged: mainImage.updateHistogramWithZoom() + onXChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + onYChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } } ] } @@ -155,11 +166,19 @@ Item { if (uiState && uiState.isCropping) { // Check if clicking on existing crop box var cropRect = getCropRect() + var box = uiState.currentCropBox + var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 + var edgeThreshold = 10 * Screen.devicePixelRatio var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height - if (inside && cropRect.width > 0 && cropRect.height > 0) { + // If crop box is full image, always start a new crop + if (isFullImage) { + cropDragMode = "new" + cropStartX = mouse.x + cropStartY = mouse.y + } else if (inside && cropRect.width > 0 && cropRect.height > 0) { // Determine which edge/corner is being dragged var nearLeft = Math.abs(mouse.x - cropRect.x) < edgeThreshold var nearRight = Math.abs(mouse.x - (cropRect.x + cropRect.width)) < edgeThreshold @@ -201,11 +220,24 @@ Item { var imgX = (mainImage.width - imgWidth) / 2 var imgY = (mainImage.height - imgHeight) / 2 var box = uiState.currentCropBox + + // Account for zoom and pan transforms when displaying crop box + var scale = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + + // Convert normalized crop box (0-1000) to image-local coordinates + var localX = (box[0] / 1000) * imgWidth + var localY = (box[1] / 1000) * imgHeight + var localWidth = (box[2] - box[0]) / 1000 * imgWidth + var localHeight = (box[3] - box[1]) / 1000 * imgHeight + + // Apply zoom and pan transforms to get screen coordinates return { - x: imgX + (box[0] / 1000) * imgWidth, - y: imgY + (box[1] / 1000) * imgHeight, - width: (box[2] - box[0]) / 1000 * imgWidth, - height: (box[3] - box[1]) / 1000 * imgHeight + x: imgX + (localX * scale) + panX, + y: imgY + (localY * scale) + panY, + width: localWidth * scale, + height: localHeight * scale } } onPositionChanged: function(mouse) { @@ -221,9 +253,20 @@ Item { var imgX = (mainImage.width - imgWidth) / 2 var imgY = (mainImage.height - imgHeight) / 2 - // Convert mouse position to normalized coordinates - var mouseX = (mouse.x - imgX) / imgWidth - var mouseY = (mouse.y - imgY) / imgHeight + // Account for zoom and pan transforms when converting mouse position + var scale = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + + // Convert screen coordinates to image-local coordinates (accounting for pan) + var localX = (mouse.x - imgX - panX) / scale + var localY = (mouse.y - imgY - panY) / scale + + // Convert to normalized image coordinates (0-1 range) + var mouseX = localX / imgWidth + var mouseY = localY / imgHeight + + // Clamp to image bounds and convert to 0-1000 range mouseX = Math.max(0, Math.min(1, mouseX)) * 1000 mouseY = Math.max(0, Math.min(1, mouseY)) * 1000 @@ -430,11 +473,24 @@ Item { var imgX = (mainImage.width - imgWidth) / 2 var imgY = (mainImage.height - imgHeight) / 2 - // Convert mouse coordinates to image coordinates - var imgCoordX1 = (x1 - imgX) / imgWidth - var imgCoordY1 = (y1 - imgY) / imgHeight - var imgCoordX2 = (x2 - imgX) / imgWidth - var imgCoordY2 = (y2 - imgY) / imgHeight + // Account for zoom and pan transforms + // The transforms are applied in order: Scale then Translate + // To reverse: subtract pan, then divide by scale + var scale = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + + // Convert screen coordinates to image-local coordinates (accounting for pan) + var localX1 = (x1 - imgX - panX) / scale + var localY1 = (y1 - imgY - panY) / scale + var localX2 = (x2 - imgX - panX) / scale + var localY2 = (y2 - imgY - panY) / scale + + // Convert to normalized image coordinates (0-1 range) + var imgCoordX1 = localX1 / imgWidth + var imgCoordY1 = localY1 / imgHeight + var imgCoordX2 = localX2 / imgWidth + var imgCoordY2 = localY2 / imgHeight // Clamp to image bounds imgCoordX1 = Math.max(0, Math.min(1, imgCoordX1)) @@ -448,6 +504,15 @@ Item { var top = Math.min(imgCoordY1, imgCoordY2) * 1000 var bottom = Math.max(imgCoordY1, imgCoordY2) * 1000 + // Ensure minimum size + if (right - left < 10) { + if (right < 1000) right = left + 10 + else left = right - 10 + } + if (bottom - top < 10) { + if (bottom < 1000) bottom = top + 10 + else top = bottom - 10 + } var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom) left = constrainedBox[0] @@ -529,12 +594,17 @@ Item { // Crop rectangle overlay Item { id: cropOverlay - visible: uiState && uiState.isCropping && uiState.currentCropBox + 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 anchors.fill: parent z: 100 - property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] - onCropBoxChanged: { if (!mainImage.source) return updateCropRect() @@ -550,6 +620,16 @@ Item { function onPaintedHeightChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } } + Connections { + target: uiState + function onCurrentCropBoxChanged() { + cropOverlay.cropBox = uiState.currentCropBox + if (cropOverlay.visible && mainImage.source) { + cropOverlay.updateCropRect() + } + } + } + function updateCropRect() { if (!mainImage.source) return @@ -558,10 +638,22 @@ Item { var imgX = (mainImage.width - imgWidth) / 2 var imgY = (mainImage.height - imgHeight) / 2 - var left = imgX + (cropBox[0] / 1000) * imgWidth - var top = imgY + (cropBox[1] / 1000) * imgHeight - var right = imgX + (cropBox[2] / 1000) * imgWidth - var bottom = imgY + (cropBox[3] / 1000) * imgHeight + // Account for zoom and pan transforms when displaying crop box + var scale = mainImage.scaleTransform ? mainImage.scaleTransform.xScale : 1.0 + var panX = mainImage.panTransform ? mainImage.panTransform.x : 0 + var panY = mainImage.panTransform ? mainImage.panTransform.y : 0 + + // Convert normalized crop box (0-1000) to image-local coordinates + var localLeft = (cropBox[0] / 1000) * imgWidth + var localTop = (cropBox[1] / 1000) * imgHeight + var localRight = (cropBox[2] / 1000) * imgWidth + var localBottom = (cropBox[3] / 1000) * imgHeight + + // Apply zoom and pan transforms to get screen coordinates + var left = imgX + (localLeft * scale) + panX + var top = imgY + (localTop * scale) + panY + var right = imgX + (localRight * scale) + panX + var bottom = imgY + (localBottom * scale) + panY cropRect.x = left cropRect.y = top diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index 4bd2453..1ba60e9 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -11,6 +11,19 @@ Window { minimumWidth: 500 minimumHeight: 350 visible: uiState ? uiState.isHistogramVisible : false + + FocusScope { + id: histogramKeyScope + anchors.fill: parent + focus: histogramWindow.visible + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_H && controller) { + event.accepted = true + controller.toggle_histogram() + } + } + } // Connections need to be outside the visibility check Connections { @@ -40,6 +53,7 @@ Window { onVisibleChanged: { if (visible && controller) { + histogramKeyScope.forceActiveFocus() controller.update_histogram() } } @@ -234,4 +248,4 @@ Window { } } } -} \ No newline at end of file +} diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 911f3db..b5d73b8 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -781,44 +781,6 @@ ApplicationWindow { Layout.rightMargin: 10 } - Label { - id: cacheUsageLabel - text: uiState ? `Cache: ${cacheUsageValue.toFixed(2)} GB` : "" - color: "#00FFFF" // Cyan - font.family: "Monospace" - visible: uiState ? uiState.debugCache : false - Layout.rightMargin: 10 - property real cacheUsageValue: 0.0 - - Connections { - target: uiState - function onDebugCacheChanged() { - if (uiState && uiState.debugCache) { - cacheUsageTimer.running = true - } else { - cacheUsageTimer.running = false - } - } - } - - Component.onCompleted: { - if (uiState && uiState.debugCache) { - cacheUsageTimer.running = true - } - } - - Timer { - id: cacheUsageTimer - interval: 1000 - repeat: true - running: false - onTriggered: { - if (uiState) { - cacheUsageLabel.cacheUsageValue = uiState.get_cache_usage_gb() - } - } - } - } // Saturation slider (only visible in saturation mode) Row { diff --git a/faststack/faststack/tests/test_editor.py b/faststack/faststack/tests/test_editor.py new file mode 100644 index 0000000..fe426d2 --- /dev/null +++ b/faststack/faststack/tests/test_editor.py @@ -0,0 +1,27 @@ +import os + +import pytest +from PIL import Image + +from faststack.imaging.editor import ImageEditor + + +def test_save_image_preserves_mtime(tmp_path): + img_path = tmp_path / "sample.jpg" + Image.new("RGB", (4, 4), color=(10, 20, 30)).save(img_path) + + preserved_time = 1_600_000_000 # stable integer timestamp + os.utime(img_path, (preserved_time, preserved_time)) + + editor = ImageEditor() + assert editor.load_image(str(img_path)) + assert editor.set_edit_param('brightness', 0.1) + + saved = editor.save_image() + assert saved is not None + saved_path, backup_path = saved + + assert saved_path == img_path + assert backup_path.exists() + + assert img_path.stat().st_mtime == pytest.approx(preserved_time, rel=0, abs=1e-6) From 5ddf12fad9ceb197431aa39ea03a00b9a7348a92 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 5 Dec 2025 08:00:27 -0800 Subject: [PATCH 4/9] Updates --- faststack/faststack/app.py | 54 ++++++++++++++++++-- faststack/faststack/imaging/cache.py | 48 +++++++++++++----- faststack/faststack/imaging/editor.py | 26 +++++++--- faststack/faststack/imaging/prefetch.py | 8 +-- faststack/faststack/next.prompt | 1 + faststack/faststack/qml/Main.qml | 8 ++- faststack/faststack/verify_wb.py | 66 +++++++++++++++++++++++++ 7 files changed, 182 insertions(+), 29 deletions(-) create mode 100644 faststack/faststack/next.prompt create mode 100644 faststack/faststack/verify_wb.py diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 03e4265..72dce1c 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -41,7 +41,7 @@ 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 +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 @@ -363,7 +363,9 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: return preview_data _, _, display_gen = self.get_display_info() - cache_key = f"{index}_{display_gen}" + 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: @@ -376,6 +378,23 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: 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: @@ -396,8 +415,8 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: # Wait for decode to complete (blocking but fast for JPEGs) result = future.result(timeout=5.0) # 5 second timeout as safety if result: - decoded_index, decoded_display_gen = result - cache_key = f"{decoded_index}_{decoded_display_gen}" + 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: @@ -1076,8 +1095,30 @@ def get_cache_usage_gb(self): 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.maxsize + 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.maxsize = 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') @@ -1355,7 +1396,10 @@ def preload_all_images(self): nearby_radius = self.prefetcher.prefetch_radius * 2 for i, dist in all_images_with_dist: - cache_key = f"{i}_{display_gen}" + 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 diff --git a/faststack/faststack/imaging/cache.py b/faststack/faststack/imaging/cache.py index 883cf65..c552e9c 100644 --- a/faststack/faststack/imaging/cache.py +++ b/faststack/faststack/imaging/cache.py @@ -1,54 +1,78 @@ """Byte-aware LRU cache for storing decoded image data (CPU and GPU).""" import logging -from typing import Any, Callable +from pathlib import Path +from typing import Any, Callable, Union from cachetools import LRUCache log = logging.getLogger(__name__) + class ByteLRUCache(LRUCache): """An LRU Cache that respects the size of its items in bytes.""" - def __init__(self, max_bytes: int, size_of: Callable[[Any], int] = len, on_evict: Callable[[], None] = None): + + def __init__( + self, + max_bytes: int, + size_of: Callable[[Any], int] = len, + on_evict: Callable[[], None] = None, + ): super().__init__(maxsize=max_bytes, getsizeof=size_of) self.on_evict = on_evict self.hits = 0 self.misses = 0 - log.info(f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity.") + log.info( + f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity." + ) def __setitem__(self, key, value): # Before adding a new item, we might need to evict others # This is handled by the parent class, which will call popitem if needed super().__setitem__(key, value) - log.debug(f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB") + log.debug( + f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB" + ) def popitem(self): """Extend popitem to log eviction.""" key, value = super().popitem() - log.debug(f"Evicted item '{key}' to free up space. Cache size: {self.currsize / 1024**2:.2f} MB") - + log.debug( + f"Evicted item '{key}' to free up space. Cache size: {self.currsize / 1024**2:.2f} MB" + ) + if self.on_evict: self.on_evict() - + # In a real Qt app, `value` would be a tuple like (numpy_buffer, qtexture_id) # and we would explicitly free the GPU texture here. return key, value -# Example usage: + def get_decoded_image_size(item) -> int: """Calculates the size of a decoded image tuple (buffer, qimage).""" # In this simplified example, we only store the buffer. # In the full app, this would also account for the QImage/QTexture. from faststack.models import DecodedImage + if isinstance(item, DecodedImage): # Handle both numpy arrays and memoryview buffers - if hasattr(item.buffer, 'nbytes'): + if hasattr(item.buffer, "nbytes"): return item.buffer.nbytes elif isinstance(item.buffer, (bytes, bytearray)): return len(item.buffer) else: # Fallback: estimate from dimensions (more accurate for image buffers than sys.getsizeof) - bytes_per_pixel = getattr(item, 'channels', 4) # Default to RGBA + bytes_per_pixel = getattr(item, "channels", 4) # Default to RGBA return item.width * item.height * bytes_per_pixel - - return 1 # Should not happen + + return 1 # Should not happen + + +def build_cache_key(image_path: Union[Path, str], display_generation: int) -> str: + """Builds a stable cache key that survives list reordering.""" + if isinstance(image_path, Path): + path_str = image_path.as_posix() + else: + path_str = str(image_path) + return f"{path_str}::{display_generation}" diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index babc675..fc7d6dc 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -226,14 +226,24 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: mg_val = self.current_edits['white_balance_mg'] * 0.5 if abs(by_val) > 0.001 or abs(mg_val) > 0.001: arr = np.array(img, dtype=np.float32) - by_shift = by_val * 127.5 - mg_shift = mg_val * 127.5 - # Apply temperature (by_shift) primarily between R and B, and - # tint (mg_shift) primarily to G relative to R/B. We apply half - # of the tint opposite to R/B so that tint shifts G against R/B. - arr[:, :, 0] += (by_shift - 0.5 * mg_shift) # R - arr[:, :, 1] += (1.0 * mg_shift) # G - arr[:, :, 2] -= (by_shift - 0.5 * mg_shift) # B + # Multiplicative White Balance (Gain-based) + # This preserves black levels (0 * gain = 0) while adjusting the color balance of brighter pixels. + + # Temperature (Blue-Yellow): + # Positive = Warm (Yellow/Red), Negative = Cool (Blue) + r_gain = 1.0 + by_val + b_gain = 1.0 - by_val + + # Tint (Magenta-Green): + # Positive = Magenta (Red+Blue boost or Green cut), Negative = Green (Green boost) + # Standard approach: Adjust Green channel opposite to the tint value. + g_gain = 1.0 - mg_val + + # Apply gains + arr[:, :, 0] = arr[:, :, 0] * r_gain + arr[:, :, 1] = arr[:, :, 1] * g_gain + arr[:, :, 2] = arr[:, :, 2] * b_gain + np.clip(arr, 0, 255, out=arr) img = Image.fromarray(arr.astype(np.uint8)) diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 08fb4cb..f9753a4 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -4,6 +4,7 @@ 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 @@ -13,6 +14,7 @@ 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.config import config log = logging.getLogger(__name__) @@ -290,7 +292,7 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op 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[int, int]]: + 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 @@ -505,10 +507,10 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, bytes_per_line=bytes_per_line, format=None # Placeholder for QImage.Format.Format_RGB888 ) - cache_key = f"{index}_{display_generation}" + 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 index, display_generation + return image_file.path, display_generation except Exception: log.exception("Error decoding image %s at index %d", image_file.path, index) diff --git a/faststack/faststack/next.prompt b/faststack/faststack/next.prompt new file mode 100644 index 0000000..68f0d42 --- /dev/null +++ b/faststack/faststack/next.prompt @@ -0,0 +1 @@ +In the image editor, the whites slider should be reversed- moving it to the left makes the image brighter when it should make it darker. The user should be able to double click on a slider to move it to 0, and also the users should be able to manually enter numbers to move the sliders. Save Edited Image should be Save and Close Editor and Close Editor should be Close Without Saving. Saving an image in the image editor should not bring up a dialog box - instead it should display a breif message in the status bar. In the edits menu the order of the Whites and Shadows sliders should be flipped, so Shadows is next to blacks. Brightness should be moved up under Exposure. Add a Texture slider under Sharpness that does something similar to what the Texture slider does in Photoshop. In the White Balance sliders, spell out the colors instead of abbreviating them with a letter. When in the editor pressing E should close it, but currently doesn't. S should close and (S)ave. After pressing I to bring up the EXIF information, pressing I again should close the EXIF information pane. Add a new flag that is like edited, but is called restacked - it should display in the same way. If the user selects Stack Source Raws from the actions menu and Helicon successfully opens, this flag should be set on an image. Deleting a batch of 78 images skips me forward 78 images - it should keep me on the same image. diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index b5d73b8..da05bf8 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -681,14 +681,20 @@ ApplicationWindow { // -------- FOOTER / STATUS BAR (old version) -------- footer: Rectangle { id: footerRect - implicitHeight: footerRow.implicitHeight + 10 // Add some padding + // Keep footer height fixed so the main image area doesn't change size when + // stack/batch labels appear or disappear (prevents cache invalidations). + property int fixedHeight: 60 + height: fixedHeight + implicitHeight: fixedHeight 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 diff --git a/faststack/faststack/verify_wb.py b/faststack/faststack/verify_wb.py new file mode 100644 index 0000000..212b398 --- /dev/null +++ b/faststack/faststack/verify_wb.py @@ -0,0 +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 + try: + os.remove(black_path) + os.remove(grey_path) + except: + pass + +if __name__ == "__main__": + test_white_balance() From 9ea93e249056867113ccfb625bd138a7f4967e64 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 5 Dec 2025 21:33:18 -0800 Subject: [PATCH 5/9] fix bugs --- faststack/faststack/app.py | 6038 ++++++++--------- faststack/faststack/config.py | 200 +- faststack/faststack/imaging/editor.py | 18 + faststack/faststack/imaging/jpeg.py | 354 +- faststack/faststack/imaging/metadata.py | 390 +- faststack/faststack/imaging/prefetch.py | 1046 +-- .../faststack/io/executable_validator.py | 224 +- faststack/faststack/io/helicon.py | 184 +- faststack/faststack/io/indexer.py | 168 +- faststack/faststack/io/sidecar.py | 194 +- faststack/faststack/io/watcher.py | 148 +- faststack/faststack/logging_setup.py | 92 +- faststack/faststack/models.py | 88 +- faststack/faststack/tests/benchmark_decode.py | 80 +- .../tests/benchmark_decode_bilinear.py | 168 +- faststack/faststack/tests/check_turbo.py | 22 +- faststack/faststack/tests/debug_metadata.py | 120 +- faststack/faststack/tests/test_cache.py | 122 +- .../tests/test_executable_validator.py | 262 +- faststack/faststack/tests/test_metadata.py | 216 +- faststack/faststack/tests/test_pairing.py | 148 +- .../faststack/tests/test_prefetch_logic.py | 134 +- faststack/faststack/tests/test_sidecar.py | 148 +- faststack/faststack/ui/keystrokes.py | 228 +- faststack/faststack/ui/provider.py | 1644 ++--- faststack/faststack/verify_wb.py | 132 +- 26 files changed, 6293 insertions(+), 6275 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 72dce1c..1240268 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -1,3019 +1,3019 @@ -"""Main application entry point for FastStack.""" - -import logging -import sys -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 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 -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: - # 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: - # Handle Enter key in crop mode - if self.ui_state.isCropping and (event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return): - self.execute_crop() - return True - - # Handle ESC key to exit crop mode - if self.ui_state.isCropping and event.key() == Qt.Key_Escape: - self.cancel_crop_mode() - return True - - 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 - - # If editor is open for this image, return the live preview - if self.ui_state.isEditorOpen and self.image_editor.original_image and str(self.image_editor.current_filepath) == str(self.image_files[index].path): - preview_data = self.image_editor.get_preview_data() - if preview_data: - return preview_data - - _, _, 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 future: - try: - # Wait for decode to complete (blocking but fast for JPEGs) - result = future.result(timeout=5.0) # 5 second timeout as safety - if result: - 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 - except concurrent.futures.TimeoutError: - log.exception("Timeout decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except concurrent.futures.CancelledError: - log.warning("Decode cancelled for index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except Exception as e: - log.exception("Error decoding image at index %d", index) - 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 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() - - # 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 - ) - - - # --- Actions --- - - def next_image(self): - if self.current_index < len(self.image_files) - 1: - self.current_index += 1 - self._do_prefetch(self.current_index, is_navigation=True, direction=1) - self.sync_ui_state() - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - - def prev_image(self): - if self.current_index > 0: - self.current_index -= 1 - self._do_prefetch(self.current_index, is_navigation=True, direction=-1) - self.sync_ui_state() - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - - @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.current_index = index - self._do_prefetch(self.current_index, is_navigation=True, direction=direction) - self.sync_ui_state() - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - 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 = True - log.debug("Dialog opened, disabling global keybindings") - - @Slot() - def dialog_closed(self): - """Called when any dialog closes to re-enable global keybindings.""" - self._dialog_open = False - log.debug("Dialog closed, 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_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 "", - "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 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 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.maxsize - 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.maxsize = 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(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_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() - - 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 _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 - - # 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 - - # Move files to recycle bin - deleted_files = [] - try: - if jpg_path.exists(): - dest = self.recycle_bin_dir / jpg_path.name - jpg_path.rename(dest) - deleted_files.append(jpg_path.name) - log.info("Moved %s to recycle bin", jpg_path.name) - - if raw_path and raw_path.exists(): - dest = self.recycle_bin_dir / raw_path.name - raw_path.rename(dest) - deleted_files.append(raw_path.name) - log.info("Moved %s to recycle bin", raw_path.name) - - # Add to delete history only if at least one file was moved - if deleted_files: - import time - timestamp = time.time() - self.delete_history.append((jpg_path, raw_path)) - self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - - except OSError as e: - self.update_status_message(f"Delete failed: {e}") - log.exception("Failed to delete image") - 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) - - previous_index = self.current_index - preserved_path = None - if self.image_files and self.current_index not in indices_to_delete: - preserved_path = self.image_files[self.current_index].path - - # 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: - if jpg_path.exists(): - dest = self.recycle_bin_dir / jpg_path.name - jpg_path.rename(dest) - log.info("Moved %s to recycle bin", jpg_path.name) - - if raw_path and raw_path.exists(): - dest = self.recycle_bin_dir / raw_path.name - raw_path.rename(dest) - log.info("Moved %s to recycle bin", raw_path.name) - - # Add to delete history - self.delete_history.append((jpg_path, raw_path)) - self.undo_history.append(("delete", (jpg_path, raw_path), 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: - self._reposition_after_delete(preserved_path, 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() - - 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.") - - @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": - jpg_path, raw_path = action_data - # Also remove from delete_history - if self.delete_history and self.delete_history[-1] == (jpg_path, raw_path): - self.delete_history.pop() - - restored_files = [] - try: - # Restore JPG - jpg_in_bin = self.recycle_bin_dir / jpg_path.name - if jpg_in_bin.exists(): - jpg_in_bin.rename(jpg_path) - restored_files.append(jpg_path.name) - log.info("Restored %s from recycle bin", jpg_path.name) - - # Restore RAW - if raw_path: - raw_in_bin = self.recycle_bin_dir / raw_path.name - if raw_in_bin.exists(): - raw_in_bin.rename(raw_path) - restored_files.append(raw_path.name) - log.info("Restored %s from recycle bin", raw_path.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_path: - 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", (jpg_path, raw_path), timestamp)) - self.delete_history.append((jpg_path, raw_path)) - - elif action_type == "auto_white_balance": - saved_path, backup_path = action_data - filepath_obj = Path(saved_path) - - try: - backup_path_obj = Path(backup_path) - if backup_path_obj.exists(): - # Restore the backup - filepath_obj.unlink() # Remove the edited version - backup_path_obj.rename(filepath_obj) # Restore backup - log.info("Restored backup %s for %s", backup_path_obj.name, saved_path) - - # Refresh the view - self.refresh_image_list() - - # Find the restored image - for i, img_file in enumerate(self.image_files): - if img_file.path == filepath_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 auto white balance") - else: - # This case should not be reached if glob finds files - self.update_status_message("Backup not found") - log.warning("Backup %s disappeared before it could be restored.", backup_path) - self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) - except OSError as e: - self.update_status_message(f"Undo failed: {e}") - log.exception("Failed to undo auto white balance") - # Put it back in history if it failed - self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) - - elif action_type == "crop": - saved_path, backup_path = action_data - filepath_obj = Path(saved_path) - - try: - backup_path_obj = Path(backup_path) - if backup_path_obj.exists(): - # Restore the backup - filepath_obj.unlink() # Remove the cropped version - backup_path_obj.rename(filepath_obj) # Restore backup - log.info("Restored backup %s for %s", backup_path_obj.name, saved_path) - - # Refresh the view - self.refresh_image_list() - - # Find the restored image - for i, img_file in enumerate(self.image_files): - if img_file.path == filepath_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") - else: - self.update_status_message("Backup not found") - log.warning("Backup %s disappeared before it could be restored.", backup_path) - self.undo_history.append(("crop", (saved_path, backup_path), timestamp)) - except OSError as e: - self.update_status_message(f"Undo failed: {e}") - log.exception("Failed to undo crop") - # Put it back in history if it failed - self.undo_history.append(("crop", (saved_path, backup_path), 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 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.""" - if not self._has_warned_cache_full: - self._has_warned_cache_full = True - # Use QTimer.singleShot to ensure this runs on the main thread if called from a background thread - QTimer.singleShot(0, lambda: self.update_status_message("Cache full! Consider increasing cache size in settings.")) - log.warning("Cache full, eviction started. User warned.") - - 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()] - file_paths = [self.image_files[idx].path for idx in existing_indices] - - 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) - - # --- Image Editor Logic --- - - @Slot(result=bool) - def load_image_for_editing(self): - """Loads the currently viewed image into the editor.""" - if self.image_files and self.current_index < len(self.image_files): - filepath = str(self.image_files[self.current_index].path) - # Only load if the editor is not already open for this file - if str(self.image_editor.current_filepath) == filepath and self.image_editor.original_image is not None: - # Already loaded, just reset UI state for a fresh start - self.reset_edit_parameters() - return True - - # Get the cached, display-sized image to use for fast previews - cached_preview = self.get_decoded_image(self.current_index) - - if self.image_editor.load_image(filepath, cached_preview=cached_preview): - # Pass 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) - - # Set aspect ratios for QML dropdown - self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] - self.ui_state.currentAspectRatioIndex = 0 - self.ui_state.currentCropBox = (0, 0, 1000, 1000) # Reset crop box visually - return True - return False - - @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.""" - if self.image_editor.set_edit_param(key, value): - # Update the corresponding UIState property to reflect the new value in QML - if hasattr(self.ui_state, key): - setattr(self.ui_state, key, value) - - # Trigger a refresh of the image to show the edit - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - - @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.current_edits = self.image_editor._initial_edits() - if hasattr(self.ui_state, 'reset_editor_state'): - self.ui_state.reset_editor_state() - - # Trigger a refresh to show the reset image - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - - @Slot() - def save_edited_image(self): - """Saves the edited image.""" - save_result = self.image_editor.save_image() - if not save_result: - QMessageBox.warning( - None, - "Save Failed", - "Failed to save edited image. Please check the log for details.", - QMessageBox.Ok, - ) - self.update_status_message("Failed to save image") - log.error("Failed to save edited image") - return - - saved_path, _ = save_result - # Clear the image editor state so it will reload fresh next time - self.image_editor.clear() - - # Reset all edit parameters in the controller/UI - self.reset_edit_parameters() - - # Refresh the view - need to refresh image list since backup file was created - original_path = saved_path - 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 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() - - QMessageBox.information( - None, - "Save Successful", - f"Image saved to: {saved_path}. Original backed up.", - QMessageBox.Ok - ) - - - @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) - - @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 - if new_rotation < 0: - new_rotation += 360 - self.set_edit_parameter('rotation', new_rotation) - - @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): - """Update histogram data from current image. - - 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 - """ - if not self.image_files or self.current_index >= len(self.image_files): - return - - try: - import numpy as np - - # Get the current image data - decoded = self.get_decoded_image(self.current_index) - if not decoded: - return - - # If editor is open and has a preview, use that instead - if self.ui_state.isEditorOpen and self.image_editor.original_image: - preview_data = self.image_editor.get_preview_data() - if preview_data: - decoded = preview_data - - # Convert buffer to numpy array - arr = np.frombuffer(decoded.buffer, dtype=np.uint8) - arr = arr.reshape((decoded.height, decoded.width, 3)) - - # If zoomed in, calculate visible region and only use that portion - if zoom > 1.1 and self.ui_state.isZoomed: - # Calculate visible region in image coordinates - # When zoomed, the visible area is the original image size divided by zoom - # The pan_x/pan_y are in screen coordinates relative to transform origin (center) - # image_scale is the scale factor of displayed image vs original - - # Visible size in original image coordinates - visible_width = decoded.width / zoom - visible_height = decoded.height / zoom - - # Center of visible region in image coordinates - # pan_x/pan_y are screen pixel offsets, convert to image pixels - # Account for image_scale: if image is scaled down for display, pan needs scaling too - center_x = decoded.width / 2 - center_y = decoded.height / 2 - - # Convert pan from screen pixels to image pixels - # If image_scale < 1, the image is displayed smaller, so pan needs to be scaled up - 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 - - # The visible center in image coordinates (accounting for pan) - visible_center_x = center_x - (pan_x_image / zoom) - visible_center_y = center_y - (pan_y_image / zoom) - - # Calculate bounds - 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)) - - # Ensure we have valid bounds - if visible_x_end > visible_x_start and visible_y_end > visible_y_start: - # Extract only the visible portion - arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] - log.debug(f"Histogram: Using zoomed region {visible_x_start},{visible_y_start} to {visible_x_end},{visible_y_end} (zoom={zoom:.2f}, pan=({pan_x:.1f},{pan_y:.1f}))") - - # --- New Histogram Logic --- - bins = 256 - value_range = (0, 256) - - # Compute histograms for each channel - 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] - - # Calculate clip and pre-clip counts *before* log scaling - 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])) - - # Apply log scaling for better visualization - log_r_hist = np.log1p(r_hist).tolist() - log_g_hist = np.log1p(g_hist).tolist() - log_b_hist = np.log1p(b_hist).tolist() - - # Create the structured data for QML - histogram_data = { - 'r_hist': log_r_hist, - 'g_hist': log_g_hist, - 'b_hist': 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, - } - - self.ui_state.histogramData = histogram_data - log.debug("Histogram updated with log scale and clip counts") - - except ImportError: - log.error("NumPy not available for histogram computation") - self.update_status_message("Histogram requires NumPy") - except Exception as e: - log.exception("Failed to compute histogram: %s", e) - self.update_status_message(f"Histogram error: {e}") - - @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 - 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: - self.update_status_message("Helicon Focus launched successfully.") - else: - self.update_status_message("Failed to launch Helicon Focus.") - - - @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) - self.update_status_message("Crop cancelled") - log.info("Crop mode cancelled") - - @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 - - # Convert QJSValue to Python list if needed - crop_box_raw = self.ui_state.currentCropBox - try: - # Try to convert QJSValue to list - if hasattr(crop_box_raw, 'toVariant'): - # It's a QJSValue, convert to list - variant = crop_box_raw.toVariant() - if isinstance(variant, (list, tuple)): - crop_box = list(variant) - else: - # Try to iterate if it's iterable - crop_box = [variant[0], variant[1], variant[2], variant[3]] - elif isinstance(crop_box_raw, (list, tuple)): - crop_box = list(crop_box_raw) - else: - # Try direct access (might work for some QJSValue types) - crop_box = [crop_box_raw[0], crop_box_raw[1], crop_box_raw[2], crop_box_raw[3]] - except (TypeError, IndexError, AttributeError) as e: - self.update_status_message("Invalid crop box") - log.error("Failed to parse crop box (type: %s): %s", type(crop_box_raw), e) - return - - if len(crop_box) != 4: - self.update_status_message("Invalid crop box") - return - - if crop_box == [0, 0, 1000, 1000] or crop_box == (0, 0, 1000, 1000): - self.update_status_message("No crop area selected") - return - - image_file = self.image_files[self.current_index] - filepath = str(image_file.path) - - try: - # Load the image - img = Image.open(filepath).convert("RGB") - width, height = img.size - - # Convert normalized crop box (0-1000) to pixel coordinates - left = int(crop_box[0] * width / 1000) - top = int(crop_box[1] * height / 1000) - right = int(crop_box[2] * width / 1000) - bottom = int(crop_box[3] * height / 1000) - - # Ensure valid crop box - left = max(0, min(left, width - 1)) - top = max(0, min(top, height - 1)) - right = max(left + 1, min(right, width)) - bottom = max(top + 1, min(bottom, height)) - - # Crop the image - cropped_img = img.crop((left, top, right, bottom)) - - # Create backup - original_path = Path(filepath) - - # Preserve original file modification time - original_mtime = original_path.stat().st_mtime - original_atime = original_path.stat().st_atime - - backup_path = create_backup_file(original_path) - if backup_path is None: - self.update_status_message("Failed to create backup") - log.error("Failed to create backup for crop operation") - return - - # Save the cropped image - with Image.open(original_path) as original_img: - original_format = original_img.format or original_path.suffix.lstrip('.').upper() - exif_data = original_img.info.get('exif') - - save_kwargs = {} - if original_format == 'JPEG': - save_kwargs['format'] = 'JPEG' - save_kwargs['quality'] = 95 - if exif_data: - save_kwargs['exif'] = exif_data - else: - save_kwargs['format'] = original_format - - try: - cropped_img.save(original_path, **save_kwargs) - except Exception as e: - log.warning(f"Could not save with original format settings: {e}") - cropped_img.save(original_path) - - # Restore original modification and access times to preserve file position in sorted list - import os - os.utime(original_path, (original_atime, original_mtime)) - - # Track for undo - import time - timestamp = time.time() - self.undo_history.append(("crop", (str(original_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 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 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 to fit the new cropped image - self.ui_state.resetZoomPan() - - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - - self.update_status_message("Image cropped and saved") - log.info("Crop operation completed for %s", filepath) - - except Exception as e: - self.update_status_message(f"Crop failed: {e}") - log.exception("Failed to crop 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) - save_result = self.image_editor.save_image() - 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) - _TARGET_A_LAB = 128.0 - _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 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 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 +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: + # 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: + # Handle Enter key in crop mode + if self.ui_state.isCropping and (event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return): + self.execute_crop() + return True + + # Handle ESC key to exit crop mode + if self.ui_state.isCropping and event.key() == Qt.Key_Escape: + self.cancel_crop_mode() + return True + + 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 + + # If editor is open for this image, return the live preview + if self.ui_state.isEditorOpen and self.image_editor.original_image and str(self.image_editor.current_filepath) == str(self.image_files[index].path): + preview_data = self.image_editor.get_preview_data() + if preview_data: + return preview_data + + _, _, 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 future: + try: + # Wait for decode to complete (blocking but fast for JPEGs) + result = future.result(timeout=5.0) # 5 second timeout as safety + if result: + 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 + except concurrent.futures.TimeoutError: + log.exception("Timeout decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except concurrent.futures.CancelledError: + log.warning("Decode cancelled for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except Exception as e: + log.exception("Error decoding image at index %d", index) + 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 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() + + # 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 + ) + + + # --- Actions --- + + def next_image(self): + if self.current_index < len(self.image_files) - 1: + self.current_index += 1 + self._do_prefetch(self.current_index, is_navigation=True, direction=1) + self.sync_ui_state() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + + def prev_image(self): + if self.current_index > 0: + self.current_index -= 1 + self._do_prefetch(self.current_index, is_navigation=True, direction=-1) + self.sync_ui_state() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + + @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.current_index = index + self._do_prefetch(self.current_index, is_navigation=True, direction=direction) + self.sync_ui_state() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + 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 = True + log.debug("Dialog opened, disabling global keybindings") + + @Slot() + def dialog_closed(self): + """Called when any dialog closes to re-enable global keybindings.""" + self._dialog_open = False + log.debug("Dialog closed, 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_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 "", + "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 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 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.maxsize + 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.maxsize = 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(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_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() + + 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 _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 + + # 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 + + # Move files to recycle bin + deleted_files = [] + try: + if jpg_path.exists(): + dest = self.recycle_bin_dir / jpg_path.name + jpg_path.rename(dest) + deleted_files.append(jpg_path.name) + log.info("Moved %s to recycle bin", jpg_path.name) + + if raw_path and raw_path.exists(): + dest = self.recycle_bin_dir / raw_path.name + raw_path.rename(dest) + deleted_files.append(raw_path.name) + log.info("Moved %s to recycle bin", raw_path.name) + + # Add to delete history only if at least one file was moved + if deleted_files: + import time + timestamp = time.time() + self.delete_history.append((jpg_path, raw_path)) + self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) + + except OSError as e: + self.update_status_message(f"Delete failed: {e}") + log.exception("Failed to delete image") + 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) + + previous_index = self.current_index + preserved_path = None + if self.image_files and self.current_index not in indices_to_delete: + preserved_path = self.image_files[self.current_index].path + + # 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: + if jpg_path.exists(): + dest = self.recycle_bin_dir / jpg_path.name + jpg_path.rename(dest) + log.info("Moved %s to recycle bin", jpg_path.name) + + if raw_path and raw_path.exists(): + dest = self.recycle_bin_dir / raw_path.name + raw_path.rename(dest) + log.info("Moved %s to recycle bin", raw_path.name) + + # Add to delete history + self.delete_history.append((jpg_path, raw_path)) + self.undo_history.append(("delete", (jpg_path, raw_path), 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: + self._reposition_after_delete(preserved_path, 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() + + 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.") + + @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": + jpg_path, raw_path = action_data + # Also remove from delete_history + if self.delete_history and self.delete_history[-1] == (jpg_path, raw_path): + self.delete_history.pop() + + restored_files = [] + try: + # Restore JPG + jpg_in_bin = self.recycle_bin_dir / jpg_path.name + if jpg_in_bin.exists(): + jpg_in_bin.rename(jpg_path) + restored_files.append(jpg_path.name) + log.info("Restored %s from recycle bin", jpg_path.name) + + # Restore RAW + if raw_path: + raw_in_bin = self.recycle_bin_dir / raw_path.name + if raw_in_bin.exists(): + raw_in_bin.rename(raw_path) + restored_files.append(raw_path.name) + log.info("Restored %s from recycle bin", raw_path.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_path: + 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", (jpg_path, raw_path), timestamp)) + self.delete_history.append((jpg_path, raw_path)) + + elif action_type == "auto_white_balance": + saved_path, backup_path = action_data + filepath_obj = Path(saved_path) + + try: + backup_path_obj = Path(backup_path) + if backup_path_obj.exists(): + # Restore the backup + filepath_obj.unlink() # Remove the edited version + backup_path_obj.rename(filepath_obj) # Restore backup + log.info("Restored backup %s for %s", backup_path_obj.name, saved_path) + + # Refresh the view + self.refresh_image_list() + + # Find the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == filepath_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 auto white balance") + else: + # This case should not be reached if glob finds files + self.update_status_message("Backup not found") + log.warning("Backup %s disappeared before it could be restored.", backup_path) + self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to undo auto white balance") + # Put it back in history if it failed + self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) + + elif action_type == "crop": + saved_path, backup_path = action_data + filepath_obj = Path(saved_path) + + try: + backup_path_obj = Path(backup_path) + if backup_path_obj.exists(): + # Restore the backup + filepath_obj.unlink() # Remove the cropped version + backup_path_obj.rename(filepath_obj) # Restore backup + log.info("Restored backup %s for %s", backup_path_obj.name, saved_path) + + # Refresh the view + self.refresh_image_list() + + # Find the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == filepath_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") + else: + self.update_status_message("Backup not found") + log.warning("Backup %s disappeared before it could be restored.", backup_path) + self.undo_history.append(("crop", (saved_path, backup_path), timestamp)) + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to undo crop") + # Put it back in history if it failed + self.undo_history.append(("crop", (saved_path, backup_path), 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 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.""" + if not self._has_warned_cache_full: + self._has_warned_cache_full = True + # Use QTimer.singleShot to ensure this runs on the main thread if called from a background thread + QTimer.singleShot(0, lambda: self.update_status_message("Cache full! Consider increasing cache size in settings.")) + log.warning("Cache full, eviction started. User warned.") + + 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()] + file_paths = [self.image_files[idx].path for idx in existing_indices] + + 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) + + # --- Image Editor Logic --- + + @Slot(result=bool) + def load_image_for_editing(self): + """Loads the currently viewed image into the editor.""" + if self.image_files and self.current_index < len(self.image_files): + filepath = str(self.image_files[self.current_index].path) + # Only load if the editor is not already open for this file + if str(self.image_editor.current_filepath) == filepath and self.image_editor.original_image is not None: + # Already loaded, just reset UI state for a fresh start + self.reset_edit_parameters() + return True + + # Get the cached, display-sized image to use for fast previews + cached_preview = self.get_decoded_image(self.current_index) + + if self.image_editor.load_image(filepath, cached_preview=cached_preview): + # Pass 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) + + # Set aspect ratios for QML dropdown + self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + self.ui_state.currentAspectRatioIndex = 0 + self.ui_state.currentCropBox = (0, 0, 1000, 1000) # Reset crop box visually + return True + return False + + @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.""" + if self.image_editor.set_edit_param(key, value): + # Update the corresponding UIState property to reflect the new value in QML + if hasattr(self.ui_state, key): + setattr(self.ui_state, key, value) + + # Trigger a refresh of the image to show the edit + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + + @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.current_edits = self.image_editor._initial_edits() + if hasattr(self.ui_state, 'reset_editor_state'): + self.ui_state.reset_editor_state() + + # Trigger a refresh to show the reset image + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + + @Slot() + def save_edited_image(self): + """Saves the edited image.""" + save_result = self.image_editor.save_image() + if not save_result: + QMessageBox.warning( + None, + "Save Failed", + "Failed to save edited image. Please check the log for details.", + QMessageBox.Ok, + ) + self.update_status_message("Failed to save image") + log.error("Failed to save edited image") + return + + saved_path, _ = save_result + # Clear the image editor state so it will reload fresh next time + self.image_editor.clear() + + # Reset all edit parameters in the controller/UI + self.reset_edit_parameters() + + # Refresh the view - need to refresh image list since backup file was created + original_path = saved_path + 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 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() + + QMessageBox.information( + None, + "Save Successful", + f"Image saved to: {saved_path}. Original backed up.", + QMessageBox.Ok + ) + + + @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) + + @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 + if new_rotation < 0: + new_rotation += 360 + self.set_edit_parameter('rotation', new_rotation) + + @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): + """Update histogram data from current image. + + 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 + """ + if not self.image_files or self.current_index >= len(self.image_files): + return + + try: + import numpy as np + + # Get the current image data + decoded = self.get_decoded_image(self.current_index) + if not decoded: + return + + # If editor is open and has a preview, use that instead + if self.ui_state.isEditorOpen and self.image_editor.original_image: + preview_data = self.image_editor.get_preview_data() + if preview_data: + decoded = preview_data + + # Convert buffer to numpy array + arr = np.frombuffer(decoded.buffer, dtype=np.uint8) + arr = arr.reshape((decoded.height, decoded.width, 3)) + + # If zoomed in, calculate visible region and only use that portion + if zoom > 1.1 and self.ui_state.isZoomed: + # Calculate visible region in image coordinates + # When zoomed, the visible area is the original image size divided by zoom + # The pan_x/pan_y are in screen coordinates relative to transform origin (center) + # image_scale is the scale factor of displayed image vs original + + # Visible size in original image coordinates + visible_width = decoded.width / zoom + visible_height = decoded.height / zoom + + # Center of visible region in image coordinates + # pan_x/pan_y are screen pixel offsets, convert to image pixels + # Account for image_scale: if image is scaled down for display, pan needs scaling too + center_x = decoded.width / 2 + center_y = decoded.height / 2 + + # Convert pan from screen pixels to image pixels + # If image_scale < 1, the image is displayed smaller, so pan needs to be scaled up + 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 + + # The visible center in image coordinates (accounting for pan) + visible_center_x = center_x - (pan_x_image / zoom) + visible_center_y = center_y - (pan_y_image / zoom) + + # Calculate bounds + 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)) + + # Ensure we have valid bounds + if visible_x_end > visible_x_start and visible_y_end > visible_y_start: + # Extract only the visible portion + arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] + log.debug(f"Histogram: Using zoomed region {visible_x_start},{visible_y_start} to {visible_x_end},{visible_y_end} (zoom={zoom:.2f}, pan=({pan_x:.1f},{pan_y:.1f}))") + + # --- New Histogram Logic --- + bins = 256 + value_range = (0, 256) + + # Compute histograms for each channel + 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] + + # Calculate clip and pre-clip counts *before* log scaling + 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])) + + # Apply log scaling for better visualization + log_r_hist = np.log1p(r_hist).tolist() + log_g_hist = np.log1p(g_hist).tolist() + log_b_hist = np.log1p(b_hist).tolist() + + # Create the structured data for QML + histogram_data = { + 'r_hist': log_r_hist, + 'g_hist': log_g_hist, + 'b_hist': 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, + } + + self.ui_state.histogramData = histogram_data + log.debug("Histogram updated with log scale and clip counts") + + except ImportError: + log.error("NumPy not available for histogram computation") + self.update_status_message("Histogram requires NumPy") + except Exception as e: + log.exception("Failed to compute histogram: %s", e) + self.update_status_message(f"Histogram error: {e}") + + @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 + 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: + self.update_status_message("Helicon Focus launched successfully.") + else: + self.update_status_message("Failed to launch Helicon Focus.") + + + @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) + self.update_status_message("Crop cancelled") + log.info("Crop mode cancelled") + + @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 + + # Convert QJSValue to Python list if needed + crop_box_raw = self.ui_state.currentCropBox + try: + # Try to convert QJSValue to list + if hasattr(crop_box_raw, 'toVariant'): + # It's a QJSValue, convert to list + variant = crop_box_raw.toVariant() + if isinstance(variant, (list, tuple)): + crop_box = list(variant) + else: + # Try to iterate if it's iterable + crop_box = [variant[0], variant[1], variant[2], variant[3]] + elif isinstance(crop_box_raw, (list, tuple)): + crop_box = list(crop_box_raw) + else: + # Try direct access (might work for some QJSValue types) + crop_box = [crop_box_raw[0], crop_box_raw[1], crop_box_raw[2], crop_box_raw[3]] + except (TypeError, IndexError, AttributeError) as e: + self.update_status_message("Invalid crop box") + log.error("Failed to parse crop box (type: %s): %s", type(crop_box_raw), e) + return + + if len(crop_box) != 4: + self.update_status_message("Invalid crop box") + return + + if crop_box == [0, 0, 1000, 1000] or crop_box == (0, 0, 1000, 1000): + self.update_status_message("No crop area selected") + return + + image_file = self.image_files[self.current_index] + filepath = str(image_file.path) + + try: + # Load the image + img = Image.open(filepath).convert("RGB") + width, height = img.size + + # Convert normalized crop box (0-1000) to pixel coordinates + left = int(crop_box[0] * width / 1000) + top = int(crop_box[1] * height / 1000) + right = int(crop_box[2] * width / 1000) + bottom = int(crop_box[3] * height / 1000) + + # Ensure valid crop box + left = max(0, min(left, width - 1)) + top = max(0, min(top, height - 1)) + right = max(left + 1, min(right, width)) + bottom = max(top + 1, min(bottom, height)) + + # Crop the image + cropped_img = img.crop((left, top, right, bottom)) + + # Create backup + original_path = Path(filepath) + + # Preserve original file modification time + original_mtime = original_path.stat().st_mtime + original_atime = original_path.stat().st_atime + + backup_path = create_backup_file(original_path) + if backup_path is None: + self.update_status_message("Failed to create backup") + log.error("Failed to create backup for crop operation") + return + + # Save the cropped image + with Image.open(original_path) as original_img: + original_format = original_img.format or original_path.suffix.lstrip('.').upper() + exif_data = original_img.info.get('exif') + + save_kwargs = {} + if original_format == 'JPEG': + save_kwargs['format'] = 'JPEG' + save_kwargs['quality'] = 95 + if exif_data: + save_kwargs['exif'] = exif_data + else: + save_kwargs['format'] = original_format + + try: + cropped_img.save(original_path, **save_kwargs) + except Exception as e: + log.warning(f"Could not save with original format settings: {e}") + cropped_img.save(original_path) + + # Restore original modification and access times to preserve file position in sorted list + import os + os.utime(original_path, (original_atime, original_mtime)) + + # Track for undo + import time + timestamp = time.time() + self.undo_history.append(("crop", (str(original_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 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 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 to fit the new cropped image + self.ui_state.resetZoomPan() + + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + + self.update_status_message("Image cropped and saved") + log.info("Crop operation completed for %s", filepath) + + except Exception as e: + self.update_status_message(f"Crop failed: {e}") + log.exception("Failed to crop 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) + save_result = self.image_editor.save_image() + 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) + _TARGET_A_LAB = 128.0 + _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/faststack/config.py b/faststack/faststack/config.py index 55a19ab..a5a249b 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -1,100 +1,100 @@ -"""Manages application configuration via an INI file.""" - -import configparser -import logging -from pathlib import Path - -from faststack.logging_setup import get_app_data_dir - -log = logging.getLogger(__name__) - -DEFAULT_CONFIG = { - "core": { - "cache_size_gb": "1.5", - "prefetch_radius": "4", - "theme": "dark", - "default_directory": "", - "optimize_for": "speed", # "speed" or "quality" - }, - "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", - "luma_lower_bound": "30", - "luma_upper_bound": "220", - "rgb_lower_bound": "5", - "rgb_upper_bound": "250", - }, - "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 - - - 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 +from pathlib import Path + +from faststack.logging_setup import get_app_data_dir + +log = logging.getLogger(__name__) + +DEFAULT_CONFIG = { + "core": { + "cache_size_gb": "1.5", + "prefetch_radius": "4", + "theme": "dark", + "default_directory": "", + "optimize_for": "speed", # "speed" or "quality" + }, + "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", + "luma_lower_bound": "30", + "luma_upper_bound": "220", + "rgb_lower_bound": "5", + "rgb_upper_bound": "250", + }, + "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 + + + 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/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index fc7d6dc..1a58999 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -90,6 +90,7 @@ def _initial_edits(self) -> Dict[str, Any]: 'blacks': 0.0, 'whites': 0.0, 'clarity': 0.0, + 'texture': 0.0, } def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = None): @@ -267,6 +268,23 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: arr = arr * vignette_mask img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + # 14. Texture (Fine Detail Local Contrast) + # Similar to Clarity but with a smaller radius to target texture/fine details + texture = self.current_edits.get('texture', 0.0) + if abs(texture) > 0.001: + arr = np.array(img, dtype=np.float32) + luminance = 0.299 * arr[:,:,0] + 0.587 * arr[:,:,1] + 0.114 * arr[:,:,2] + lum_img = Image.fromarray(luminance.astype(np.uint8)) + # Smaller radius for texture compared to clarity (20) + blurred = lum_img.filter(ImageFilter.GaussianBlur(radius=2.0)) + blurred_arr = np.array(blurred, dtype=np.float32) + # Apply texture enhancement primarily to midtones + midtone_mask = 1.0 - np.abs(luminance - 128) / 128.0 + local_details = (luminance - blurred_arr) * texture * midtone_mask + for c in range(3): + arr[:,:,c] += local_details + img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + return img def get_preview_data(self) -> Optional[DecodedImage]: diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index 1173b5b..44b3eda 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -1,177 +1,177 @@ -"""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)) - - 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.BILINEAR # Changed from LANCZOS to BILINEAR for speed - - 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)) + + 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.BILINEAR # Changed from LANCZOS to BILINEAR for speed + + 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/faststack/imaging/metadata.py b/faststack/faststack/imaging/metadata.py index 39997cc..faf5699 100644 --- a/faststack/faststack/imaging/metadata.py +++ b/faststack/faststack/imaging/metadata.py @@ -1,195 +1,195 @@ - -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) - exif = img._getexif() - if not exif: - return {"summary": {}, "full": {}} - except Exception as e: - 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) + exif = img._getexif() + if not exif: + return {"summary": {}, "full": {}} + except Exception as e: + 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/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index f9753a4..77408d4 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -11,535 +11,535 @@ import numpy as np from PIL import Image as PILImage, ImageCms - + 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.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): - """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(): - """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] - -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: 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. - - # Clean up old generation entries to prevent memory leak - old_generations = [g for g in self._scheduled if g < self.generation] - for g in old_generations: - del self._scheduled[g] - - # 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) - - # Get scheduled set for current generation - scheduled = self._scheduled.setdefault(self.generation, set()) - - # Cancel stale futures and remove from scheduled - 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 - """ - 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 - # 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 - + +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): + """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(): + """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] + +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: 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. + + # Clean up old generation entries to prevent memory leak + old_generations = [g for g in self._scheduled if g < self.generation] + for g in old_generations: + del self._scheduled[g] + + # 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) + + # Get scheduled set for current generation + scheduled = self._scheduled.setdefault(self.generation, set()) + + # Cancel stale futures and remove from scheduled + 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 + """ + 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 + # 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() - - # 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: - # 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 - - # 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 - 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! Decoders accept bytes-like objects - if use_resized: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: - 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() - - # Convert numpy array to PIL Image for ICC conversion - img = PILImage.fromarray(buffer) - t_after_array_to_pil = time.perf_counter() - - # Extract ICC profile from original file (need to read header only) - t_before_profile_read = time.perf_counter() - with PILImage.open(image_file.path) as orig: - icc_bytes = orig.info.get("icc_profile") - 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) - h, w, _ = rgb.shape - bytes_per_line = w * 3 - arr = rgb.reshape(-1).copy() - 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, w, h) - 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: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: - 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() - - h, w, _ = buffer.shape - bytes_per_line = w * 3 - arr = buffer.reshape(-1).copy() - - if self.debug: - decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.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_fallback_decode - t_start, w, h) - 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: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: - 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() - - h, w, _ = buffer.shape - bytes_per_line = w * 3 - arr = buffer.reshape(-1).copy() - - 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, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_decode - t_start, w, h) - - else: - # Standard decode path (Option A or no color management) - 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! Decoders accept bytes-like objects - if use_resized: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) - else: - # Quality mode: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: - 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() - - h, w, _ = buffer.shape - bytes_per_line = w * 3 - arr = buffer.reshape(-1).copy() - t_after_copy = time.perf_counter() - - # Option A: Saturation compensation - if color_mode == "saturation": - try: - t_before_saturation = time.perf_counter() - factor = float(config.get('color', 'saturation_factor', fallback="1.0")) - 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_before_saturation, - 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=arr.data, - width=w, - height=h, - bytes_per_line=bytes_per_line, - format=None # Placeholder for QImage.Format.Format_RGB888 - ) + """The actual work done by the thread pool.""" + import time + + t_start = time.perf_counter() + + # 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: + # 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 + + # 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 + 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! Decoders accept bytes-like objects + if use_resized: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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() + + # Convert numpy array to PIL Image for ICC conversion + img = PILImage.fromarray(buffer) + t_after_array_to_pil = time.perf_counter() + + # Extract ICC profile from original file (need to read header only) + t_before_profile_read = time.perf_counter() + with PILImage.open(image_file.path) as orig: + icc_bytes = orig.info.get("icc_profile") + 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) + h, w, _ = rgb.shape + bytes_per_line = w * 3 + arr = rgb.reshape(-1).copy() + 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, w, h) + 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: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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() + + h, w, _ = buffer.shape + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() + + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.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_fallback_decode - t_start, w, h) + 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: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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() + + h, w, _ = buffer.shape + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() + + 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, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_decode - t_start, w, h) + + else: + # Standard decode path (Option A or no color management) + 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! Decoders accept bytes-like objects + if use_resized: + buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + else: + # Quality mode: decode full image then resize with high quality + buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + if buffer is not None: + 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() + + h, w, _ = buffer.shape + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() + t_after_copy = time.perf_counter() + + # Option A: Saturation compensation + if color_mode == "saturation": + try: + t_before_saturation = time.perf_counter() + factor = float(config.get('color', 'saturation_factor', fallback="1.0")) + 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_before_saturation, + 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=arr.data, + width=w, + height=h, + bytes_per_line=bytes_per_line, + format=None # Placeholder for QImage.Format.Format_RGB888 + ) 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.""" - 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) + + 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.""" + 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/faststack/io/executable_validator.py b/faststack/faststack/io/executable_validator.py index 501c7f0..c251afb 100644 --- a/faststack/faststack/io/executable_validator.py +++ b/faststack/faststack/io/executable_validator.py @@ -1,112 +1,112 @@ -"""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}" - ) - # This is a warning, not a hard failure, but log it - - # 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}") - 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).""" - 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}" + ) + # This is a warning, not a hard failure, but log it + + # 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}") + 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).""" + 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/faststack/io/helicon.py b/faststack/faststack/io/helicon.py index 86ac9f0..c1539d1 100644 --- a/faststack/faststack/io/helicon.py +++ b/faststack/faststack/io/helicon.py @@ -1,92 +1,92 @@ -"""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. - """ - 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. + """ + 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/faststack/io/indexer.py b/faststack/faststack/io/indexer.py index d55683e..5322a79 100644 --- a/faststack/faststack/io/indexer.py +++ b/faststack/faststack/io/indexer.py @@ -1,84 +1,84 @@ -"""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) - 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: - 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 [] - - # Sort JPGs by modification time (oldest first), then filename - jpgs.sort(key=lambda x: (x[1].st_mtime, x[0].name)) - - image_files: List[ImageFile] = [] - for jpg_path, jpg_stat in jpgs: - raw_pair = _find_raw_pair(jpg_path, jpg_stat, raws.get(jpg_path.stem, [])) - image_files.append(ImageFile( - path=jpg_path, - raw_pair=raw_pair, - timestamp=jpg_stat.st_mtime, - )) - - elapsed = time.perf_counter() - t_start - paired_count = sum(1 for im in image_files if im.raw_pair) - - if log.isEnabledFor(logging.DEBUG): - log.info("Found %d JPG files and paired %d with RAWs in %.3fs", len(image_files), paired_count, elapsed) - else: - log.info("Found %d JPG files and paired %d with RAWs.", len(image_files), paired_count) - return image_files - -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 - - # Removed per-pair debug logging to reduce noise - summary is logged at end of find_images() - 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) + 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: + 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 [] + + # Sort JPGs by modification time (oldest first), then filename + jpgs.sort(key=lambda x: (x[1].st_mtime, x[0].name)) + + image_files: List[ImageFile] = [] + for jpg_path, jpg_stat in jpgs: + raw_pair = _find_raw_pair(jpg_path, jpg_stat, raws.get(jpg_path.stem, [])) + image_files.append(ImageFile( + path=jpg_path, + raw_pair=raw_pair, + timestamp=jpg_stat.st_mtime, + )) + + elapsed = time.perf_counter() - t_start + paired_count = sum(1 for im in image_files if im.raw_pair) + + if log.isEnabledFor(logging.DEBUG): + log.info("Found %d JPG files and paired %d with RAWs in %.3fs", len(image_files), paired_count, elapsed) + else: + log.info("Found %d JPG files and paired %d with RAWs.", len(image_files), paired_count) + return image_files + +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 + + # Removed per-pair debug logging to reduce noise - summary is logged at end of find_images() + return best_match diff --git a/faststack/faststack/io/sidecar.py b/faststack/faststack/io/sidecar.py index fcff8e7..9ffacb0 100644 --- a/faststack/faststack/io/sidecar.py +++ b/faststack/faststack/io/sidecar.py @@ -1,97 +1,97 @@ -"""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__) - -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(**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__) + +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(**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/faststack/io/watcher.py b/faststack/faststack/io/watcher.py index 938c169..034a54c 100644 --- a/faststack/faststack/io/watcher.py +++ b/faststack/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/faststack/logging_setup.py b/faststack/faststack/logging_setup.py index 6c413ce..824e814 100644 --- a/faststack/faststack/logging_setup.py +++ b/faststack/faststack/logging_setup.py @@ -1,46 +1,46 @@ -"""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 INFO to reduce noise. - """ - log_dir = get_app_data_dir() / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - log_file = log_dir / "app.log" - - handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=10*1024*1024, backupCount=5 - ) - formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - handler.setFormatter(formatter) - - root_logger = logging.getLogger() - # Set log level based on debug flag - root_logger.setLevel(logging.DEBUG if debug else logging.INFO) - root_logger.handlers.clear() - root_logger.addHandler(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 INFO to reduce noise. + """ + log_dir = get_app_data_dir() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "app.log" + + handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=10*1024*1024, backupCount=5 + ) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + # Set log level based on debug flag + root_logger.setLevel(logging.DEBUG if debug else logging.INFO) + root_logger.handlers.clear() + root_logger.addHandler(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/faststack/models.py b/faststack/faststack/models.py index 094aaa3..ea00c4c 100644 --- a/faststack/faststack/models.py +++ b/faststack/faststack/models.py @@ -1,44 +1,44 @@ -"""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 - -@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 - - -@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 + +@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 + + +@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/faststack/tests/benchmark_decode.py b/faststack/faststack/tests/benchmark_decode.py index 19ead5a..9a41156 100644 --- a/faststack/faststack/tests/benchmark_decode.py +++ b/faststack/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/faststack/tests/benchmark_decode_bilinear.py b/faststack/faststack/tests/benchmark_decode_bilinear.py index 12cb58a..b262770 100644 --- a/faststack/faststack/tests/benchmark_decode_bilinear.py +++ b/faststack/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/faststack/tests/check_turbo.py b/faststack/faststack/tests/check_turbo.py index 1fb0afc..1d62690 100644 --- a/faststack/faststack/tests/check_turbo.py +++ b/faststack/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/faststack/tests/debug_metadata.py b/faststack/faststack/tests/debug_metadata.py index d2c0dea..e05066f 100644 --- a/faststack/faststack/tests/debug_metadata.py +++ b/faststack/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/faststack/tests/test_cache.py b/faststack/faststack/tests/test_cache.py index e3f90a1..f36d4dc 100644 --- a/faststack/faststack/tests/test_cache.py +++ b/faststack/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.maxsize == 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.maxsize == 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/faststack/tests/test_executable_validator.py b/faststack/faststack/tests/test_executable_validator.py index 2a80a0d..c70e2a8 100644 --- a/faststack/faststack/tests/test_executable_validator.py +++ b/faststack/faststack/tests/test_executable_validator.py @@ -1,131 +1,131 @@ -"""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', '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', '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/faststack/tests/test_metadata.py b/faststack/faststack/tests/test_metadata.py index 9618651..80e2e48 100644 --- a/faststack/faststack/tests/test_metadata.py +++ b/faststack/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("() 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_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", - } - - 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() - log.debug(f"Key pressed: {key} ({text!r}) with modifiers {event.modifiers()}") - - # Check for modifier + key combinations - for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): - if key == mapped_key and event.modifiers() & mapped_modifier: - 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_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", + } + + 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() + log.debug(f"Key pressed: {key} ({text!r}) with modifiers {event.modifiers()}") + + # Check for modifier + key combinations + for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): + if key == mapped_key and event.modifiers() & mapped_modifier: + 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/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index c85b033..f512407 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -1,822 +1,822 @@ -"""QML Image Provider and application state bridge.""" - -import logging -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 - -# 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) - - def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: - """Handles image requests from QML.""" - if not id: - return self.placeholder - - try: - image_index_str = id.split('/')[0] - index = int(image_index_str) - image_data = self.app_controller.get_decoded_image(index) - - if image_data: - qimg = QImage( - image_data.buffer, - image_data.width, - image_data.height, - image_data.bytes_per_line, - QImage.Format.Format_RGB888 - ) - # 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)") - # keep buffer alive - qimg.original_buffer = image_data.buffer - 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 - 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() - awbLumaLowerBoundChanged = Signal() - awbLumaUpperBoundChanged = Signal() - awbRgbLowerBoundChanged = Signal() - awbRgbUpperBoundChanged = Signal() - default_directory_changed = Signal(str) - isStackedJpgChanged = Signal() # New signal for isStackedJpg - # Image Editor Signals - is_editor_open_changed = Signal(bool) - is_cropping_changed = Signal(bool) - is_histogram_visible_changed = Signal(bool) - histogram_data_changed = Signal() - 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 - 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) - - # Debug Cache Signals - debugCacheChanged = Signal(bool) - cacheStatsChanged = Signal(str) - isDecodingChanged = Signal(bool) - - 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 = None # 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._aspect_ratio_names = [] - 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 - - # Debug Cache State - self._debug_cache = False - self._cache_stats = "" - self._is_decoding = False - - # ---- 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) - - # ---- 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(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=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=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") - - # --- 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 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() - - @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(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 - self.app_controller.update_histogram() - - @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(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: - 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) - - # --- 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) - - # --- 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) +"""QML Image Provider and application state bridge.""" + +import logging +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 + +# 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) + + def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: + """Handles image requests from QML.""" + if not id: + return self.placeholder + + try: + image_index_str = id.split('/')[0] + index = int(image_index_str) + image_data = self.app_controller.get_decoded_image(index) + + if image_data: + qimg = QImage( + image_data.buffer, + image_data.width, + image_data.height, + image_data.bytes_per_line, + QImage.Format.Format_RGB888 + ) + # 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)") + # keep buffer alive + qimg.original_buffer = image_data.buffer + 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 + 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() + awbLumaLowerBoundChanged = Signal() + awbLumaUpperBoundChanged = Signal() + awbRgbLowerBoundChanged = Signal() + awbRgbUpperBoundChanged = Signal() + default_directory_changed = Signal(str) + isStackedJpgChanged = Signal() # New signal for isStackedJpg + # Image Editor Signals + is_editor_open_changed = Signal(bool) + is_cropping_changed = Signal(bool) + is_histogram_visible_changed = Signal(bool) + histogram_data_changed = Signal() + 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 + 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) + + # Debug Cache Signals + debugCacheChanged = Signal(bool) + cacheStatsChanged = Signal(str) + isDecodingChanged = Signal(bool) + + 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 = None # 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._aspect_ratio_names = [] + 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 + + # Debug Cache State + self._debug_cache = False + self._cache_stats = "" + self._is_decoding = False + + # ---- 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) + + # ---- 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(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=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=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") + + # --- 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 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() + + @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(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 + self.app_controller.update_histogram() + + @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(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: + 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) + + # --- 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) + + # --- 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) diff --git a/faststack/faststack/verify_wb.py b/faststack/faststack/verify_wb.py index 212b398..ea1d6b0 100644 --- a/faststack/faststack/verify_wb.py +++ b/faststack/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 - try: - os.remove(black_path) - os.remove(grey_path) - except: - pass - -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 + try: + os.remove(black_path) + os.remove(grey_path) + except: + pass + +if __name__ == "__main__": + test_white_balance() From 548604d97d28df7a7faa568da3dee57e5908c7fe Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 5 Dec 2025 21:45:34 -0800 Subject: [PATCH 6/9] fix crop --- faststack/ChangeLog.md | 436 ++-- faststack/README.md | 134 +- faststack/faststack.egg-info/PKG-INFO | 142 +- faststack/faststack/debug_output.txt | 26 +- faststack/faststack/faststack.json | 10 +- faststack/faststack/qml/Components.qml | 1581 ++++++------- faststack/faststack/qml/DeleteBatchDialog.qml | 244 +- faststack/faststack/qml/ExifDialog.qml | 202 +- faststack/faststack/qml/FilterDialog.qml | 174 +- faststack/faststack/qml/ImageEditorDialog.qml | 71 +- faststack/faststack/qml/JumpToImageDialog.qml | 180 +- faststack/faststack/qml/Main.qml | 1954 ++++++++--------- faststack/faststack/qml/SettingsDialog.qml | 640 +++--- faststack/pyproject.toml | 68 +- faststack/requirements.txt | 16 +- 15 files changed, 2971 insertions(+), 2907 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 1c7ad7b..194f766 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,218 +1,218 @@ -# ChangeLog - -Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. - -## [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.A -- Added a setting that switches between image display optimized for speed or quality. - -## [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 increassing 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]). -## [0.8.0] - 2025-11-20 - -### Added -- 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.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.A +- Added a setting that switches between image display optimized for speed or quality. + +## [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 increassing 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]). +## [0.8.0] - 2025-11-20 + +### Added +- 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/faststack/README.md b/faststack/README.md index af32470..49ddd54 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,67 +1,67 @@ -# FastStack - -# Version 1.4 - December 1, 2025 -# 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 images via the cr(O)p hotkey. 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, even for backup files -- **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 & Usage - -1. **Install Dependencies:** - ```bash - pip install -r requirements.txt - ``` - -2. **Run the App:** - ```bash - python -m faststack.app "C:\path\to\your\images" - ``` - -## Keyboard Shortcuts - -- `J` / `Right Arrow`: Next Image -- `K` / `Left Arrow`: Previous Image -- `G`: Go to image # -- `S`: Toggle selection of current image for stacking -- `B`: Toggle selection of current image for batch drag & drop -- `[`: Begin new stack group -- `]`: End current stack group -- `{`: 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`: Toggle crop mode (cr(O)p hotkey; Enter to crop, Esc to cancel) -- `Delete` / `Backspace`: Move image to recycle bin -- `Ctrl+Z`: Undo last action (delete or auto white balance) -- `A`: Quick auto white balance (saves automatically) -- `E`: Toggle Image Editor -- `Ctrl+C`: Copy image path to clipboard -- `Ctrl+0`: Reset zoom and pan -- `C`: Clear all stacks +# FastStack + +# Version 1.4 - December 1, 2025 +# 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 images via the cr(O)p hotkey. 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, even for backup files +- **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 & Usage + +1. **Install Dependencies:** + ```bash + pip install -r requirements.txt + ``` + +2. **Run the App:** + ```bash + python -m faststack.app "C:\path\to\your\images" + ``` + +## Keyboard Shortcuts + +- `J` / `Right Arrow`: Next Image +- `K` / `Left Arrow`: Previous Image +- `G`: Go to image # +- `S`: Toggle selection of current image for stacking +- `B`: Toggle selection of current image for batch drag & drop +- `[`: Begin new stack group +- `]`: End current stack group +- `{`: 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`: Toggle crop mode (cr(O)p hotkey; Enter to crop, Esc to cancel) +- `Delete` / `Backspace`: Move image to recycle bin +- `Ctrl+Z`: Undo last action (delete or auto white balance) +- `A`: Quick auto white balance (saves automatically) +- `E`: Toggle Image Editor +- `Ctrl+C`: Copy image path to clipboard +- `Ctrl+0`: Reset zoom and pan +- `C`: Clear all stacks diff --git a/faststack/faststack.egg-info/PKG-INFO b/faststack/faststack.egg-info/PKG-INFO index bb5c9e1..2dfb203 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -1,71 +1,71 @@ -Metadata-Version: 2.4 -Name: faststack -Version: 1.3 -Summary: Ultra-fast JPG Viewer for Focus Stacking Selection and website upload via drag and drop -Author-email: Alan Rockefeller -Classifier: Programming Language :: Python :: 3 -Classifier: License :: OSI Approved :: MIT License -Classifier: Operating System :: Microsoft :: Windows -Requires-Python: >=3.11 -Description-Content-Type: text/markdown -License-File: LICENSE -Requires-Dist: PySide6<7.0,>=6.0 -Requires-Dist: PyTurboJPEG<2.0,>=1.8 -Requires-Dist: numpy<3.0,>=2.0 -Requires-Dist: cachetools<6.0,>=5.0 -Requires-Dist: watchdog<5.0,>=4.0 -Requires-Dist: Pillow<11.0,>=10.0 -Requires-Dist: pytest<9.0,>=8.0 -Dynamic: license-file - -# FastStack - -# Version 1.3 - November 23, 2025 -# By Alan Rockefeller - -Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking. - -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 - -- **Instant Navigation:** Sub-10ms next/previous image switching on cache hits. -- **High-Performance Decoding:** Uses `PyTurboJPEG` for fast JPEG decoding, with a fallback to `Pillow`. -- **Zoom & Pan:** Smooth, mipmapped zooming and panning. -- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). -- **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`). -- **Sidecar Metadata:** Saves flags, rejections, and stack groupings to a non-destructive `faststack.json` file. -- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus path via a settings dialog and a persistent `.ini` file. -- **Photoshop Integration:** Edit current image in Photoshop (E key) -- **Clipboard Support:** Copy image path to clipboard (Ctrl+C) -- **Image Filtering:** Filter images by filename -- **Drag & Drop:** Drag images to external applications -- **Theme Support:** Toggle between light and dark themes - -## Installation & Usage - -1. **Install Dependencies:** - ```bash - pip install -r requirements.txt - ``` - -2. **Run the App:** - ```bash - python -m faststack.app "C:\path\to\your\images" - ``` - -## Keyboard Shortcuts - -- `J` / `Right Arrow`: Next Image -- `K` / `Left Arrow`: Previous Image -- `G`: Toggle Grid View -- `S`: Toggle selection of current image for stacking -- `[`: Begin new stack group -- `]`: End current stack group -- `Space`: Toggle Flag -- `X`: Toggle Reject -- `Enter`: Launch Helicon Focus with selected RAWs -- `E`: Edit in Photoshop -- `Ctrl+C`: Copy image path to clipboard -- `C`: Clear all stacks +Metadata-Version: 2.4 +Name: faststack +Version: 1.3 +Summary: Ultra-fast JPG Viewer for Focus Stacking Selection and website upload via drag and drop +Author-email: Alan Rockefeller +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: Microsoft :: Windows +Requires-Python: >=3.11 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: PySide6<7.0,>=6.0 +Requires-Dist: PyTurboJPEG<2.0,>=1.8 +Requires-Dist: numpy<3.0,>=2.0 +Requires-Dist: cachetools<6.0,>=5.0 +Requires-Dist: watchdog<5.0,>=4.0 +Requires-Dist: Pillow<11.0,>=10.0 +Requires-Dist: pytest<9.0,>=8.0 +Dynamic: license-file + +# FastStack + +# Version 1.3 - November 23, 2025 +# By Alan Rockefeller + +Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking. + +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 + +- **Instant Navigation:** Sub-10ms next/previous image switching on cache hits. +- **High-Performance Decoding:** Uses `PyTurboJPEG` for fast JPEG decoding, with a fallback to `Pillow`. +- **Zoom & Pan:** Smooth, mipmapped zooming and panning. +- **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). +- **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`). +- **Sidecar Metadata:** Saves flags, rejections, and stack groupings to a non-destructive `faststack.json` file. +- **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus path via a settings dialog and a persistent `.ini` file. +- **Photoshop Integration:** Edit current image in Photoshop (E key) +- **Clipboard Support:** Copy image path to clipboard (Ctrl+C) +- **Image Filtering:** Filter images by filename +- **Drag & Drop:** Drag images to external applications +- **Theme Support:** Toggle between light and dark themes + +## Installation & Usage + +1. **Install Dependencies:** + ```bash + pip install -r requirements.txt + ``` + +2. **Run the App:** + ```bash + python -m faststack.app "C:\path\to\your\images" + ``` + +## Keyboard Shortcuts + +- `J` / `Right Arrow`: Next Image +- `K` / `Left Arrow`: Previous Image +- `G`: Toggle Grid View +- `S`: Toggle selection of current image for stacking +- `[`: Begin new stack group +- `]`: End current stack group +- `Space`: Toggle Flag +- `X`: Toggle Reject +- `Enter`: Launch Helicon Focus with selected RAWs +- `E`: Edit in Photoshop +- `Ctrl+C`: Copy image path to clipboard +- `C`: Clear all stacks diff --git a/faststack/faststack/debug_output.txt b/faststack/faststack/debug_output.txt index 0d5afaf..55eb5c7 100644 --- a/faststack/faststack/debug_output.txt +++ b/faststack/faststack/debug_output.txt @@ -1,13 +1,13 @@ -Starting debug test... -Calling get_exif_data... -Result Summary: { - "Date Taken": "2023:01:01 12:00:00", - "Camera": "Canon EOS R5", - "Lens": "RF 24-70mm F2.8L IS USM", - "ISO": "100", - "Aperture": "f/2.8", - "Shutter Speed": "1/200s", - "Focal Length": "50mm" -} -Result Full Keys: ['DateTimeOriginal', 'Make', 'Model', 'LensModel', 'ISOSpeedRatings', 'FNumber', 'ExposureTime', 'FocalLength'] -Test PASSED +Starting debug test... +Calling get_exif_data... +Result Summary: { + "Date Taken": "2023:01:01 12:00:00", + "Camera": "Canon EOS R5", + "Lens": "RF 24-70mm F2.8L IS USM", + "ISO": "100", + "Aperture": "f/2.8", + "Shutter Speed": "1/200s", + "Focal Length": "50mm" +} +Result Full Keys: ['DateTimeOriginal', 'Make', 'Model', 'LensModel', 'ISOSpeedRatings', 'FNumber', 'ExposureTime', 'FocalLength'] +Test PASSED diff --git a/faststack/faststack/faststack.json b/faststack/faststack/faststack.json index 82bff3b..d9c7526 100644 --- a/faststack/faststack/faststack.json +++ b/faststack/faststack/faststack.json @@ -1,6 +1,6 @@ -{ - "version": 2, - "last_index": 0, - "entries": {}, - "stacks": [] +{ + "version": 2, + "last_index": 0, + "entries": {}, + "stacks": [] } \ No newline at end of file diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 85955b6..4fb9412 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -1,782 +1,799 @@ -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 - - - // Connection to handle zoom/pan reset signal from Python - Connections { - target: uiState - function onResetZoomPanRequested() { - scaleTransform.xScale = 1.0 - scaleTransform.yScale = 1.0 - panTransform.x = 0 - panTransform.y = 0 - } - } - - // The main image display - Image { - id: mainImage - anchors.fill: parent - source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" - fillMode: Image.PreserveAspectFit - cache: false // We do our own caching in Python - smooth: uiState && !uiState.anySliderPressed && !isZooming - mipmap: uiState && !uiState.anySliderPressed && !isZooming - - property bool isZooming: false - - Component.onCompleted: { - if (width > 0 && height > 0) { - var dpr = Screen.devicePixelRatio - uiState.onDisplaySizeChanged(Math.round(width * dpr), Math.round(height * dpr)) - } - } - - onWidthChanged: { - if (width > 0 && height > 0) { - resizeDebounceTimer.restart() - } - } - - onHeightChanged: { - if (width > 0 && height > 0) { - resizeDebounceTimer.restart() - } - } - - function updateZoomState() { - if (scaleTransform.xScale > 1.1 && !uiState.isZoomed) { - uiState.setZoomed(true); - } else if (scaleTransform.xScale <= 1.0 && uiState.isZoomed) { - uiState.setZoomed(false); - } - - // Update histogram with zoom/pan info if histogram is visible - if (uiState && uiState.isHistogramVisible && controller) { - var zoom = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - // Calculate image scale (painted size vs actual size) - var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 - controller.update_histogram(zoom, panX, panY, imageScale) - } - } - - function updateHistogramWithZoom() { - if (uiState && uiState.isHistogramVisible && controller) { - var zoom = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 - controller.update_histogram(zoom, panX, panY, imageScale) - } - } - - property alias scaleTransform: scaleTransform - property alias panTransform: panTransform - - transform: [ - Scale { - id: scaleTransform - origin.x: mainImage.width / 2 - origin.y: mainImage.height / 2 - onXScaleChanged: { - mainImage.updateZoomState() - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() - } - onYScaleChanged: { - mainImage.updateZoomState() - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() - } - }, - Translate { - id: panTransform - onXChanged: { - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() - } - onYChanged: { - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() - } - } - ] - } - - // Zoom and Pan logic would go here - // For example, using PinchArea or MouseArea - Timer { - id: resizeDebounceTimer - interval: 100 // milliseconds - running: false - onTriggered: { - if (mainImage.width > 0 && mainImage.height > 0) { - var dpr = Screen.devicePixelRatio - uiState.onDisplaySizeChanged(Math.round(mainImage.width * dpr), Math.round(mainImage.height * dpr)) - } - running = false - } - } - - MouseArea { - id: mainMouseArea - anchors.fill: parent - acceptedButtons: Qt.LeftButton - 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 - - onPressed: function(mouse) { - lastX = mouse.x - lastY = mouse.y - startX = mouse.x - startY = mouse.y - isDraggingOutside = false - - if (uiState && uiState.isCropping) { - // Check if clicking on existing crop box - var cropRect = getCropRect() - var box = uiState.currentCropBox - var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 - - var edgeThreshold = 10 * Screen.devicePixelRatio - var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && - mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height - - // If crop box is full image, always start a new crop - if (isFullImage) { - cropDragMode = "new" - cropStartX = mouse.x - cropStartY = mouse.y - } else if (inside && cropRect.width > 0 && cropRect.height > 0) { - // Determine which edge/corner is being dragged - var nearLeft = Math.abs(mouse.x - cropRect.x) < edgeThreshold - var nearRight = Math.abs(mouse.x - (cropRect.x + cropRect.width)) < edgeThreshold - var nearTop = Math.abs(mouse.y - cropRect.y) < edgeThreshold - var nearBottom = Math.abs(mouse.y - (cropRect.y + cropRect.height)) < 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 - var box = uiState.currentCropBox - if (!box || box.length !== 4) return - 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 - } - isCropDragging = true - } - } - function getCropRect() { - if (!mainImage.source || !uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) { - return {x: 0, y: 0, width: 0, height: 0} - } - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - var box = uiState.currentCropBox - - // Account for zoom and pan transforms when displaying crop box - var scale = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - - // Convert normalized crop box (0-1000) to image-local coordinates - var localX = (box[0] / 1000) * imgWidth - var localY = (box[1] / 1000) * imgHeight - var localWidth = (box[2] - box[0]) / 1000 * imgWidth - var localHeight = (box[3] - box[1]) / 1000 * imgHeight - - // Apply zoom and pan transforms to get screen coordinates - return { - x: imgX + (localX * scale) + panX, - y: imgY + (localY * scale) + panY, - width: localWidth * scale, - height: localHeight * scale - } - } - onPositionChanged: function(mouse) { - if (uiState && uiState.isCropping && isCropDragging) { - if (cropDragMode === "new") { - // Update crop rectangle while dragging - updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y) - } else if (cropDragMode !== "none") { - // Refine existing crop box - var cropRect = getCropRect() - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - - // Account for zoom and pan transforms when converting mouse position - var scale = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - - // Convert screen coordinates to image-local coordinates (accounting for pan) - var localX = (mouse.x - imgX - panX) / scale - var localY = (mouse.y - imgY - panY) / scale - - // Convert to normalized image coordinates (0-1 range) - var mouseX = localX / imgWidth - var mouseY = localY / imgHeight - - // Clamp to image bounds and convert to 0-1000 range - mouseX = Math.max(0, Math.min(1, mouseX)) * 1000 - mouseY = Math.max(0, Math.min(1, mouseY)) * 1000 - - var left = cropBoxStartLeft - var top = cropBoxStartTop - var right = cropBoxStartRight - var bottom = cropBoxStartBottom - - // Adjust based on drag mode - if (cropDragMode === "move") { - var dx = mouseX - (cropBoxStartLeft + cropBoxStartRight) / 2 - var dy = mouseY - (cropBoxStartTop + cropBoxStartBottom) / 2 - 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 === "left") { - left = Math.max(0, Math.min(right - 10, mouseX)) - } else if (cropDragMode === "right") { - right = Math.max(left + 10, Math.min(1000, mouseX)) - } else if (cropDragMode === "top") { - top = Math.max(0, Math.min(bottom - 10, mouseY)) - } else if (cropDragMode === "bottom") { - bottom = Math.max(top + 10, Math.min(1000, mouseY)) - } else if (cropDragMode === "topleft") { - left = Math.max(0, Math.min(right - 10, mouseX)) - top = Math.max(0, Math.min(bottom - 10, mouseY)) - } else if (cropDragMode === "topright") { - right = Math.max(left + 10, Math.min(1000, mouseX)) - top = Math.max(0, Math.min(bottom - 10, mouseY)) - } else if (cropDragMode === "bottomleft") { - left = Math.max(0, Math.min(right - 10, mouseX)) - bottom = Math.max(top + 10, Math.min(1000, mouseY)) - } else if (cropDragMode === "bottomright") { - right = Math.max(left + 10, Math.min(1000, mouseX)) - bottom = Math.max(top + 10, Math.min(1000, mouseY)) - } - - - var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom) - 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 - 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) { - isCropDragging = false - cropDragMode = "none" - } - } - - // 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 = scaleTransform.xScale - var newScale = oldScale * scaleFactor - newScale = Math.max(0.1, Math.min(20.0, newScale)) - - // Get the image's painted (displayed) bounds - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var centerX = mainImage.width / 2 - var centerY = mainImage.height / 2 - - if (isZoomingIn) { - // Zoom in: zoom towards cursor position - var mouseX = wheel.x - var mouseY = wheel.y - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - - // Calculate the point in the image that's under the cursor - var pointInImageX = mouseX - imgX - var pointInImageY = mouseY - imgY - - // Only zoom towards cursor if cursor is over the image - if (pointInImageX >= 0 && pointInImageX <= imgWidth && - pointInImageY >= 0 && pointInImageY <= imgHeight) { - - // Calculate offset from image center in screen coordinates - var centerOffsetX = pointInImageX - imgWidth / 2 - var centerOffsetY = pointInImageY - imgHeight / 2 - - // The current screen position of a point is: (imgPoint * oldScale) + oldPan + center - // We want to find what's currently under the cursor and keep it there - // Instead of dividing by oldScale (which loses precision), work with scaled values - - // Calculate what the scaled image point currently is (before zoom) - // This is: (centerOffset - pan) which represents (imgPoint * oldScale) - var scaledImagePointX = centerOffsetX - panTransform.x - var scaledImagePointY = centerOffsetY - panTransform.y - - // Adjust the scale origin to the cursor position - scaleTransform.origin.x = mouseX - scaleTransform.origin.y = mouseY - - // Apply the new scale first - scaleTransform.xScale = newScale - scaleTransform.yScale = newScale - - // After zoom, the scaled image point becomes: scaledImagePoint * (newScale / oldScale) - // We want it to stay at the same screen position, so: - // newPan = centerOffset - (scaledImagePoint * newScale / oldScale) - // Use scaleRatio to avoid precision loss from repeated division - var scaleRatio = newScale / oldScale - var newPanX = centerOffsetX - (scaledImagePointX * scaleRatio) - var newPanY = centerOffsetY - (scaledImagePointY * scaleRatio) - - // Apply the adjusted pan - panTransform.x = newPanX - panTransform.y = newPanY - } else { - // If cursor is outside image, zoom from center - scaleTransform.origin.x = centerX - scaleTransform.origin.y = centerY - scaleTransform.xScale = newScale - scaleTransform.yScale = newScale - } - } else { - // Zoom out: always zoom towards center of screen - scaleTransform.origin.x = centerX - scaleTransform.origin.y = centerY - - // When zooming out, we need to adjust pan to keep the center visible - // The pan values are in screen coordinates, but they represent image-space offsets - // When scale changes, we need to scale the pan proportionally to maintain - // the same visual position relative to the center - var scaleRatio = newScale / oldScale - - // Adjust pan to keep the center point fixed - // If we're zooming out (scaleRatio < 1), pan should be reduced proportionally - panTransform.x = panTransform.x * scaleRatio - panTransform.y = panTransform.y * scaleRatio - - // Apply the new scale - scaleTransform.xScale = newScale - scaleTransform.yScale = newScale - } - - // 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) { - if (!uiState || !mainImage.source) return - - // Get image display bounds (accounting for PreserveAspectFit) - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - - // Account for zoom and pan transforms - // The transforms are applied in order: Scale then Translate - // To reverse: subtract pan, then divide by scale - var scale = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - - // Convert screen coordinates to image-local coordinates (accounting for pan) - var localX1 = (x1 - imgX - panX) / scale - var localY1 = (y1 - imgY - panY) / scale - var localX2 = (x2 - imgX - panX) / scale - var localY2 = (y2 - imgY - panY) / scale - - // Convert to normalized image coordinates (0-1 range) - var imgCoordX1 = localX1 / imgWidth - var imgCoordY1 = localY1 / imgHeight - var imgCoordX2 = localX2 / imgWidth - var imgCoordY2 = localY2 / imgHeight - - // Clamp to image bounds - imgCoordX1 = Math.max(0, Math.min(1, imgCoordX1)) - imgCoordY1 = Math.max(0, Math.min(1, imgCoordY1)) - imgCoordX2 = Math.max(0, Math.min(1, imgCoordX2)) - imgCoordY2 = Math.max(0, Math.min(1, imgCoordY2)) - - // Ensure left < right and top < bottom - 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 - - // Ensure minimum size - if (right - left < 10) { - if (right < 1000) right = left + 10 - else left = right - 10 - } - if (bottom - top < 10) { - if (bottom < 1000) bottom = top + 10 - else top = bottom - 10 - } - - var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom) - 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)] - } - - 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] - return null - } - - function applyAspectRatioConstraint(left, top, right, bottom) { - if (uiState.currentAspectRatioIndex > 0 && uiState.aspectRatioNames && uiState.aspectRatioNames.length > uiState.currentAspectRatioIndex) { - var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex] - var ratio = getAspectRatio(ratioName) - if (ratio) { - var currentWidth = right - left - var currentHeight = bottom - top - var targetAspect = ratio[0] / ratio[1] - var currentAspect = currentWidth / currentHeight - - if (currentAspect > targetAspect) { - // Too wide, adjust height - var newHeight = currentWidth / targetAspect - var centerY = (top + bottom) / 2 - top = centerY - newHeight / 2 - bottom = centerY + newHeight / 2 - // Clamp to image bounds - if (top < 0) { - bottom += -top - top = 0 - } - if (bottom > 1000) { - top -= (bottom - 1000) - bottom = 1000 - } - } else { - // Too tall, adjust width - var newWidth = currentHeight * targetAspect - var centerX = (left + right) / 2 - left = centerX - newWidth / 2 - right = centerX + newWidth / 2 - // Clamp to image bounds - if (left < 0) { - right += -left - left = 0 - } - if (right > 1000) { - left -= (right - 1000) - right = 1000 - } - } - } - } - return [left, top, right, bottom] - } - - function updateCropBoxFromAspectRatio() { - if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return - var box = uiState.currentCropBox - updateCropBox( - box[0] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, - box[1] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2, - box[2] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, - box[3] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2 - ) - } - } - - // Crop rectangle overlay - 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 - anchors.fill: parent - z: 100 - - onCropBoxChanged: { - if (!mainImage.source) return - updateCropRect() - } - - Component.onCompleted: { - if (mainImage.source) updateCropRect() - } - - Connections { - target: mainImage - function onPaintedWidthChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } - function onPaintedHeightChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } - } - - Connections { - target: uiState - function onCurrentCropBoxChanged() { - cropOverlay.cropBox = uiState.currentCropBox - if (cropOverlay.visible && mainImage.source) { - cropOverlay.updateCropRect() - } - } - } - - function updateCropRect() { - if (!mainImage.source) return - - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - - // Account for zoom and pan transforms when displaying crop box - var scale = mainImage.scaleTransform ? mainImage.scaleTransform.xScale : 1.0 - var panX = mainImage.panTransform ? mainImage.panTransform.x : 0 - var panY = mainImage.panTransform ? mainImage.panTransform.y : 0 - - // Convert normalized crop box (0-1000) to image-local coordinates - var localLeft = (cropBox[0] / 1000) * imgWidth - var localTop = (cropBox[1] / 1000) * imgHeight - var localRight = (cropBox[2] / 1000) * imgWidth - var localBottom = (cropBox[3] / 1000) * imgHeight - - // Apply zoom and pan transforms to get screen coordinates - var left = imgX + (localLeft * scale) + panX - var top = imgY + (localTop * scale) + panY - var right = imgX + (localRight * scale) + panX - var bottom = imgY + (localBottom * scale) + panY - - cropRect.x = left - cropRect.y = top - cropRect.width = right - left - cropRect.height = bottom - top - } - - // Semi-transparent overlay - draw 4 rectangles around the crop area - Rectangle { - // Top - x: 0 - y: 0 - width: parent.width - height: cropRect.y - color: "black" - opacity: 0.3 - } - Rectangle { - // Bottom - x: 0 - y: cropRect.y + cropRect.height - width: parent.width - height: parent.height - (cropRect.y + cropRect.height) - color: "black" - opacity: 0.3 - } - Rectangle { - // Left - x: 0 - y: cropRect.y - width: cropRect.x - height: cropRect.height - color: "black" - opacity: 0.3 - } - Rectangle { - // Right - x: cropRect.x + cropRect.width - y: cropRect.y - width: parent.width - (cropRect.x + cropRect.width) - height: cropRect.height - color: "black" - opacity: 0.3 - } - - // Crop rectangle with thick white border - Rectangle { - id: cropRect - color: "transparent" - border.color: "white" - border.width: 3 - } - } - - // 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: 200 - 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: "white" - 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() - } - } - } - } - } - } - } - } - - -} +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 + + + // Connection to handle zoom/pan reset signal from Python + Connections { + target: uiState + function onResetZoomPanRequested() { + scaleTransform.xScale = 1.0 + scaleTransform.yScale = 1.0 + panTransform.x = 0 + panTransform.y = 0 + } + } + + // The main image display + Image { + id: mainImage + anchors.fill: parent + source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" + fillMode: Image.PreserveAspectFit + cache: false // We do our own caching in Python + smooth: uiState && !uiState.anySliderPressed && !isZooming + mipmap: uiState && !uiState.anySliderPressed && !isZooming + + property bool isZooming: false + + Component.onCompleted: { + if (width > 0 && height > 0) { + var dpr = Screen.devicePixelRatio + uiState.onDisplaySizeChanged(Math.round(width * dpr), Math.round(height * dpr)) + } + } + + onWidthChanged: { + if (width > 0 && height > 0) { + resizeDebounceTimer.restart() + } + } + + onHeightChanged: { + if (width > 0 && height > 0) { + resizeDebounceTimer.restart() + } + } + + function updateZoomState() { + if (scaleTransform.xScale > 1.1 && !uiState.isZoomed) { + uiState.setZoomed(true); + } else if (scaleTransform.xScale <= 1.0 && uiState.isZoomed) { + uiState.setZoomed(false); + } + + // Update histogram with zoom/pan info if histogram is visible + if (uiState && uiState.isHistogramVisible && controller) { + var zoom = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + // Calculate image scale (painted size vs actual size) + var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 + controller.update_histogram(zoom, panX, panY, imageScale) + } + } + + function updateHistogramWithZoom() { + if (uiState && uiState.isHistogramVisible && controller) { + var zoom = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 + controller.update_histogram(zoom, panX, panY, imageScale) + } + } + + property alias scaleTransform: scaleTransform + property alias panTransform: panTransform + + transform: [ + Scale { + id: scaleTransform + origin.x: mainImage.width / 2 + origin.y: mainImage.height / 2 + onXScaleChanged: { + mainImage.updateZoomState() + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + onYScaleChanged: { + mainImage.updateZoomState() + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + }, + Translate { + id: panTransform + onXChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + onYChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + } + ] + } + + // Zoom and Pan logic would go here + // For example, using PinchArea or MouseArea + Timer { + id: resizeDebounceTimer + interval: 100 // milliseconds + running: false + onTriggered: { + if (mainImage.width > 0 && mainImage.height > 0) { + var dpr = Screen.devicePixelRatio + uiState.onDisplaySizeChanged(Math.round(mainImage.width * dpr), Math.round(mainImage.height * dpr)) + } + running = false + } + } + + MouseArea { + id: mainMouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + 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 + + onPressed: function(mouse) { + lastX = mouse.x + lastY = mouse.y + startX = mouse.x + startY = mouse.y + isDraggingOutside = false + + if (uiState && uiState.isCropping) { + // Check if clicking on existing crop box + var cropRect = getCropRect() + var box = uiState.currentCropBox + var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 + + var edgeThreshold = 10 * Screen.devicePixelRatio + var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && + mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height + + // If crop box is full image, always start a new crop + if (isFullImage) { + cropDragMode = "new" + cropStartX = mouse.x + cropStartY = mouse.y + } else if (inside && cropRect.width > 0 && cropRect.height > 0) { + // Determine which edge/corner is being dragged + var nearLeft = Math.abs(mouse.x - cropRect.x) < edgeThreshold + var nearRight = Math.abs(mouse.x - (cropRect.x + cropRect.width)) < edgeThreshold + var nearTop = Math.abs(mouse.y - cropRect.y) < edgeThreshold + var nearBottom = Math.abs(mouse.y - (cropRect.y + cropRect.height)) < 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 + var box = uiState.currentCropBox + if (!box || box.length !== 4) return + 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 + } + isCropDragging = true + } + } + function getCropRect() { + if (!mainImage.source || !uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) { + return {x: 0, y: 0, width: 0, height: 0} + } + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + var box = uiState.currentCropBox + + // Account for zoom and pan transforms when displaying crop box + var scale = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + + // Convert normalized crop box (0-1000) to image-local coordinates + var localX = (box[0] / 1000) * imgWidth + var localY = (box[1] / 1000) * imgHeight + var localWidth = (box[2] - box[0]) / 1000 * imgWidth + var localHeight = (box[3] - box[1]) / 1000 * imgHeight + + // Apply zoom and pan transforms to get screen coordinates + return { + x: imgX + (localX * scale) + panX, + y: imgY + (localY * scale) + panY, + width: localWidth * scale, + height: localHeight * scale + } + } + function mapToImageCoordinates(screenPoint) { + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + + var scale = scaleTransform.xScale + var panX = panTransform.x + var panY = panTransform.y + + var originX = scaleTransform.origin.x + var originY = scaleTransform.origin.y + + var x_no_pan = screenPoint.x - panX + var y_no_pan = screenPoint.y - panY + + var x_no_scale = originX + (x_no_pan - originX) / scale + var y_no_scale = originY + (y_no_pan - originY) / scale + + var localX = x_no_scale - imgX + var localY = y_no_scale - imgY + + return {x: localX / imgWidth, y: localY / imgHeight} + } + 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 !== "none") { + + 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 + 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) { + isCropDragging = false + cropDragMode = "none" + } + } + + // 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 = scaleTransform.xScale + var newScale = oldScale * scaleFactor + newScale = Math.max(0.1, Math.min(20.0, newScale)) + + // Get the image's painted (displayed) bounds + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var centerX = mainImage.width / 2 + var centerY = mainImage.height / 2 + + if (isZoomingIn) { + // Zoom in: zoom towards cursor position + var mouseX = wheel.x + var mouseY = wheel.y + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + + // Calculate the point in the image that's under the cursor + var pointInImageX = mouseX - imgX + var pointInImageY = mouseY - imgY + + // Only zoom towards cursor if cursor is over the image + if (pointInImageX >= 0 && pointInImageX <= imgWidth && + pointInImageY >= 0 && pointInImageY <= imgHeight) { + + // Calculate offset from image center in screen coordinates + var centerOffsetX = pointInImageX - imgWidth / 2 + var centerOffsetY = pointInImageY - imgHeight / 2 + + // The current screen position of a point is: (imgPoint * oldScale) + oldPan + center + // We want to find what's currently under the cursor and keep it there + // Instead of dividing by oldScale (which loses precision), work with scaled values + + // Calculate what the scaled image point currently is (before zoom) + // This is: (centerOffset - pan) which represents (imgPoint * oldScale) + var scaledImagePointX = centerOffsetX - panTransform.x + var scaledImagePointY = centerOffsetY - panTransform.y + + // Adjust the scale origin to the cursor position + scaleTransform.origin.x = mouseX + scaleTransform.origin.y = mouseY + + // Apply the new scale first + scaleTransform.xScale = newScale + scaleTransform.yScale = newScale + + // After zoom, the scaled image point becomes: scaledImagePoint * (newScale / oldScale) + // We want it to stay at the same screen position, so: + // newPan = centerOffset - (scaledImagePoint * newScale / oldScale) + // Use scaleRatio to avoid precision loss from repeated division + var scaleRatio = newScale / oldScale + var newPanX = centerOffsetX - (scaledImagePointX * scaleRatio) + var newPanY = centerOffsetY - (scaledImagePointY * scaleRatio) + + // Apply the adjusted pan + panTransform.x = newPanX + panTransform.y = newPanY + } else { + // If cursor is outside image, zoom from center + scaleTransform.origin.x = centerX + scaleTransform.origin.y = centerY + scaleTransform.xScale = newScale + scaleTransform.yScale = newScale + } + } else { + // Zoom out: always zoom towards center of screen + scaleTransform.origin.x = centerX + scaleTransform.origin.y = centerY + + // When zooming out, we need to adjust pan to keep the center visible + // The pan values are in screen coordinates, but they represent image-space offsets + // When scale changes, we need to scale the pan proportionally to maintain + // the same visual position relative to the center + var scaleRatio = newScale / oldScale + + // Adjust pan to keep the center point fixed + // If we're zooming out (scaleRatio < 1), pan should be reduced proportionally + panTransform.x = panTransform.x * scaleRatio + panTransform.y = panTransform.y * scaleRatio + + // Apply the new scale + scaleTransform.xScale = newScale + scaleTransform.yScale = newScale + } + + // 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 + 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)) + + // Ensure left < right and top < bottom + 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 + + // Ensure minimum size + if (right - left < 10) { + if (right < 1000) right = left + 10 + else left = right - 10 + } + if (bottom - top < 10) { + if (bottom < 1000) bottom = top + 10 + else top = bottom - 10 + } + + if (applyAspectRatio) { + var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, "new") + 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)] + } + + 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) { + return [left, top, right, bottom]; + } + + var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; + var ratio = getAspectRatio(ratioName); + if (!ratio) { + return [left, top, right, bottom]; + } + + var targetAspect = ratio[0] / ratio[1]; + var width = right - left; + var height = bottom - top; + + // Adjust dimensions based on which edge/corner is being dragged + if (dragMode.includes("left") || dragMode.includes("right")) { + height = width / targetAspect; + } else if (dragMode.includes("top") || dragMode.includes("bottom")) { + width = height * targetAspect; + } else if (dragMode === "new") { + if(width / height > targetAspect) { + width = height * targetAspect; + } else { + height = width / targetAspect; + } + } + + + // Anchor the box to the correct edge/corner + if (dragMode.includes("top")) { + top = bottom - height; + } else { + bottom = top + height; + } + + if (dragMode.includes("left")) { + left = right - width; + } else { + right = left + width; + } + + + // Clamp to image bounds and readjust + if (left < 0) { + left = 0; + right = width; + } + if (right > 1000) { + right = 1000; + left = 1000 - width; + } + if (top < 0) { + top = 0; + bottom = height; + } + if (bottom > 1000) { + bottom = 1000; + top = 1000 - height; + } + + // Final check to ensure aspect ratio is maintained after clamping + var finalWidth = right - left; + var finalHeight = bottom - top; + + if(Math.abs(finalWidth / finalHeight - targetAspect) > 0.01) { + if (dragMode.includes("left") || dragMode.includes("right")) { + finalWidth = finalHeight * targetAspect; + if(dragMode.includes("left")) { + left = right - finalWidth; + } else { + right = left + finalWidth; + } + } else { + finalHeight = finalWidth / targetAspect; + if(dragMode.includes("top")) { + top = bottom - finalHeight; + } else { + bottom = top + finalHeight; + } + } + } + + + return [left, top, right, bottom]; + } + + function updateCropBoxFromAspectRatio() { + if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return + var box = uiState.currentCropBox + updateCropBox( + box[0] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, + box[1] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2, + box[2] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, + box[3] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2, + true + ) + } + } + + // Crop rectangle overlay + 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 + anchors.fill: parent + z: 100 + + onCropBoxChanged: { + if (!mainImage.source) return + updateCropRect() + } + + Component.onCompleted: { + if (mainImage.source) updateCropRect() + } + + Connections { + target: mainImage + function onPaintedWidthChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } + function onPaintedHeightChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } + } + + Connections { + target: uiState + function onCurrentCropBoxChanged() { + cropOverlay.cropBox = uiState.currentCropBox + if (cropOverlay.visible && mainImage.source) { + cropOverlay.updateCropRect() + } + } + } + + function updateCropRect() { + if (!mainImage.source) return + + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + + // Account for zoom and pan transforms when displaying crop box + var scale = mainImage.scaleTransform ? mainImage.scaleTransform.xScale : 1.0 + var panX = mainImage.panTransform ? mainImage.panTransform.x : 0 + var panY = mainImage.panTransform ? mainImage.panTransform.y : 0 + + // Convert normalized crop box (0-1000) to image-local coordinates + var localLeft = (cropBox[0] / 1000) * imgWidth + var localTop = (cropBox[1] / 1000) * imgHeight + var localRight = (cropBox[2] / 1000) * imgWidth + var localBottom = (cropBox[3] / 1000) * imgHeight + + // Apply zoom and pan transforms to get screen coordinates + var left = imgX + (localLeft * scale) + panX + var top = imgY + (localTop * scale) + panY + var right = imgX + (localRight * scale) + panX + var bottom = imgY + (localBottom * scale) + panY + + cropRect.x = left + cropRect.y = top + cropRect.width = right - left + cropRect.height = bottom - top + } + + // Semi-transparent overlay - draw 4 rectangles around the crop area + Rectangle { + // Top + x: 0 + y: 0 + width: parent.width + height: cropRect.y + color: "black" + opacity: 0.3 + } + Rectangle { + // Bottom + x: 0 + y: cropRect.y + cropRect.height + width: parent.width + height: parent.height - (cropRect.y + cropRect.height) + color: "black" + opacity: 0.3 + } + Rectangle { + // Left + x: 0 + y: cropRect.y + width: cropRect.x + height: cropRect.height + color: "black" + opacity: 0.3 + } + Rectangle { + // Right + x: cropRect.x + cropRect.width + y: cropRect.y + width: parent.width - (cropRect.x + cropRect.width) + height: cropRect.height + color: "black" + opacity: 0.3 + } + + // Crop rectangle with thick white border + Rectangle { + id: cropRect + color: "transparent" + border.color: "white" + border.width: 3 + } + } + + // 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: 200 + 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: "white" + 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() + } + } + } + } + } + } + } + } + + +} diff --git a/faststack/faststack/qml/DeleteBatchDialog.qml b/faststack/faststack/qml/DeleteBatchDialog.qml index e7bb0aa..38e47f2 100644 --- a/faststack/faststack/qml/DeleteBatchDialog.qml +++ b/faststack/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: "white" - 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: "white" + 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/faststack/qml/ExifDialog.qml b/faststack/faststack/qml/ExifDialog.qml index 3f1aa4e..f6bd5f7 100644 --- a/faststack/faststack/qml/ExifDialog.qml +++ b/faststack/faststack/qml/ExifDialog.qml @@ -1,101 +1,101 @@ -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 - controller.dialog_opened() - } - - onClosed: { - controller.dialog_closed() - } - - contentItem: ColumnLayout { - spacing: 10 - - 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 + controller.dialog_opened() + } + + onClosed: { + controller.dialog_closed() + } + + contentItem: ColumnLayout { + spacing: 10 + + 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/faststack/qml/FilterDialog.qml b/faststack/faststack/qml/FilterDialog.qml index 7fccc44..e233291 100644 --- a/faststack/faststack/qml/FilterDialog.qml +++ b/faststack/faststack/qml/FilterDialog.qml @@ -1,87 +1,87 @@ -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: filterDialog.backgroundColor - } - - 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.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 - controller.dialog_opened() - } - - onClosed: { - // Notify Python that dialog is 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: filterDialog.backgroundColor + } + + 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.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 + controller.dialog_opened() + } + + onClosed: { + // Notify Python that dialog is closed + controller.dialog_closed() + } +} diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index 618c237..de3045d 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -61,11 +61,11 @@ Window { ListModel { id: lightModel ListElement { name: "Exposure"; key: "exposure" } + ListElement { name: "Brightness"; key: "brightness" } ListElement { name: "Highlights"; key: "highlights" } ListElement { name: "Shadows"; key: "shadows" } - ListElement { name: "Whites"; key: "whites" } + ListElement { name: "Whites"; key: "whites"; reverse: true } ListElement { name: "Blacks"; key: "blacks" } - ListElement { name: "Brightness"; key: "brightness" } ListElement { name: "Contrast"; key: "contrast" } } Repeater { model: lightModel; delegate: editSlider } @@ -75,6 +75,7 @@ Window { ListModel { id: detailModel ListElement { name: "Clarity"; key: "clarity" } + ListElement { name: "Texture"; key: "texture" } ListElement { name: "Sharpness"; key: "sharpness" } } Repeater { model: detailModel; delegate: editSlider } @@ -92,8 +93,8 @@ Window { id: colorModel ListElement { name: "Saturation"; key: "saturation"; reverse: false } ListElement { name: "Vibrance"; key: "vibrance"; reverse: false } - ListElement { name: "White Balance (B/Y)"; key: "white_balance_by"; reverse: false } - ListElement { name: "White Balance (G/M)"; key: "white_balance_mg"; reverse: false } + ListElement { name: "White Balance (Blue/Yellow)"; key: "white_balance_by"; reverse: false } + ListElement { name: "White Balance (Green/Magenta)"; key: "white_balance_mg"; reverse: false } } Repeater { model: colorModel; delegate: editSlider } @@ -135,12 +136,15 @@ Window { } } Button { - text: "Save Edited Image (Ctrl+S)" + text: "Save and Close Editor (Ctrl+S)" Layout.fillWidth: true - onClicked: controller.save_edited_image() + onClicked: { + controller.save_edited_image() + uiState.isEditorOpen = false + } } Button { - text: "Close Editor (E)" + text: "Close Without Saving (E)" Layout.fillWidth: true onClicked: { uiState.isEditorOpen = false @@ -159,12 +163,41 @@ Window { property bool isReversed: model.reverse !== undefined ? model.reverse : false property real displayValue: isReversed ? -slider.value : slider.value - Text { - text: model.name + ": " + displayValue.toFixed(0) - color: imageEditorDialog.textColor - font.pixelSize: 14 - wrapMode: Text.WordWrap + RowLayout { Layout.fillWidth: true + spacing: 5 + + Text { + text: model.name + ":" + color: imageEditorDialog.textColor + font.pixelSize: 14 + } + + Item { Layout.fillWidth: true } // Spacer + + TextInput { + id: valueInput + text: displayValue.toFixed(0) + color: imageEditorDialog.textColor + font.pixelSize: 14 + selectByMouse: true + validator: IntValidator { bottom: model.min === undefined ? -100 : model.min; top: model.max === undefined ? 100 : model.max } + + onEditingFinished: { + var val = parseInt(text) + if (isNaN(val)) return + + // Clamp value + var min = model.min === undefined ? -100 : model.min + var max = model.max === undefined ? 100 : model.max + if (val < min) val = min + if (val > max) val = max + + var sendValue = isReversed ? -val : val + controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) + editDialog.updatePulse++ // Force slider update + } + } } Slider { id: slider @@ -197,6 +230,20 @@ Window { controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) } + // Double click to reset + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + propagateComposedEvents: true + onDoubleClicked: (mouse) => { + controller.set_edit_parameter(model.key, 0.0) + editDialog.updatePulse++ + } + onPressed: (mouse) => { + mouse.accepted = false // Let slider handle drag + } + } + onPressedChanged: { if (pressed) editDialog.slidersPressedCount++; else editDialog.slidersPressedCount--; } diff --git a/faststack/faststack/qml/JumpToImageDialog.qml b/faststack/faststack/qml/JumpToImageDialog.qml index b5d5e3d..37512fc 100644 --- a/faststack/faststack/qml/JumpToImageDialog.qml +++ b/faststack/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/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index da05bf8..223ad88 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -1,684 +1,684 @@ -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 ? "#2b2b2b" : "#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() - } - - Connections { - target: uiState - function onThemeChanged() { - root.isDarkTheme = uiState.theme === 0 - } - } - - // -------- 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: { - if (uiState) { - settingsDialog.heliconPath = uiState.get_helicon_path() - settingsDialog.photoshopPath = uiState.get_photoshop_path() - settingsDialog.cacheSize = uiState.get_cache_size() - settingsDialog.prefetchRadius = uiState.get_prefetch_radius() - settingsDialog.theme = uiState.theme - settingsDialog.defaultDirectory = uiState.get_default_directory() - settingsDialog.optimizeFor = uiState.get_optimize_for() - settingsDialog.awbMode = uiState.awbMode - settingsDialog.awbStrength = uiState.awbStrength - settingsDialog.awbWarmBias = uiState.awbWarmBias - settingsDialog.awbLumaLowerBound = uiState.awbLumaLowerBound - settingsDialog.awbLumaUpperBound = uiState.awbLumaUpperBound - settingsDialog.awbRgbLowerBound = uiState.awbRgbLowerBound - settingsDialog.awbRgbUpperBound = uiState.awbRgbUpperBound - } - 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 - - // 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 - } - } - } - } - - // -------- MAIN VIEW -------- - Item { - id: contentArea - anchors.fill: parent - - Loader { - id: mainViewLoader - anchors.fill: parent - source: "Components.qml" - focus: true - - // Key bindings implemented in old Main.qml - Keys.onPressed: function(event) { - if (!uiState || !controller) { - return - } - - // Toggle Image Editor with 'E' key - // If editor is open, close it without saving. Otherwise open it. - if (event.key === Qt.Key_E && !event.isAutoRepeat) { - if (uiState.isEditorOpen) { - // Close editor without saving - uiState.isEditorOpen = false - } else { - // Open editor - uiState.isEditorOpen = true - if (controller) { - controller.load_image_for_editing() - } - } - event.accepted = true - } - // Global Key for saving edited image (Ctrl+S) when editor is open - else if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { - if (uiState.isEditorOpen) { - controller.save_edited_image() - event.accepted = true - } - } - } - } - } - - // -------- FOOTER / STATUS BAR (old version) -------- +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 ? "#2b2b2b" : "#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() + } + + Connections { + target: uiState + function onThemeChanged() { + root.isDarkTheme = uiState.theme === 0 + } + } + + // -------- 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: { + if (uiState) { + settingsDialog.heliconPath = uiState.get_helicon_path() + settingsDialog.photoshopPath = uiState.get_photoshop_path() + settingsDialog.cacheSize = uiState.get_cache_size() + settingsDialog.prefetchRadius = uiState.get_prefetch_radius() + settingsDialog.theme = uiState.theme + settingsDialog.defaultDirectory = uiState.get_default_directory() + settingsDialog.optimizeFor = uiState.get_optimize_for() + settingsDialog.awbMode = uiState.awbMode + settingsDialog.awbStrength = uiState.awbStrength + settingsDialog.awbWarmBias = uiState.awbWarmBias + settingsDialog.awbLumaLowerBound = uiState.awbLumaLowerBound + settingsDialog.awbLumaUpperBound = uiState.awbLumaUpperBound + settingsDialog.awbRgbLowerBound = uiState.awbRgbLowerBound + settingsDialog.awbRgbUpperBound = uiState.awbRgbUpperBound + } + 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 + + // 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 + } + } + } + } + + // -------- MAIN VIEW -------- + Item { + id: contentArea + anchors.fill: parent + + Loader { + id: mainViewLoader + anchors.fill: parent + source: "Components.qml" + focus: true + + // Key bindings implemented in old Main.qml + Keys.onPressed: function(event) { + if (!uiState || !controller) { + return + } + + // Toggle Image Editor with 'E' key + // If editor is open, close it without saving. Otherwise open it. + if (event.key === Qt.Key_E && !event.isAutoRepeat) { + if (uiState.isEditorOpen) { + // Close editor without saving + uiState.isEditorOpen = false + } else { + // Open editor + uiState.isEditorOpen = true + if (controller) { + controller.load_image_for_editing() + } + } + event.accepted = true + } + // Global Key for saving edited image (Ctrl+S) when editor is open + else if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { + if (uiState.isEditorOpen) { + controller.save_edited_image() + event.accepted = true + } + } + } + } + } + + // -------- FOOTER / STATUS BAR (old version) -------- footer: Rectangle { id: footerRect // Keep footer height fixed so the main image area doesn't change size when @@ -698,299 +698,299 @@ ApplicationWindow { 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 ? ` | 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: 600 - height: 750 - - background: Rectangle { - color: root.currentBackgroundColor - } - - contentItem: ScrollView { - clip: true - Text { - 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

" + - "Stacking:
" + - "  [: Begin new stack
" + - "  ]: End current stack
" + - "  C: Clear all stacks

" + - "Batch Selection (for drag-and-drop):
" + - "  {: Begin new batch
" + - "  }: End current batch
" + - "  \\: Clear all batches
" + - "  X or S: Remove current image from batch/stack

" + - "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
" + - "  A: Quick auto white balance (saves automatically)
" + - "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + - "  O: Toggle crop mode (Enter to execute crop, ESC to cancel)
" + - "  H: Toggle histogram window
" + - "  E: Toggle Image Editor (closes without saving if open)
" + - "  Ctrl+C: Copy image path to clipboard" - 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" - } - } -} + 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 ? ` | 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: 600 + height: 750 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: ScrollView { + clip: true + Text { + 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

" + + "Stacking:
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks

" + + "Batch Selection (for drag-and-drop):
" + + "  {: Begin new batch
" + + "  }: End current batch
" + + "  \\: Clear all batches
" + + "  X or S: Remove current image from batch/stack

" + + "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
" + + "  A: Quick auto white balance (saves automatically)
" + + "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + + "  O: Toggle crop mode (Enter to execute crop, ESC to cancel)
" + + "  H: Toggle histogram window
" + + "  E: Toggle Image Editor (closes without saving if open)
" + + "  Ctrl+C: Copy image path to clipboard" + 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/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index bc7f68c..849283b 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -1,320 +1,320 @@ -import QtQuick -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -Dialog { - id: settingsDialog - title: "Settings" - standardButtons: Dialog.Ok | Dialog.Cancel - modal: true - closePolicy: Popup.CloseOnEscape - focus: true - width: 600 - height: 600 - - // Live cache usage value (updated by timer) - property real cacheUsage: 0.0 - - onVisibleChanged: { - cacheUsageTimer.running = visible - if (visible) { - controller.dialog_opened() - } else { - controller.dialog_closed() - } - } - - onOpened: { - // Refresh text fields when dialog opens with current values - cacheSizeField.text = settingsDialog.cacheSize.toFixed(1) - heliconPathField.text = settingsDialog.heliconPath - photoshopPathField.text = settingsDialog.photoshopPath - optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor) - } - - property string heliconPath: "" - property double cacheSize: 1.5 - property int prefetchRadius: 4 - property int theme: 0 - property string defaultDirectory: "" - property string photoshopPath: "" - property string optimizeFor: "speed" - - property string awbMode: "lab" - property double awbStrength: 0.7 - property int awbWarmBias: 6 - - property int awbLumaLowerBound: 30 - property int awbLumaUpperBound: 220 - property int awbRgbLowerBound: 5 - property int awbRgbUpperBound: 250 - - onAccepted: { - uiState.set_helicon_path(heliconPath) - uiState.set_photoshop_path(photoshopPath) - 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.awbMode = awbMode - uiState.awbStrength = awbStrength - uiState.awbWarmBias = awbWarmBias - - uiState.awbLumaLowerBound = awbLumaLowerBound - uiState.awbLumaUpperBound = awbLumaUpperBound - uiState.awbRgbLowerBound = awbRgbLowerBound - uiState.awbRgbUpperBound = awbRgbUpperBound - } - - contentItem: ColumnLayout { - Row { - id: tabButtons - spacing: 5 - - Button { - text: "General" - highlighted: settingsStackLayout.currentIndex === 0 - onClicked: settingsStackLayout.currentIndex = 0 - } - Button { - text: "Auto White Balance" - highlighted: settingsStackLayout.currentIndex === 1 - onClicked: settingsStackLayout.currentIndex = 1 - } - } - - StackLayout { - id: settingsStackLayout - currentIndex: 0 - - GridLayout { - columns: 3 - - // Helicon Path - Label { text: "Helicon Focus Path:" } - TextField { - id: heliconPathField - Layout.fillWidth: true - text: settingsDialog.heliconPath - onTextChanged: settingsDialog.heliconPath = text - } - RowLayout { - Button { - text: "Browse..." - onClicked: { - var path = uiState.open_file_dialog() - if (path) heliconPathField.text = path - } - } - Label { - id: checkMarkLabel - text: "✔" - color: "lightgreen" - visible: uiState.check_path_exists(heliconPathField.text) - } - } - - // Photoshop Path - Label { text: "Photoshop Path:" } - TextField { - id: photoshopPathField - Layout.fillWidth: true - text: settingsDialog.photoshopPath - onTextChanged: settingsDialog.photoshopPath = text - } - RowLayout { - Button { - text: "Browse..." - onClicked: { - var path = uiState.open_file_dialog() - if (path) photoshopPathField.text = path - } - } - Label { - id: photoshopCheckMarkLabel - text: "✔" - color: "lightgreen" - visible: uiState.check_path_exists(photoshopPathField.text) - } - } - - // Cache Size - Label { text: "Cache Size (GB):" } - TextField { - id: cacheSizeField - Layout.fillWidth: true - - Component.onCompleted: { - text = settingsDialog.cacheSize.toFixed(1) - } - - onEditingFinished: { - var value = parseFloat(text) - if (!isNaN(value) && value >= 0.5 && value <= 16) { - settingsDialog.cacheSize = value - text = value.toFixed(1) // Format it - } else { - // Invalid input, reset to current value - text = settingsDialog.cacheSize.toFixed(1) - } - } - } - Label { - id: cacheUsageLabel - text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB" - color: "#1013e6" - } - - // Prefetch Radius - Label { text: "Prefetch Radius:" } - SpinBox { - id: prefetchRadiusSpinBox - from: 1 - to: 20 - value: settingsDialog.prefetchRadius - onValueChanged: settingsDialog.prefetchRadius = value - } - Label {} // Placeholder - - // Theme - Label { text: "Theme:" } - ComboBox { - id: themeComboBox - model: ["Dark", "Light"] - currentIndex: settingsDialog.theme - onCurrentIndexChanged: settingsDialog.theme = currentIndex - } - Label {} // Placeholder - - // Default Directory - Label { text: "Default Image Directory:" } - TextField { - id: defaultDirectoryField - Layout.fillWidth: true - text: settingsDialog.defaultDirectory - onTextChanged: settingsDialog.defaultDirectory = text - } - Button { - text: "Browse..." - onClicked: { - var path = uiState.open_directory_dialog() - if (path) defaultDirectoryField.text = path - } - } - - // Optimize For - Label { text: "Optimize For:" } - ComboBox { - id: optimizeForComboBox - model: ["speed", "quality"] - currentIndex: model.indexOf(settingsDialog.optimizeFor) - onCurrentIndexChanged: settingsDialog.optimizeFor = model[currentIndex] - Layout.fillWidth: true - } - Label {} // Placeholder - } - - GridLayout { - columns: 3 - - // --- Auto White Balance --- - Label { - text: "Auto WB Mode:" - Layout.topMargin: 10 - } - ComboBox { - id: awbModeComboBox - model: ["lab", "rgb"] - currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) - onCurrentIndexChanged: settingsDialog.awbMode = model[currentIndex] - Layout.topMargin: 10 - } - Label { - Layout.topMargin: 10 - } - - Label { text: "Auto WB Strength:" } - Slider { - id: awbStrengthSlider - from: 0.3 - to: 1.0 - value: settingsDialog.awbStrength - onValueChanged: settingsDialog.awbStrength = value - } - Label { text: (awbStrengthSlider.value * 100).toFixed(0) + "%" } - - Label { text: "Auto WB Warm Bias:" } - SpinBox { - id: awbWarmBiasSpinBox - from: -10 - to: 20 - value: settingsDialog.awbWarmBias - onValueChanged: settingsDialog.awbWarmBias = value - } - Label {} // Placeholder - - // --- Advanced AWB Settings --- - CheckBox { - id: advancedAwbCheckBox - text: "Advanced Settings" - checked: false - Layout.columnSpan: 3 - } - - GridLayout { - visible: advancedAwbCheckBox.checked - columns: 3 - Layout.columnSpan: 3 - Layout.fillWidth: true - - Label { text: "Luma Lower Bound:" } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbLumaLowerBound - onValueChanged: settingsDialog.awbLumaLowerBound = value - } - Label {} - - Label { text: "Luma Upper Bound:" } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbLumaUpperBound - onValueChanged: settingsDialog.awbLumaUpperBound = value - } - Label {} - - Label { text: "RGB Lower Bound:" } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbRgbLowerBound - onValueChanged: settingsDialog.awbRgbLowerBound = value - } - Label {} - - Label { text: "RGB Upper Bound:" } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbRgbUpperBound - onValueChanged: settingsDialog.awbRgbUpperBound = value - } - Label {} - } - } - } - } - - // Poll cache usage periodically while the dialog is open - Timer { - id: cacheUsageTimer - interval: 1000 - repeat: true - running: false - onTriggered: settingsDialog.cacheUsage = uiState.get_cache_usage_gb() - } -} +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Dialog { + id: settingsDialog + title: "Settings" + standardButtons: Dialog.Ok | Dialog.Cancel + modal: true + closePolicy: Popup.CloseOnEscape + focus: true + width: 600 + height: 600 + + // Live cache usage value (updated by timer) + property real cacheUsage: 0.0 + + onVisibleChanged: { + cacheUsageTimer.running = visible + if (visible) { + controller.dialog_opened() + } else { + controller.dialog_closed() + } + } + + onOpened: { + // Refresh text fields when dialog opens with current values + cacheSizeField.text = settingsDialog.cacheSize.toFixed(1) + heliconPathField.text = settingsDialog.heliconPath + photoshopPathField.text = settingsDialog.photoshopPath + optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor) + } + + property string heliconPath: "" + property double cacheSize: 1.5 + property int prefetchRadius: 4 + property int theme: 0 + property string defaultDirectory: "" + property string photoshopPath: "" + property string optimizeFor: "speed" + + property string awbMode: "lab" + property double awbStrength: 0.7 + property int awbWarmBias: 6 + + property int awbLumaLowerBound: 30 + property int awbLumaUpperBound: 220 + property int awbRgbLowerBound: 5 + property int awbRgbUpperBound: 250 + + onAccepted: { + uiState.set_helicon_path(heliconPath) + uiState.set_photoshop_path(photoshopPath) + 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.awbMode = awbMode + uiState.awbStrength = awbStrength + uiState.awbWarmBias = awbWarmBias + + uiState.awbLumaLowerBound = awbLumaLowerBound + uiState.awbLumaUpperBound = awbLumaUpperBound + uiState.awbRgbLowerBound = awbRgbLowerBound + uiState.awbRgbUpperBound = awbRgbUpperBound + } + + contentItem: ColumnLayout { + Row { + id: tabButtons + spacing: 5 + + Button { + text: "General" + highlighted: settingsStackLayout.currentIndex === 0 + onClicked: settingsStackLayout.currentIndex = 0 + } + Button { + text: "Auto White Balance" + highlighted: settingsStackLayout.currentIndex === 1 + onClicked: settingsStackLayout.currentIndex = 1 + } + } + + StackLayout { + id: settingsStackLayout + currentIndex: 0 + + GridLayout { + columns: 3 + + // Helicon Path + Label { text: "Helicon Focus Path:" } + TextField { + id: heliconPathField + Layout.fillWidth: true + text: settingsDialog.heliconPath + onTextChanged: settingsDialog.heliconPath = text + } + RowLayout { + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_file_dialog() + if (path) heliconPathField.text = path + } + } + Label { + id: checkMarkLabel + text: "✔" + color: "lightgreen" + visible: uiState.check_path_exists(heliconPathField.text) + } + } + + // Photoshop Path + Label { text: "Photoshop Path:" } + TextField { + id: photoshopPathField + Layout.fillWidth: true + text: settingsDialog.photoshopPath + onTextChanged: settingsDialog.photoshopPath = text + } + RowLayout { + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_file_dialog() + if (path) photoshopPathField.text = path + } + } + Label { + id: photoshopCheckMarkLabel + text: "✔" + color: "lightgreen" + visible: uiState.check_path_exists(photoshopPathField.text) + } + } + + // Cache Size + Label { text: "Cache Size (GB):" } + TextField { + id: cacheSizeField + Layout.fillWidth: true + + Component.onCompleted: { + text = settingsDialog.cacheSize.toFixed(1) + } + + onEditingFinished: { + var value = parseFloat(text) + if (!isNaN(value) && value >= 0.5 && value <= 16) { + settingsDialog.cacheSize = value + text = value.toFixed(1) // Format it + } else { + // Invalid input, reset to current value + text = settingsDialog.cacheSize.toFixed(1) + } + } + } + Label { + id: cacheUsageLabel + text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB" + color: "#1013e6" + } + + // Prefetch Radius + Label { text: "Prefetch Radius:" } + SpinBox { + id: prefetchRadiusSpinBox + from: 1 + to: 20 + value: settingsDialog.prefetchRadius + onValueChanged: settingsDialog.prefetchRadius = value + } + Label {} // Placeholder + + // Theme + Label { text: "Theme:" } + ComboBox { + id: themeComboBox + model: ["Dark", "Light"] + currentIndex: settingsDialog.theme + onCurrentIndexChanged: settingsDialog.theme = currentIndex + } + Label {} // Placeholder + + // Default Directory + Label { text: "Default Image Directory:" } + TextField { + id: defaultDirectoryField + Layout.fillWidth: true + text: settingsDialog.defaultDirectory + onTextChanged: settingsDialog.defaultDirectory = text + } + Button { + text: "Browse..." + onClicked: { + var path = uiState.open_directory_dialog() + if (path) defaultDirectoryField.text = path + } + } + + // Optimize For + Label { text: "Optimize For:" } + ComboBox { + id: optimizeForComboBox + model: ["speed", "quality"] + currentIndex: model.indexOf(settingsDialog.optimizeFor) + onCurrentIndexChanged: settingsDialog.optimizeFor = model[currentIndex] + Layout.fillWidth: true + } + Label {} // Placeholder + } + + GridLayout { + columns: 3 + + // --- Auto White Balance --- + Label { + text: "Auto WB Mode:" + Layout.topMargin: 10 + } + ComboBox { + id: awbModeComboBox + model: ["lab", "rgb"] + currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) + onCurrentIndexChanged: settingsDialog.awbMode = model[currentIndex] + Layout.topMargin: 10 + } + Label { + Layout.topMargin: 10 + } + + Label { text: "Auto WB Strength:" } + Slider { + id: awbStrengthSlider + from: 0.3 + to: 1.0 + value: settingsDialog.awbStrength + onValueChanged: settingsDialog.awbStrength = value + } + Label { text: (awbStrengthSlider.value * 100).toFixed(0) + "%" } + + Label { text: "Auto WB Warm Bias:" } + SpinBox { + id: awbWarmBiasSpinBox + from: -10 + to: 20 + value: settingsDialog.awbWarmBias + onValueChanged: settingsDialog.awbWarmBias = value + } + Label {} // Placeholder + + // --- Advanced AWB Settings --- + CheckBox { + id: advancedAwbCheckBox + text: "Advanced Settings" + checked: false + Layout.columnSpan: 3 + } + + GridLayout { + visible: advancedAwbCheckBox.checked + columns: 3 + Layout.columnSpan: 3 + Layout.fillWidth: true + + Label { text: "Luma Lower Bound:" } + SpinBox { + from: 0 + to: 255 + value: settingsDialog.awbLumaLowerBound + onValueChanged: settingsDialog.awbLumaLowerBound = value + } + Label {} + + Label { text: "Luma Upper Bound:" } + SpinBox { + from: 0 + to: 255 + value: settingsDialog.awbLumaUpperBound + onValueChanged: settingsDialog.awbLumaUpperBound = value + } + Label {} + + Label { text: "RGB Lower Bound:" } + SpinBox { + from: 0 + to: 255 + value: settingsDialog.awbRgbLowerBound + onValueChanged: settingsDialog.awbRgbLowerBound = value + } + Label {} + + Label { text: "RGB Upper Bound:" } + SpinBox { + from: 0 + to: 255 + value: settingsDialog.awbRgbUpperBound + onValueChanged: settingsDialog.awbRgbUpperBound = value + } + Label {} + } + } + } + } + + // Poll cache usage periodically while the dialog is open + Timer { + id: cacheUsageTimer + interval: 1000 + repeat: true + running: false + onTriggered: settingsDialog.cacheUsage = uiState.get_cache_usage_gb() + } +} diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 5ee0f59..a4a4045 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -1,34 +1,34 @@ - -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "faststack" -version = "1.4" -authors = [ - { name="Alan Rockefeller", email="alanrockefeller at gmail" }, -] -description = "Ultra-fast JPG Viewer for Focus Stacking Selection" -readme = "README.md" -requires-python = ">=3.11" -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: Microsoft :: Windows", -] -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", - "pytest>=8.0,<9.0", -] - -[project.scripts] -faststack = "faststack.app:cli" - -[tool.setuptools] -packages = ["faststack"] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "faststack" +version = "1.4" +authors = [ + { name="Alan Rockefeller", email="alanrockefeller at gmail" }, +] +description = "Ultra-fast JPG Viewer for Focus Stacking Selection" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", +] +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", + "pytest>=8.0,<9.0", +] + +[project.scripts] +faststack = "faststack.app:cli" + +[tool.setuptools] +packages = ["faststack"] diff --git a/faststack/requirements.txt b/faststack/requirements.txt index a5ad649..687f77d 100644 --- a/faststack/requirements.txt +++ b/faststack/requirements.txt @@ -1,8 +1,8 @@ -PySide6==6.10.* -PyTurboJPEG==1.* -numpy==2.* -cachetools==5.* -watchdog==4.* -Pillow==10.* # fallback decode; keep it -pyinstaller==6.* -pytest==8.* +PySide6==6.10.* +PyTurboJPEG==1.* +numpy==2.* +cachetools==5.* +watchdog==4.* +Pillow==10.* # fallback decode; keep it +pyinstaller==6.* +pytest==8.* From 207a553eefededd6ae55c414f19e3e11844cfe73 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 7 Dec 2025 00:38:54 -0800 Subject: [PATCH 7/9] Implemented auto levels --- faststack/faststack/app.py | 70 ++++++++++++++++++++++ faststack/faststack/config.py | 1 + faststack/faststack/imaging/editor.py | 44 ++++++++++++++ faststack/faststack/qml/Components.qml | 51 +++++++++++++++- faststack/faststack/qml/SettingsDialog.qml | 23 ++++++- faststack/faststack/ui/keystrokes.py | 1 + faststack/faststack/ui/provider.py | 22 +++++++ 7 files changed, 209 insertions(+), 3 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 1240268..04afadc 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -162,6 +162,8 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: # Track if any dialog is open to disable keybindings self._dialog_open = False + + self.auto_level_threshold = config.getfloat('core', 'auto_level_threshold', 0.1) @Slot(str) @@ -2657,6 +2659,74 @@ def execute_crop(self): self.update_status_message(f"Crop failed: {e}") log.exception("Failed to crop image") + @Slot() + def auto_levels(self): + """Quickly apply auto levels, 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 levels + blacks, whites = self.image_editor.auto_levels(self.auto_level_threshold) + + # Save the edited image (this creates a backup automatically) + save_result = self.image_editor.save_image() + if save_result: + saved_path, backup_path = save_result + # Track this action for undo + timestamp = time.time() + self.undo_history.append(("auto_levels", (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 + 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(f"Auto levels applied (clip {self.auto_level_threshold}%)") + log.info("Auto levels applied to %s with threshold %.2f%%", filepath, self.auto_level_threshold) + else: + self.update_status_message("Failed to save image") + + @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): + if self.auto_level_threshold != value: + self.auto_level_threshold = value + config.set('core', 'auto_level_threshold', value) + config.save() + @Slot() def quick_auto_white_balance(self): """Quickly apply auto white balance, save the image, and track for undo.""" diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index a5a249b..9bf4309 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -15,6 +15,7 @@ "theme": "dark", "default_directory": "", "optimize_for": "speed", # "speed" or "quality" + "auto_level_threshold": "0.1", }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 1a58999..be7524e 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -287,6 +287,50 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: return img + def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float]: + """ + Automatically adjusts blacks and whites based on image histogram. + + Args: + threshold_percent: value 0.0-10.0, percentage of pixels to clip at each end. + + Returns: + Tuple of (blacks, whites) parameter values. + """ + if self.original_image is None: + return 0.0, 0.0 + + # Use preview image for speed if available, otherwise original + img = self._preview_image if self._preview_image else self.original_image + + # Convert to numpy array for histogram analysis + arr = np.array(img.convert('L')) # Use luminance for levels + + # Calculate percentiles + low_p = threshold_percent + high_p = 100.0 - threshold_percent + + p_low, p_high = np.percentile(arr, [low_p, high_p]) + + # Calculate parameters to map p_low->0 and p_high->255 + # Logic matches _apply_edits: + # black_point = -blacks * 40 + # white_point = 255 + whites * 40 + + # We want black_point to be p_low + # p_low = -blacks * 40 => blacks = -p_low / 40.0 + blacks = -float(p_low) / 40.0 + + # We want white_point to be p_high + # p_high = 255 + whites * 40 => whites = (p_high - 255) / 40.0 + whites = (float(p_high) - 255.0) / 40.0 + + # Update state + self.current_edits['blacks'] = blacks + self.current_edits['whites'] = whites + + return blacks, whites + def get_preview_data(self) -> Optional[DecodedImage]: """Apply current edits and return the data as a DecodedImage.""" if self._preview_image is None: diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 4fb9412..3037514 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -155,6 +155,11 @@ Item { 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 + onCropRotationChanged: uiState.cropRotation = cropRotation onPressed: function(mouse) { lastX = mouse.x @@ -173,8 +178,15 @@ Item { var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height + if (mainMouseArea.isRotating) { + cropDragMode = "rotate" + var cropCenterX = cropRect.x + cropRect.width / 2 + var cropCenterY = cropRect.y + cropRect.height / 2 + cropStartAngle = Math.atan2(mouse.y - cropCenterY, mouse.x - cropCenterX) * 180 / Math.PI + cropStartRotation = cropRotation + } // If crop box is full image, always start a new crop - if (isFullImage) { + else if (isFullImage) { cropDragMode = "new" cropStartX = mouse.x cropStartY = mouse.y @@ -269,6 +281,11 @@ Item { if (cropDragMode === "new") { // Update crop rectangle while dragging updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y, true) + } else if (cropDragMode === "rotate") { + var cropCenterX = getCropRect().x + getCropRect().width / 2 + var cropCenterY = getCropRect().y + getCropRect().height / 2 + var currentAngle = Math.atan2(mouse.y - cropCenterY, mouse.x - cropCenterX) * 180 / Math.PI + cropRotation = cropStartRotation + (currentAngle - cropStartAngle) } else if (cropDragMode !== "none") { var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) @@ -722,6 +739,8 @@ Item { color: "transparent" border.color: "white" border.width: 3 + rotation: mainMouseArea.cropRotation + transformOrigin: Item.Center } } @@ -732,7 +751,7 @@ Item { anchors.top: parent.top anchors.left: parent.left anchors.margins: 10 - width: 200 + width: 120 height: Math.max(150, aspectRatioColumn.implicitHeight + 20) color: "#333333" border.color: "#666666" @@ -792,6 +811,34 @@ Item { } } } + + Rectangle { + width: parent.width + height: 30 + color: "transparent" + radius: 3 + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: "Rotate" + color: "white" + font.pixelSize: 11 + } + + MouseArea { + anchors.fill: parent + onClicked: { + mainMouseArea.isRotating = !mainMouseArea.isRotating + if(mainMouseArea.isRotating) { + mainMouseArea.cropDragMode = "rotate" + } else { + mainMouseArea.cropDragMode = "none" + } + } + } + } } } diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index 849283b..45f83df 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -10,7 +10,7 @@ Dialog { closePolicy: Popup.CloseOnEscape focus: true width: 600 - height: 600 + height: 700 // Live cache usage value (updated by timer) property real cacheUsage: 0.0 @@ -30,10 +30,12 @@ Dialog { heliconPathField.text = settingsDialog.heliconPath photoshopPathField.text = settingsDialog.photoshopPath optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor) + autoLevelThresholdField.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) } property string heliconPath: "" property double cacheSize: 1.5 + property double autoLevelClippingThreshold: 0.1 property int prefetchRadius: 4 property int theme: 0 property string defaultDirectory: "" @@ -57,6 +59,7 @@ Dialog { uiState.set_theme(theme) uiState.set_default_directory(defaultDirectory) uiState.set_optimize_for(optimizeFor) + uiState.autoLevelClippingThreshold = autoLevelClippingThreshold uiState.awbMode = awbMode uiState.awbStrength = awbStrength @@ -214,6 +217,24 @@ Dialog { Layout.fillWidth: true } Label {} // Placeholder + + // Auto Levels Clip Threshold + Label { text: "Auto Levels Clip %:" } + TextField { + id: autoLevelThresholdField + Layout.fillWidth: true + + onEditingFinished: { + var value = parseFloat(text) + if (!isNaN(value) && value >= 0.0 && value <= 10.0) { + settingsDialog.autoLevelClippingThreshold = value + text = value.toFixed(4) + } else { + text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + } + } + } + Label {} // Placeholder } GridLayout { diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index dbc5f54..6701c30 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -47,6 +47,7 @@ def __init__(self, controller): Qt.Key_P: "edit_in_photoshop", Qt.Key_C: "clear_all_stacks", Qt.Key_A: "quick_auto_white_balance", + Qt.Key_L: "auto_levels", Qt.Key_O: "toggle_crop_mode", Qt.Key_H: "toggle_histogram", Qt.Key_Delete: "delete_current_image", diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index f512407..89ac202 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -93,6 +93,7 @@ class UIState(QObject): awbRgbUpperBoundChanged = Signal() default_directory_changed = Signal(str) isStackedJpgChanged = Signal() # New signal for isStackedJpg + autoLevelClippingThresholdChanged = Signal(float) # Image Editor Signals is_editor_open_changed = Signal(bool) is_cropping_changed = Signal(bool) @@ -106,6 +107,7 @@ class UIState(QObject): 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) @@ -142,6 +144,7 @@ def __init__(self, app_controller): 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._aspect_ratio_names = [] self._current_aspect_ratio_index = 0 self._any_slider_pressed = False @@ -475,6 +478,15 @@ def set_optimize_for(self, optimize_for): 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) + @Slot() def open_folder(self): self.app_controller.open_folder() @@ -687,6 +699,16 @@ def currentCropBox(self, new_value): if self._current_crop_box != new_value: self._current_crop_box = new_value self.current_crop_box_changed.emit(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) From 7292c018209a381d4c8c7de2be28ffce800fc2c7 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 7 Dec 2025 19:13:40 -0800 Subject: [PATCH 8/9] Add test scaffold for new features --- .../faststack/tests/test_new_features.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 faststack/faststack/tests/test_new_features.py diff --git a/faststack/faststack/tests/test_new_features.py b/faststack/faststack/tests/test_new_features.py new file mode 100644 index 0000000..ffe12bb --- /dev/null +++ b/faststack/faststack/tests/test_new_features.py @@ -0,0 +1,84 @@ +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 + # 10% black (0), 80% gray (128), 10% white (255) + arr = np.zeros((100, 100), dtype=np.uint8) + 128 + arr[0:10, :] = 0 + arr[90:100, :] = 255 + img = Image.fromarray(arr) + + self.editor.original_image = img + self.editor._preview_image = img + + # Calculate auto levels with 10% threshold (should clip the 0s and 255s) + # Percentiles: 10% is 0, 90% is 255? + # Let's use a threshold that clips inside the gray area to force a stretch if possible, + # or just ensure it returns non-zero. + + # Actually, simpler: just check valid return + blacks, whites = self.editor.auto_levels(0.1) + + # Mock strength application matching app.py logic + strength = 0.5 + b_scaled = blacks * strength + w_scaled = whites * strength + + self.assertEqual(b_scaled, blacks * 0.5) + self.assertEqual(w_scaled, whites * 0.5) + + 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()) + + # 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) + +if __name__ == '__main__': + unittest.main(verbosity=2) From 81c9ba6496839195b2562befe1f738c0153a2fc5 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 7 Dec 2025 20:01:10 -0800 Subject: [PATCH 9/9] re-implement rolled back fixes --- faststack/faststack/app.py | 118 +++++++++++++----- faststack/faststack/config.py | 1 + faststack/faststack/imaging/editor.py | 27 +++- faststack/faststack/qml/Components.qml | 41 +++++- faststack/faststack/qml/ExifDialog.qml | 13 ++ faststack/faststack/qml/HistogramWindow.qml | 6 +- faststack/faststack/qml/ImageEditorDialog.qml | 34 +++-- faststack/faststack/qml/SettingsDialog.qml | 16 +++ faststack/faststack/ui/keystrokes.py | 2 +- faststack/faststack/ui/provider.py | 10 ++ 10 files changed, 221 insertions(+), 47 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 04afadc..4e9b406 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -48,6 +48,7 @@ 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): @@ -164,6 +165,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self._dialog_open = False self.auto_level_threshold = config.getfloat('core', 'auto_level_threshold', 0.1) + self.auto_level_strength = config.getfloat('core', 'auto_level_strength', 1.0) @Slot(str) @@ -2264,16 +2266,16 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = pan_y: Pan offset in Y direction (in image coordinates) image_scale: Scale factor of displayed image vs original """ + # Return immediately if histogram is not visible + if not self.ui_state.isHistogramVisible: + return + if not self.image_files or self.current_index >= len(self.image_files): return try: - import numpy as np - # Get the current image data - decoded = self.get_decoded_image(self.current_index) - if not decoded: - return + decoded = None # If editor is open and has a preview, use that instead if self.ui_state.isEditorOpen and self.image_editor.original_image: @@ -2281,6 +2283,13 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = if preview_data: decoded = preview_data + # Fallback to cached image + if not decoded: + decoded = self.get_decoded_image(self.current_index) + + if not decoded: + return + # Convert buffer to numpy array arr = np.frombuffer(decoded.buffer, dtype=np.uint8) arr = arr.reshape((decoded.height, decoded.width, 3)) @@ -2341,16 +2350,17 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = g_preclip_count = int(np.sum(g_hist[250:255])) b_preclip_count = int(np.sum(b_hist[250:255])) - # Apply log scaling for better visualization - log_r_hist = np.log1p(r_hist).tolist() - log_g_hist = np.log1p(g_hist).tolist() - log_b_hist = np.log1p(b_hist).tolist() + # Apply log scaling for better visualization - keeping this per existing code style + # but converting to float as requested + 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)] - # Create the structured data for QML + # Create the structured data for QML with keys 'r', 'g', 'b' histogram_data = { - 'r_hist': log_r_hist, - 'g_hist': log_g_hist, - 'b_hist': log_b_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, @@ -2661,58 +2671,90 @@ def execute_crop(self): @Slot() def auto_levels(self): - """Quickly apply auto levels, save the image, and track for undo.""" + """Calculates and applies auto levels (preview only).""" 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 levels + # Ensure image is loaded in editor + # Only load if not already loaded to avoid resetting other edits + 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 + + # Calculate auto levels blacks, whites = self.image_editor.auto_levels(self.auto_level_threshold) - # Save the edited image (this creates a backup automatically) + # Scale by strength + blacks *= self.auto_level_strength + whites *= self.auto_level_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() + + self.update_status_message(f"Auto levels applied (preview only)") + log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f)", + filepath, self.auto_level_threshold, self.auto_level_strength) + + @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) + self.auto_levels() + + # Save + import time save_result = self.image_editor.save_image() if save_result: saved_path, backup_path = save_result - # Track this action for undo timestamp = time.time() self.undo_history.append(("auto_levels", (saved_path, backup_path), timestamp)) - # Force the image editor to clear its current state so it reloads fresh + # Force reload to ensure disk consistency self.image_editor.clear() - # Refresh the view - need to refresh image list since backup file was created - original_path = Path(filepath) + # Refresh list/cache/UI (standard save pattern) + image_file = self.image_files[self.current_index] + original_path = image_file.path self.refresh_image_list() - # Find the edited image (not the backup) in the refreshed list + # Find image again for i, img_file in enumerate(self.image_files): if img_file.path == original_path: self.current_index = i break - # Invalidate cache 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(f"Auto levels applied (clip {self.auto_level_threshold}%)") - log.info("Auto levels applied to %s with threshold %.2f%%", filepath, self.auto_level_threshold) + self.update_status_message("Auto levels applied and saved") + log.info("Quick auto levels saved for %s", original_path) else: self.update_status_message("Failed to save image") @@ -2727,6 +2769,18 @@ def set_auto_level_clipping_threshold(self, value): config.set('core', 'auto_level_threshold', value) 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: float): + value = max(0.0, min(1.0, value)) + if self.auto_level_strength != value: + self.auto_level_strength = value + config.set('core', 'auto_level_strength', str(value)) + config.save() + @Slot() def quick_auto_white_balance(self): """Quickly apply auto white balance, save the image, and track for undo.""" diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 9bf4309..513782d 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -16,6 +16,7 @@ "default_directory": "", "optimize_for": "speed", # "speed" or "quality" "auto_level_threshold": "0.1", + "auto_level_strength": "1.0", }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index be7524e..216a28f 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -91,6 +91,7 @@ def _initial_edits(self) -> Dict[str, Any]: 'whites': 0.0, 'clarity': 0.0, 'texture': 0.0, + 'straighten_angle': 0.0, } def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = None): @@ -139,7 +140,12 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: elif rotation == 270: img = img.transpose(Image.Transpose.ROTATE_270) - # 2. Cropping + # 2. Free Rotation (Straighten) + straighten_angle = self.current_edits['straighten_angle'] + if abs(straighten_angle) > 0.001: + img = img.rotate(straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) + + # 3. Cropping crop_box = self.current_edits.get('crop_box') if crop_box: width, height = img.size @@ -178,7 +184,13 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: if abs(shadows) > 0.001: shadow_mask = 1.0 - np.clip(arr / 128.0, 0, 1) arr += shadows * 60 * shadow_mask - if abs(highlights) > 0.001: + + if highlights < -0.001: # Negative highlights (recovery) + mask = np.clip((arr - 128) / 127.0, 0, 1) # targets bright pixels + # highlights is negative here, so 1.0 + (negative * positive) = something less than 1.0 + factor = 1.0 + (highlights * 0.75 * mask) + arr = arr * factor + elif highlights > 0.001: # Positive highlights (keep existing) highlight_mask = np.clip((arr - 128) / 127.0, 0, 1) arr += highlights * 60 * highlight_mask img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) @@ -452,6 +464,17 @@ def _restore_file_times(self, path: Path, original_stat: os.stat_result) -> None except OSError as e: print(f"Warning: Unable to restore timestamps for {path}: {e}") + def rotate_image_cw(self): + """Decreases the rotation edit parameter by 90° modulo 360.""" + current = self.current_edits.get('rotation', 0) + self.current_edits['rotation'] = (current - 90) % 360 + if self.current_edits['rotation'] < 0: + self.current_edits['rotation'] += 360 + + def rotate_image_ccw(self): + """Increases the rotation edit parameter by 90° modulo 360.""" + current = self.current_edits.get('rotation', 0) + self.current_edits['rotation'] = (current + 90) % 360 # Dictionary of ratios for QML dropdown ASPECT_RATIOS = [{"name": name, "ratio": ratio} for name, ratio in INSTAGRAM_RATIOS.items()] diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 3037514..1090c4b 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -178,7 +178,24 @@ Item { var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height - if (mainMouseArea.isRotating) { + // Hit test for rotation handle + var cropCenterX = cropRect.x + cropRect.width / 2 + var cropCenterY = cropRect.y + cropRect.height / 2 + var theta = mainMouseArea.cropRotation * Math.PI / 180 + // Handle is at bottom center + 25px + var handleOffset = cropRect.height / 2 + 25 + // Rotated offset: x = -offset * sin(theta), y = offset * cos(theta) + var handleX = cropCenterX - handleOffset * Math.sin(theta) + var handleY = cropCenterY + handleOffset * Math.cos(theta) + + var dist = Math.sqrt(Math.pow(mouse.x - handleX, 2) + Math.pow(mouse.y - handleY, 2)) + + if (dist < 20) { + cropDragMode = "rotate" + cropStartAngle = Math.atan2(mouse.y - cropCenterY, mouse.x - cropCenterX) * 180 / Math.PI + cropStartRotation = cropRotation + } + else if (mainMouseArea.isRotating) { cropDragMode = "rotate" var cropCenterX = cropRect.x + cropRect.width / 2 var cropCenterY = cropRect.y + cropRect.height / 2 @@ -741,6 +758,28 @@ Item { border.width: 3 rotation: mainMouseArea.cropRotation transformOrigin: Item.Center + + // Rotation Handle Line + Rectangle { + id: handleLine + width: 2 + height: 25 + color: "white" + anchors.top: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + } + + // Rotation Handle Knob + Rectangle { + width: 12 + height: 12 + radius: 6 + color: "white" + border.color: "black" + border.width: 1 + anchors.verticalCenter: handleLine.bottom + anchors.horizontalCenter: handleLine.horizontalCenter + } } } diff --git a/faststack/faststack/qml/ExifDialog.qml b/faststack/faststack/qml/ExifDialog.qml index f6bd5f7..926a3e7 100644 --- a/faststack/faststack/qml/ExifDialog.qml +++ b/faststack/faststack/qml/ExifDialog.qml @@ -38,6 +38,19 @@ Dialog { 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 diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index 1ba60e9..26a752b 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -194,7 +194,7 @@ Window { target: uiState function onHistogramDataChanged() { if (redLoader.item && uiState.histogramData) { - redLoader.item.histogramData = uiState.histogramData.r_hist + redLoader.item.histogramData = uiState.histogramData.r redLoader.item.clipCount = uiState.histogramData.r_clip redLoader.item.preClipCount = uiState.histogramData.r_preclip // Access canvas through item: item.children[0].children[1] is fragile @@ -217,7 +217,7 @@ Window { target: uiState function onHistogramDataChanged() { if (greenLoader.item && uiState.histogramData) { - greenLoader.item.histogramData = uiState.histogramData.g_hist + greenLoader.item.histogramData = uiState.histogramData.g greenLoader.item.clipCount = uiState.histogramData.g_clip greenLoader.item.preClipCount = uiState.histogramData.g_preclip greenLoader.item.children[0].children[1].requestPaint() @@ -239,7 +239,7 @@ Window { target: uiState function onHistogramDataChanged() { if (blueLoader.item && uiState.histogramData) { - blueLoader.item.histogramData = uiState.histogramData.b_hist + blueLoader.item.histogramData = uiState.histogramData.b blueLoader.item.clipCount = uiState.histogramData.b_clip blueLoader.item.preClipCount = uiState.histogramData.b_preclip blueLoader.item.children[0].children[1].requestPaint() diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index de3045d..b3a6b99 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -40,6 +40,18 @@ Window { // Background color: imageEditorDialog.backgroundColor + // Keyboard Shortcuts + Item { + anchors.fill: parent + focus: true + Keys.onPressed: (event) => { + if (event.key === Qt.Key_E || event.key === Qt.Key_Escape) { + uiState.isEditorOpen = false + event.accepted = true + } + } + } + ScrollView { anchors.fill: parent anchors.margins: 10 @@ -107,6 +119,16 @@ Window { editDialog.updatePulse++ } } + + Button { + text: "Auto Levels" + Layout.fillWidth: true + Layout.topMargin: 5 + onClicked: { + controller.auto_levels() + editDialog.updatePulse++ + } + } // --- Effects Group --- Label { text: "Effects"; font.bold: true; color: imageEditorDialog.textColor; Layout.topMargin: 10 } @@ -230,18 +252,14 @@ Window { controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) } - // Double click to reset - MouseArea { - anchors.fill: parent + // Double click/tap to reset + TapHandler { acceptedButtons: Qt.LeftButton - propagateComposedEvents: true - onDoubleClicked: (mouse) => { + onDoubleTapped: { controller.set_edit_parameter(model.key, 0.0) + slider.value = 0.0 editDialog.updatePulse++ } - onPressed: (mouse) => { - mouse.accepted = false // Let slider handle drag - } } onPressedChanged: { diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index 45f83df..7240696 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -31,11 +31,13 @@ Dialog { photoshopPathField.text = settingsDialog.photoshopPath optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor) autoLevelThresholdField.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + settingsDialog.autoLevelStrength = uiState.autoLevelStrength } property string heliconPath: "" property double cacheSize: 1.5 property double autoLevelClippingThreshold: 0.1 + property double autoLevelStrength: 1.0 property int prefetchRadius: 4 property int theme: 0 property string defaultDirectory: "" @@ -60,6 +62,7 @@ Dialog { uiState.set_default_directory(defaultDirectory) uiState.set_optimize_for(optimizeFor) uiState.autoLevelClippingThreshold = autoLevelClippingThreshold + uiState.autoLevelStrength = autoLevelStrength uiState.awbMode = awbMode uiState.awbStrength = awbStrength @@ -235,6 +238,19 @@ Dialog { } } Label {} // Placeholder + + // Auto Levels Strength + Label { text: "Auto Levels Strength:" } + Slider { + id: autoLevelStrengthSlider + from: 0.0 + to: 1.0 + stepSize: 0.05 + value: settingsDialog.autoLevelStrength + onValueChanged: settingsDialog.autoLevelStrength = value + Layout.fillWidth: true + } + Label { text: Math.round(settingsDialog.autoLevelStrength * 100) + "%" } } GridLayout { diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 6701c30..6eb87e4 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -47,7 +47,7 @@ def __init__(self, controller): Qt.Key_P: "edit_in_photoshop", Qt.Key_C: "clear_all_stacks", Qt.Key_A: "quick_auto_white_balance", - Qt.Key_L: "auto_levels", + Qt.Key_L: "quick_auto_levels", Qt.Key_O: "toggle_crop_mode", Qt.Key_H: "toggle_histogram", Qt.Key_Delete: "delete_current_image", diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 89ac202..5eff04d 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -94,6 +94,7 @@ class UIState(QObject): default_directory_changed = Signal(str) isStackedJpgChanged = Signal() # New signal for isStackedJpg autoLevelClippingThresholdChanged = Signal(float) + autoLevelStrengthChanged = Signal(float) # Image Editor Signals is_editor_open_changed = Signal(bool) is_cropping_changed = Signal(bool) @@ -487,6 +488,15 @@ 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) + @Slot() def open_folder(self): self.app_controller.open_folder()