From 0208aa8840577977c5157e3cb4f799bd06c5a4c9 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 20 Nov 2025 12:56:38 -0500 Subject: [PATCH 1/4] =?UTF-8?q?Release=20v0.8=20=E2=80=94=20more=20improve?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/ChangeLog.md | 20 +++ faststack/README.md | 10 +- faststack/faststack.egg-info/PKG-INFO | 4 +- faststack/faststack/app.py | 212 +++++++++++++++++++++++- faststack/faststack/config.py | 5 + faststack/faststack/imaging/prefetch.py | 175 ++++++++++++++++--- faststack/faststack/qml/Main.qml | 116 ++++++++++--- faststack/faststack/ui/keystrokes.py | 3 + faststack/faststack/ui/provider.py | 29 +++- faststack/pyproject.toml | 2 +- 10 files changed, 510 insertions(+), 66 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 12c473e..43d4b3e 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,5 +1,25 @@ # ChangeLog +## [0.8.0] - 2025-11-20 + +### Added +- Backspace key now deletes images (in addition to Delete key) +- Photoshop integration now automatically uses RAW files when available, falling back to JPG + +### Fixed +- Fixed QML syntax error in Main.qml dialog structure (extra closing brace) +- Fixed image display not updating immediately after delete operations +- Fixed image display not updating correctly after undo/restore operations +- Fixed grey box display issue when navigating after delete/restore operations +- Fixed prefetcher generation mismatch causing cache failures after delete/undo +- Restored images now display correctly at their original position in the list + +### Changed +- Delete operations now immediately show the next image in the list +- Undo operations now navigate to and display the restored image +- Image cache is properly cleared and display generation incremented after delete/restore operations +- Prefetcher properly handles generation counter to avoid cache mismatches + ## [0.7.0] - 2025-11-20 ### Added diff --git a/faststack/README.md b/faststack/README.md index 0a88d08..71f2391 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 0.7 - November 20, 2025 +# Version 0.8 - November 20, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking. @@ -17,11 +17,12 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). - **Sidecar Metadata:** Saves flags, rejections, and stack groupings to a non-destructive `faststack.json` file. - **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus path via a settings dialog and a persistent `.ini` file. -- **Photoshop Integration:** Edit current image in Photoshop (E key) +- **Photoshop Integration:** Edit current image in Photoshop (E key) - automatically uses RAW files when available - **Clipboard Support:** Copy image path to clipboard (Ctrl+C) - **Image Filtering:** Filter images by filename - **Drag & Drop:** Drag images to external applications - **Theme Support:** Toggle between light and dark themes +- **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) ## Installation & Usage @@ -46,6 +47,9 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `Space`: Toggle Flag - `X`: Toggle Reject - `Enter`: Launch Helicon Focus with selected RAWs -- `E`: Edit in Photoshop +- `E`: Edit in Photoshop (uses RAW file if available) +- `Delete` / `Backspace`: Move image to recycle bin +- `Ctrl+Z`: Undo last delete - `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 879a364..3906d9b 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: faststack -Version: 0.7 +Version: 0.8 Summary: Ultra-fast JPG Viewer for Focus Stacking Selection Author-email: Alan Rockefeller Classifier: Programming Language :: Python :: 3 @@ -21,7 +21,7 @@ Dynamic: license-file # FastStack -# Version 0.7 - November 20, 2025 +# Version 0.8 - November 20, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index b150ac3..8f1b0ba 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -25,7 +25,7 @@ Qt, QPoint ) -from PySide6.QtWidgets import QApplication, QFileDialog +from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox from PySide6.QtQml import QQmlApplicationEngine # ⬇️ these are the ones that went missing @@ -110,6 +110,10 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self._metadata_cache = {} self._metadata_cache_index = (-1, -1) + + # -- Delete/Undo State -- + self.recycle_bin_dir = self.image_dir / "image recycle bin" + self.delete_history: List[tuple[Path, Optional[Path]]] = [] # [(jpg_path, raw_path), ...] self.resize_timer = QTimer() self.resize_timer.setSingleShot(True) @@ -516,6 +520,63 @@ def set_theme(self, theme_index): # 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.""" + if mode not in ['none', 'saturation', 'icc']: + log.error(f"Invalid color mode: {mode}") + return + + log.info(f"Setting color mode to: {mode}") + config.set('color', 'mode', mode) + config.save() + + # 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(f"Setting saturation factor to: {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() def get_default_directory(self): return config.get('core', 'default_directory') @@ -577,8 +638,133 @@ def _finish_preloading(self): self.ui_state.preloadProgress = 0 log.info("Finished preloading all images.") + @Slot() + def delete_current_image(self): + """Moves current JPG and RAW to recycle bin.""" + if not self.image_files: + self.update_status_message("No image to delete.") + return + + image_file = self.image_files[self.current_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(f"Failed to create recycle bin directory: {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(f"Moved {jpg_path.name} to recycle bin") + + 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(f"Moved {raw_path.name} to recycle bin") + + # Add to delete history for undo + self.delete_history.append((jpg_path, raw_path)) + + # Update status + files_str = ", ".join(deleted_files) + self.update_status_message(f"Deleted: {files_str}") + + # 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() + # update_prefetch will handle cancelling stale tasks and incrementing generation + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Delete failed: {e}") + log.error(f"Failed to delete image: {e}") + + @Slot() + def undo_delete(self): + """Restores the last deleted image from recycle bin.""" + if not self.delete_history: + self.update_status_message("Nothing to undo.") + return + + 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(f"Restored {jpg_path.name} from recycle bin") + + # 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(f"Restored {raw_path.name} from recycle bin") + + # Update status + files_str = ", ".join(restored_files) + self.update_status_message(f"Restored: {files_str}") + + # 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() + # update_prefetch will handle cancelling stale tasks and incrementing generation + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.error(f"Failed to restore image: {e}") + # Put it back in history if it failed + self.delete_history.append((jpg_path, raw_path)) + 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: + reply = QMessageBox.question( + None, + "Empty Recycle Bin?", + f"There are {len(files_in_bin)} files in the recycle bin. Do you want to permanently delete them?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.empty_recycle_bin() + # Clear QML context property to prevent TypeErrors during shutdown if self.engine: log.info("Clearing uiState context property in QML.") @@ -589,13 +775,35 @@ def shutdown(self): 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) + log.info("Emptied recycle bin") + except OSError as e: + log.error(f"Failed to empty recycle bin: {e}") + @Slot() def edit_in_photoshop(self): if not self.image_files: self.update_status_message("No image to edit.") return - current_image_path = self.image_files[self.current_index].path + # Prefer RAW file if it exists, otherwise use JPG + image_file = self.image_files[self.current_index] + raw_path = image_file.raw_pair + + if raw_path and raw_path.exists(): + current_image_path = raw_path + log.info(f"Using RAW file for Photoshop: {raw_path}") + else: + current_image_path = image_file.path + log.info(f"Using JPG file for Photoshop: {current_image_path}") + photoshop_exe = config.get('photoshop', 'exe') photoshop_args = config.get('photoshop', 'args') diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 4ff8fd9..095be0e 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -23,6 +23,11 @@ "exe": "C:\\Program Files\\Adobe\\Adobe Photoshop 2026\\Photoshop.exe", "args": "", }, + "color": { + "mode": "none", # Options: "none", "saturation", "icc" + "saturation_factor": "0.85", # Option A: 0.0-1.0, lower = less saturated + "monitor_icc_path": "", # Option C: path to monitor ICC profile + }, } class AppConfig: diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 24a7491..b82297d 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -2,15 +2,74 @@ import logging import os +import io from concurrent.futures import ThreadPoolExecutor, Future from typing import List, Dict, Optional, Callable import mmap +import numpy as np +from PIL import Image as PILImage, ImageCms + from faststack.models import ImageFile, DecodedImage from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized +from faststack.config import config log = logging.getLogger(__name__) +# ---- Option C: ICC Color Management Setup ---- +SRGB_PROFILE = ImageCms.createProfile("sRGB") + +def get_monitor_profile(): + """Dynamically load monitor ICC profile based on current config.""" + try: + monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() + if monitor_icc_path: + profile = ImageCms.ImageCmsProfile(monitor_icc_path) + log.debug(f"Loaded monitor ICC profile: {monitor_icc_path}") + return profile + else: + log.warning("ICC mode enabled but no monitor_icc_path configured") + return None + except Exception as e: + log.warning(f"Failed to load monitor ICC profile: {e}") + return None + + +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: 1.0 = no change, <1.0 = less saturated, >1.0 = more saturated + """ + if factor == 1.0: + return + + # Treat the buffer as [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): self.image_files = image_files @@ -77,32 +136,102 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, return None try: - # Memory-mapped file reading (faster than traditional read) - with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - jpeg_bytes = mmapped[:] + # Get current color management mode + color_mode = config.get('color', 'mode', fallback="none").lower() + + # Option C: Full ICC pipeline with Pillow + if color_mode == "icc": + monitor_profile = get_monitor_profile() + + if monitor_profile is not None: + img = PILImage.open(str(image_file.path)) + + # Resize before color conversion for speed + if display_width > 0 and display_height > 0: + img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + + # Extract embedded ICC profile or assume sRGB + icc_bytes = img.info.get("icc_profile") + src_profile = None + + if icc_bytes: + try: + src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) + log.debug(f"Using embedded ICC profile from {image_file.path}") + except Exception as e: + log.warning(f"Failed to parse ICC profile from {image_file.path}: {e}") + + if src_profile is None: + src_profile = SRGB_PROFILE + log.debug(f"No embedded profile, assuming sRGB for {image_file.path}") + + # Convert from source profile to monitor profile + log.debug(f"Converting image from source to monitor profile") + img = ImageCms.profileToProfile( + img, + src_profile, + monitor_profile, + outputMode="RGB", + ) + + rgb = np.array(img, dtype=np.uint8) + h, w, _ = rgb.shape + bytes_per_line = w * 3 + arr = rgb.reshape(-1).copy() + else: + # Fall back to standard decode if ICC profile not available + log.warning("ICC mode selected but no monitor profile available, using standard decode") + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + jpeg_bytes = mmapped[:] + + buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) + if buffer is None: + return None + + h, w, _ = buffer.shape + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() - buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) - if buffer is not None: - # Re-check generation before caching to prevent race conditions - if self.generation != local_generation: - log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") + else: + # Standard decode path (Option A or no color management) + with open(image_file.path, "rb") as f: + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + jpeg_bytes = mmapped[:] + + buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) + if buffer is None: return None - + h, w, _ = buffer.shape - # In a real Qt app, we would create the QImage here in the main thread - # For now, we'll just store the raw buffer data. - decoded_image = DecodedImage( - buffer=buffer.data, - width=w, - height=h, - bytes_per_line=w * 3, - format=None # Placeholder for QImage.Format.Format_RGB888 - ) - cache_key = f"{index}_{display_generation}" - self.cache_put(cache_key, decoded_image) - log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}") - return index, display_generation + bytes_per_line = w * 3 + arr = buffer.reshape(-1).copy() + + # Option A: Saturation compensation + if color_mode == "saturation": + try: + factor = float(config.get('color', 'saturation_factor', fallback="1.0")) + apply_saturation_compensation(arr, w, h, bytes_per_line, factor) + except Exception as e: + log.warning(f"Failed to apply saturation compensation: {e}") + + # Re-check generation before caching + if self.generation != local_generation: + log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") + 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 = f"{index}_{display_generation}" + self.cache_put(cache_key, decoded_image) + log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}") + return index, display_generation + except Exception as e: log.error(f"Error decoding image {image_file.path} at index {index}: {e}") diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 2ee2124..e9a71d0 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -115,6 +115,40 @@ ApplicationWindow { font.pixelSize: 16 } } + + // Saturation slider (only visible in saturation mode) + Row { + visible: 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.saturationFactor + stepSize: 0.01 + width: 150 + + onMoved: { + 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.statusMessage @@ -183,6 +217,34 @@ ApplicationWindow { Menu { title: "&View" Action { text: "Toggle Light/Dark Mode"; onTriggered: root.toggleTheme() } + MenuSeparator {} + + ActionGroup { + id: colorModeGroup + exclusive: true + } + + Action { + text: "Color: None (Original)" + checkable: true + checked: uiState.colorMode === "none" + onTriggered: controller.set_color_mode("none") + ActionGroup.group: colorModeGroup + } + Action { + text: "Color: Saturation Compensation" + checkable: true + checked: uiState.colorMode === "saturation" + onTriggered: controller.set_color_mode("saturation") + ActionGroup.group: colorModeGroup + } + Action { + text: "Color: Full ICC Profile" + checkable: true + checked: uiState.colorMode === "icc" + onTriggered: controller.set_color_mode("icc") + ActionGroup.group: colorModeGroup + } } Menu { title: "&Actions" @@ -258,40 +320,40 @@ ApplicationWindow { title: "Key Bindings" standardButtons: Dialog.Ok modal: true - width: 400 - height: 400 + width: 500 + height: 600 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

" + - "Viewing:
" + - "  Mouse Wheel: Zoom in/out
" + - "  Left-click + Drag: Pan image
" + - "  Ctrl+0: Reset zoom and pan to fit window
" + - "  G: Toggle Grid View (not implemented)

" + - "Rating & Stacking:
" + - "  Space: Toggle Flag
" + - "  X: Toggle Reject
" + - "  S: Add to selection for Helicon
" + - "  [: Begin new stack
" + - "  ]: End current stack
" + - "  C: Clear all stacks

" + - "Actions:
" + - "  Enter: Launch Helicon Focus
" + - "  E: Edit in Photoshop
" + - "  Ctrl+C: Copy image path to clipboard" - padding: 10 + contentItem: Text { + text: "FastStack Keyboard and Mouse Commands

" + + "Navigation:
" + + "  J / Right Arrow: Next Image
" + + "  K / Left Arrow: Previous Image

" + + "Viewing:
" + + "  Mouse Wheel: Zoom in/out
" + + "  Left-click + Drag: Pan image
" + + "  Ctrl+0: Reset zoom and pan to fit window
" + + "  G: Toggle Grid View (not implemented)

" + + "Rating & Stacking:
" + + "  Space: Toggle Flag
" + + "  X: Toggle Reject
" + + "  S: Add to selection for Helicon
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks

" + + "File Management:
" + + "  Delete: Move current image to recycle bin
" + + "  Ctrl+Z: Undo last delete

" + + "Actions:
" + + "  Enter: Launch Helicon Focus
" + + "  E: Edit in Photoshop
" + + "  Ctrl+C: Copy image path to clipboard" + padding: 10 wrapMode: Text.WordWrap color: root.currentTextColor - } } } diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 699f6ee..cd3327d 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -39,11 +39,14 @@ def __init__(self, controller): Qt.Key_Return: "launch_helicon", Qt.Key_E: "edit_in_photoshop", Qt.Key_C: "clear_all_stacks", # Keep C for clear_all_stacks + 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", } def _call(self, method_name: str): diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index f4153ee..725dcda 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -6,6 +6,7 @@ from PySide6.QtQuick import QQuickImageProvider from faststack.models import DecodedImage +from faststack.config import config # Try to import QColorSpace if available (Qt 6+) try: @@ -43,18 +44,18 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: QImage.Format.Format_RGB888 ) # Set sRGB color space for proper color management (if available) - # This tells Qt: "this decoded image data is in sRGB color space" - if HAS_COLOR_SPACE: + # 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: - cs = QColorSpace.fromNamedColorSpace( - QColorSpace.NamedColorSpace.SRgb - ) + # 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 Exception as e: + except (RuntimeError, ValueError) as e: log.warning(f"Failed to set color space: {e}") - else: - log.debug("QColorSpace not available in this PySide6 version") + 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 @@ -81,6 +82,8 @@ class UIState(QObject): 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 def __init__(self, app_controller): super().__init__() @@ -196,6 +199,16 @@ 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, constant=True) def currentDirectory(self): """Returns the path of the current working directory.""" diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index b059ff2..074bc6d 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "0.7" +version = "0.8" authors = [ { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ] From 2d9ada119333d07b66aae72637b8431546bc4d0b Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 20 Nov 2025 13:11:42 -0500 Subject: [PATCH 2/4] =?UTF-8?q?Release=20v0.8=20=E2=80=94=20more=20improve?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/ChangeLog.md | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 43d4b3e..0207d97 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -3,22 +3,9 @@ ## [0.8.0] - 2025-11-20 ### Added -- Backspace key now deletes images (in addition to Delete key) -- Photoshop integration now automatically uses RAW files when available, falling back to JPG - -### Fixed -- Fixed QML syntax error in Main.qml dialog structure (extra closing brace) -- Fixed image display not updating immediately after delete operations -- Fixed image display not updating correctly after undo/restore operations -- Fixed grey box display issue when navigating after delete/restore operations -- Fixed prefetcher generation mismatch causing cache failures after delete/undo -- Restored images now display correctly at their original position in the list - -### Changed -- Delete operations now immediately show the next image in the list -- Undo operations now navigate to and display the restored image -- Image cache is properly cleared and display generation incremented after delete/restore operations -- Prefetcher properly handles generation counter to avoid cache mismatches +- 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 From d24a486fd1b721fb002890c5eaaa62f2c0171380 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 20 Nov 2025 13:54:09 -0500 Subject: [PATCH 3/4] =?UTF-8?q?Release=20v0.8=20=E2=80=94=20more=20improve?= =?UTF-8?q?ments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/faststack/app.py | 65 ++++++++++++++++++++++------ faststack/faststack/io/indexer.py | 7 +++ faststack/faststack/io/sidecar.py | 37 ++++++++++------ faststack/faststack/logging_setup.py | 20 ++++++--- 4 files changed, 97 insertions(+), 32 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 8f1b0ba..c15cb23 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -4,6 +4,7 @@ import sys import struct import shlex +import time from pathlib import Path from typing import Optional, List, Dict from datetime import date @@ -60,6 +61,9 @@ def make_hdrop(paths): log = logging.getLogger(__name__) +# Global flag for debug mode - set by main() +_debug_mode = False + class AppController(QObject): dataChanged = Signal() # New signal for general data changes @@ -95,6 +99,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): prefetch_radius=config.getint('core', 'prefetch_radius', 4), get_display_info=self.get_display_info ) + self.last_displayed_image: Optional[DecodedImage] = None # Cache last image to avoid grey squares # -- UI State -- self.ui_state = UIState(self) @@ -239,7 +244,7 @@ def refresh_image_list(self): self.ui_state.imageCountChanged.emit() def get_decoded_image(self, index: int) -> Optional[DecodedImage]: - """Retrieves a decoded image, from cache or by decoding.""" + """Retrieves a decoded image, blocking until ready to ensure correct display.""" if not self.image_files: # Handle empty image list log.warning("get_decoded_image called with empty image_files.") return None @@ -247,26 +252,43 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: _, _, display_gen = self.get_display_info() cache_key = f"{index}_{display_gen}" + # Check cache first if cache_key in self.image_cache: - return self.image_cache[cache_key] + decoded = self.image_cache[cache_key] + self.last_displayed_image = decoded + return decoded + + # Cache miss: need to decode synchronously to ensure correct image displays + if _debug_mode: + decode_start = time.perf_counter() + log.info(f"Cache miss for index {index} (gen: {display_gen}). Blocking decode.") - # If not in cache, this was likely a cache miss. - # The prefetcher should have it, but we can do a blocking load if needed. - log.warning(f"Cache miss for index {index} (gen: {display_gen}). Forcing synchronous load.") future = self.prefetcher.submit_task(index, self.prefetcher.generation) if future: try: - # Wait for the result and then retrieve from cache - result = future.result() + # 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: - return self.image_cache[cache_key] + decoded = self.image_cache[cache_key] + self.last_displayed_image = decoded + if _debug_mode: + elapsed = time.perf_counter() - decode_start + log.info(f"Decoded image {index} in {elapsed:.3f}s") + return decoded + except concurrent.futures.TimeoutError: + log.error(f"Timeout decoding image at index {index}") + return self.last_displayed_image except concurrent.futures.CancelledError: - log.warning(f"Prefetch task for index {index} was cancelled. Attempting synchronous load.") - return None - return None + log.warning(f"Decode cancelled for index {index}") + return self.last_displayed_image + except Exception as e: + log.error(f"Error decoding image at index {index}: {e}") + return self.last_displayed_image + + return self.last_displayed_image def sync_ui_state(self): """Forces the UI to update by emitting all state change signals.""" @@ -963,14 +985,25 @@ def is_stacked(self) -> bool: meta = self.sidecar.get_metadata(stem) return meta.stacked -def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of images to view")): +def main( + image_dir: Optional[Path] = typer.Argument(None, help="Directory of images to view"), + debug: bool = typer.Option(False, "--debug", help="Enable debug logging and timing information") +): """FastStack Application Entry Point""" - setup_logging() + global _debug_mode + _debug_mode = debug + + t0 = time.perf_counter() + setup_logging(debug) + if debug: + log.info(f"Startup: after setup_logging: {time.perf_counter() - t0:.3f}s") log.info("Starting FastStack") os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" app = QApplication(sys.argv) # Moved here + if debug: + log.info(f"Startup: after QApplication: {time.perf_counter() - t0:.3f}s") if image_dir is None: image_dir_str = config.get('core', 'default_directory') @@ -992,6 +1025,8 @@ def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of ima engine = QQmlApplicationEngine() controller = AppController(image_dir, engine) + if debug: + log.info(f"Startup: after AppController: {time.perf_counter() - t0:.3f}s") image_provider = ImageProvider(controller) engine.addImageProvider("provider", image_provider) @@ -1002,6 +1037,8 @@ def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of ima qml_file = Path(__file__).parent / "qml" / "Main.qml" engine.load(QUrl.fromLocalFile(str(qml_file))) + if debug: + log.info(f"Startup: after engine.load(QML): {time.perf_counter() - t0:.3f}s") if not engine.rootObjects(): log.error("Failed to load QML.") @@ -1014,6 +1051,8 @@ def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of ima # Load data and start services controller.load() + if debug: + log.info(f"Startup: after controller.load(): {time.perf_counter() - t0:.3f}s") # Graceful shutdown app.aboutToQuit.connect(controller.shutdown) diff --git a/faststack/faststack/io/indexer.py b/faststack/faststack/io/indexer.py index 9ad1aed..1b34128 100644 --- a/faststack/faststack/io/indexer.py +++ b/faststack/faststack/io/indexer.py @@ -2,6 +2,7 @@ import logging import os +import time from pathlib import Path from typing import List, Dict, Tuple @@ -18,6 +19,7 @@ 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(f"Scanning directory for images: {directory}") jpgs: List[Tuple[Path, os.stat_result]] = [] raws: Dict[str, List[Tuple[Path, os.stat_result]]] = {} @@ -50,6 +52,11 @@ def find_images(directory: Path) -> List[ImageFile]: timestamp=jpg_stat.st_mtime, )) + elapsed = time.perf_counter() - t_start + # Import debug flag from app module + from faststack.app import _debug_mode + if _debug_mode: + log.info(f"find_images: found {len(image_files)} images in {elapsed:.3f}s") log.info(f"Found {len(image_files)} JPG files and paired {sum(1 for im in image_files if im.raw_pair)} with RAWs.") return image_files diff --git a/faststack/faststack/io/sidecar.py b/faststack/faststack/io/sidecar.py index 770d0b1..6f35d29 100644 --- a/faststack/faststack/io/sidecar.py +++ b/faststack/faststack/io/sidecar.py @@ -2,6 +2,7 @@ import json import logging +import time from pathlib import Path from typing import Optional @@ -29,23 +30,31 @@ def load(self) -> Sidecar: 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) - if data.get("version") != 2: - log.warning("Old sidecar format detected. Starting fresh.") - return Sidecar() + json_load_time = time.perf_counter() - t_start + + # Import debug flag from app module + from faststack.app import _debug_mode + if _debug_mode: + log.info(f"SidecarManager.load: json.load() 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", []), - ) + # 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 diff --git a/faststack/faststack/logging_setup.py b/faststack/faststack/logging_setup.py index 470959e..73737b6 100644 --- a/faststack/faststack/logging_setup.py +++ b/faststack/faststack/logging_setup.py @@ -12,8 +12,12 @@ def get_app_data_dir() -> Path: return Path(app_data) / "faststack" return Path.home() / ".faststack" -def setup_logging(): - """Sets up logging to a rotating file in the app data directory.""" +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" @@ -27,10 +31,16 @@ def setup_logging(): handler.setFormatter(formatter) root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) + # Set log level based on debug flag + root_logger.setLevel(logging.DEBUG if debug else logging.INFO) root_logger.addHandler(handler) # Configure logging for key modules - logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG) - logging.getLogger("faststack.imaging.prefetch").setLevel(logging.DEBUG) + 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) From 9ac05966ccf9a3cd405e162b5e0dc86cf22b50fb Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 20 Nov 2025 16:41:11 -0500 Subject: [PATCH 4/4] =?UTF-8?q?Release=20v0.8=20=E2=80=94=20performance=20?= =?UTF-8?q?improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/faststack/app.py | 208 +++++++++++++++--------- faststack/faststack/imaging/prefetch.py | 134 +++++++++++---- faststack/faststack/io/indexer.py | 21 +-- faststack/faststack/qml/Main.qml | 16 +- faststack/faststack/ui/provider.py | 12 ++ faststack/pyproject.toml | 3 +- 6 files changed, 259 insertions(+), 135 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index c15cb23..38eba69 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -5,11 +5,11 @@ import struct import shlex import time +import argparse from pathlib import Path from typing import Optional, List, Dict from datetime import date import os -import typer import concurrent.futures import threading import subprocess @@ -84,6 +84,8 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self.display_height = 0 self.display_generation = 0 self.is_zoomed = False + self.display_ready = False # Track if display size has been reported + self.pending_prefetch_index: Optional[int] = None # Deferred prefetch index # -- Backend Components -- self.watcher = Watcher(self.image_dir, self.refresh_image_list) @@ -115,6 +117,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self._metadata_cache = {} self._metadata_cache_index = (-1, -1) + self._logged_empty_metadata = False # -- Delete/Undo State -- self.recycle_bin_dir = self.image_dir / "image recycle bin" @@ -144,7 +147,7 @@ def apply_filter(self, filter_string: str): # reset to start of filtered list self.current_index = 0 self.sync_ui_state() - self.prefetcher.update_prefetch(self.current_index) + self._do_prefetch(self.current_index) @Slot(result=str) def get_filter_string(self): @@ -162,7 +165,7 @@ def clear_filter(self): self.ui_state.filterStringChanged.emit() # Notify UI of filter change self.current_index = min(self.current_index, max(0, len(self.image_files) - 1)) self.sync_ui_state() - self.prefetcher.update_prefetch(self.current_index) + self._do_prefetch(self.current_index) @@ -183,20 +186,34 @@ def on_display_size_changed(self, width: int, height: int): def _handle_resize(self): """Actual resize handler, called after debounce period.""" - log.info(f"Display size changed to: {self.pending_width}x{self.pending_height} (physical pixels)") + log.info("Display size changed to: %dx%d (physical pixels)", self.pending_width, self.pending_height) self.display_width = self.pending_width self.display_height = self.pending_height self.display_generation += 1 + + # Mark display as ready after first size report + is_first_resize = not self.display_ready + if is_first_resize: + self.display_ready = True + log.info("Display size now stable, enabling prefetch") + self.image_cache.clear() self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) + + # On first resize, execute deferred prefetch; on subsequent resizes, do normal prefetch + if is_first_resize and self.pending_prefetch_index is not None: + self.prefetcher.update_prefetch(self.pending_prefetch_index) + self.pending_prefetch_index = None + else: + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() # To refresh the image def set_zoomed(self, zoomed: bool): if self.is_zoomed == zoomed: return self.is_zoomed = zoomed - log.info(f"Zoom state changed to: {zoomed}") + log.info("Zoom state changed to: %s", zoomed) self.display_generation += 1 # Invalidate cache self.image_cache.clear() self.prefetcher.cancel_all() @@ -211,6 +228,19 @@ def eventFilter(self, watched: QObject, event: QEvent) -> bool: return True return super().eventFilter(watched, event) + def _do_prefetch(self, index: int, is_navigation: bool = False): + """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.) + """ + 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) + def load(self): """Loads images, sidecar data, and starts services.""" self.refresh_image_list() @@ -221,7 +251,7 @@ def load(self): self.stacks = self.sidecar.data.stacks # Load stacks from sidecar self.dataChanged.emit() # Emit after stacks are loaded self.watcher.start() - self.prefetcher.update_prefetch(self.current_index) + self._do_prefetch(self.current_index) # Defer initial UI sync until after images are loaded self.sync_ui_state() @@ -261,7 +291,7 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: # Cache miss: need to decode synchronously to ensure correct image displays if _debug_mode: decode_start = time.perf_counter() - log.info(f"Cache miss for index {index} (gen: {display_gen}). Blocking decode.") + log.info("Cache miss for index %d (gen: %d). Blocking decode.", index, display_gen) future = self.prefetcher.submit_task(index, self.prefetcher.generation) if future: @@ -276,16 +306,16 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: self.last_displayed_image = decoded if _debug_mode: elapsed = time.perf_counter() - decode_start - log.info(f"Decoded image {index} in {elapsed:.3f}s") + log.info("Decoded image %d in %.3fs", index, elapsed) return decoded except concurrent.futures.TimeoutError: - log.error(f"Timeout decoding image at index {index}") + log.error("Timeout decoding image at index %d", index) return self.last_displayed_image except concurrent.futures.CancelledError: - log.warning(f"Decode cancelled for index {index}") + log.warning("Decode cancelled for index %d", index) return self.last_displayed_image except Exception as e: - log.error(f"Error decoding image at index {index}: {e}") + log.error("Error decoding image at index %d: %s", index, e) return self.last_displayed_image return self.last_displayed_image @@ -303,13 +333,16 @@ def sync_ui_state(self): self.ui_state.metadataChanged.emit() log.debug( - f"UI State Synced: Index={self.ui_state.currentIndex}, " - f"Count={self.ui_state.imageCount}" + "UI State Synced: Index=%d, Count=%d", + self.ui_state.currentIndex, + self.ui_state.imageCount ) log.debug( - f"Metadata Synced: Filename={self.ui_state.currentFilename}, " - f"Flagged={self.ui_state.isFlagged}, Rejected={self.ui_state.isRejected}, " - f"StackInfo='{self.ui_state.stackInfoText}'" + "Metadata Synced: Filename=%s, Flagged=%s, Rejected=%s, StackInfo='%s'", + self.ui_state.currentFilename, + self.ui_state.isFlagged, + self.ui_state.isRejected, + self.ui_state.stackInfoText ) @@ -318,13 +351,13 @@ def sync_ui_state(self): def next_image(self): if self.current_index < len(self.image_files) - 1: self.current_index += 1 - self.prefetcher.update_prefetch(self.current_index) + self._do_prefetch(self.current_index, is_navigation=True) self.sync_ui_state() def prev_image(self): if self.current_index > 0: self.current_index -= 1 - self.prefetcher.update_prefetch(self.current_index) + self._do_prefetch(self.current_index, is_navigation=True) self.sync_ui_state() def toggle_grid_view(self): @@ -332,8 +365,11 @@ def toggle_grid_view(self): def get_current_metadata(self) -> Dict: if not self.image_files: - log.debug("get_current_metadata: image_files is empty, returning {}.") + if not self._logged_empty_metadata: + log.debug("get_current_metadata: image_files is empty, returning {}.") + self._logged_empty_metadata = True return {} + self._logged_empty_metadata = False # Cache hit check cache_key = (self.current_index, self.ui_refresh_generation) @@ -374,13 +410,13 @@ def toggle_current_reject(self): def begin_new_stack(self): self.stack_start_index = self.current_index - log.info(f"Stack start marked at index {self.stack_start_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(f"end_current_stack called. stack_start_index: {self.stack_start_index}") + 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) @@ -388,7 +424,7 @@ def end_current_stack(self): self.stacks.sort() # Keep stacks sorted by start index self.sidecar.data.stacks = self.stacks self.sidecar.save() - log.info(f"Defined new stack: [{start}, {end}]") + 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 @@ -406,10 +442,10 @@ def toggle_selection(self): if image_file.raw_pair: if image_file.raw_pair in self.selected_raws: self.selected_raws.remove(image_file.raw_pair) - log.info(f"Removed {image_file.raw_pair.name} from selection.") + log.info("Removed %s from selection.", image_file.raw_pair.name) else: self.selected_raws.add(image_file.raw_pair) - log.info(f"Added {image_file.raw_pair.name} to selection.") + log.info("Added %s to selection.", image_file.raw_pair.name) # In a real app, we'd update a selection indicator in the UI. # For now, we just log and can use it for batch operations. @@ -419,12 +455,12 @@ def toggle_selection(self): def launch_helicon(self): """Launches Helicon Focus with selected RAWs or all RAWs in defined stacks.""" if self.selected_raws: - log.info(f"Launching Helicon with {len(self.selected_raws)} selected RAW files.") + log.info("Launching Helicon with %d selected RAW files.", len(self.selected_raws)) self._launch_helicon_with_files(sorted(list(self.selected_raws))) self.selected_raws.clear() elif self.stacks: - log.info(f"Launching Helicon for {len(self.stacks)} defined stacks.") + log.info("Launching Helicon for %d defined stacks.", len(self.stacks)) for start, end in self.stacks: raw_files_to_process = [] for idx in range(start, end + 1): @@ -434,7 +470,7 @@ def launch_helicon(self): if raw_files_to_process: self._launch_helicon_with_files(raw_files_to_process) else: - log.warning(f"No valid RAW files found for stack [{start}, {end}].") + log.warning("No valid RAW files found for stack [%d, %d].", start, end) # clear_all_stacks() already emits stackSummaryChanged self.clear_all_stacks() @@ -447,7 +483,7 @@ def launch_helicon(self): def _launch_helicon_with_files(self, raw_files: List[Path]): """Helper to launch Helicon with a specific list of files.""" - log.info(f"Launching Helicon Focus with {len(raw_files)} RAW files.") + log.info("Launching Helicon Focus with %d RAW files.", len(raw_files)) unique_raw_files = sorted(list(set(raw_files))) success, tmp_path = launch_helicon_focus(unique_raw_files) if success and tmp_path: @@ -472,9 +508,9 @@ def _delete_temp_file(self, tmp_path: Path): if tmp_path.exists(): try: # os.remove(tmp_path) - log.info(f"Keeping temporary file: {tmp_path}") + log.info("Keeping temporary file: %s", tmp_path) except OSError as e: - log.error(f"Error deleting temporary file {tmp_path}: {e}") + log.error("Error deleting temporary file %s: %s", tmp_path, e) def clear_all_stacks(self): log.info("Clearing all defined stacks.") @@ -550,11 +586,12 @@ def get_color_mode(self): @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(f"Invalid color mode: {mode}") + log.error("Invalid color mode: %s", mode) return - log.info(f"Setting color mode to: {mode}") + log.info("Setting color mode to: %s", mode) config.set('color', 'mode', mode) config.save() @@ -585,7 +622,7 @@ def get_saturation_factor(self): 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(f"Setting saturation factor to: {factor}") + log.info("Setting saturation factor to: %.2f", factor) config.set('color', 'saturation_factor', str(factor)) config.save() @@ -652,7 +689,7 @@ def _on_done(_future): future.add_done_callback(_on_done) def _update_preload_progress(self, progress: int): - log.debug(f"Updating preload progress in UI: {progress}%") + log.debug("Updating preload progress in UI: %d%%", progress) self.ui_state.preloadProgress = progress def _finish_preloading(self): @@ -676,7 +713,7 @@ def delete_current_image(self): 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(f"Failed to create recycle bin directory: {e}") + log.error("Failed to create recycle bin directory: %s", e) return # Move files to recycle bin @@ -686,20 +723,24 @@ def delete_current_image(self): dest = self.recycle_bin_dir / jpg_path.name jpg_path.rename(dest) deleted_files.append(jpg_path.name) - log.info(f"Moved {jpg_path.name} to recycle bin") + 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(f"Moved {raw_path.name} to recycle bin") + log.info("Moved %s to recycle bin", raw_path.name) - # Add to delete history for undo - self.delete_history.append((jpg_path, raw_path)) + # Add to delete history only if at least one file was moved + if deleted_files: + self.delete_history.append((jpg_path, raw_path)) # Update status - files_str = ", ".join(deleted_files) - self.update_status_message(f"Deleted: {files_str}") + if deleted_files: + files_str = ", ".join(deleted_files) + self.update_status_message(f"Deleted: {files_str}") + else: + self.update_status_message("No files to delete") # Refresh image list and move to next image self.refresh_image_list() @@ -715,7 +756,7 @@ def delete_current_image(self): except OSError as e: self.update_status_message(f"Delete failed: {e}") - log.error(f"Failed to delete image: {e}") + log.exception(f"Failed to delete image: {e}") @Slot() def undo_delete(self): @@ -733,7 +774,7 @@ def undo_delete(self): if jpg_in_bin.exists(): jpg_in_bin.rename(jpg_path) restored_files.append(jpg_path.name) - log.info(f"Restored {jpg_path.name} from recycle bin") + log.info("Restored %s from recycle bin", jpg_path.name) # Restore RAW if raw_path: @@ -741,11 +782,14 @@ def undo_delete(self): if raw_in_bin.exists(): raw_in_bin.rename(raw_path) restored_files.append(raw_path.name) - log.info(f"Restored {raw_path.name} from recycle bin") + log.info("Restored %s from recycle bin", raw_path.name) # Update status - files_str = ", ".join(restored_files) - self.update_status_message(f"Restored: {files_str}") + 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() @@ -765,7 +809,7 @@ def undo_delete(self): except OSError as e: self.update_status_message(f"Undo failed: {e}") - log.error(f"Failed to restore image: {e}") + log.exception(f"Failed to restore image: {e}") # Put it back in history if it failed self.delete_history.append((jpg_path, raw_path)) @@ -805,9 +849,10 @@ def empty_recycle_bin(self): try: import shutil shutil.rmtree(self.recycle_bin_dir) - log.info("Emptied recycle bin") + self.delete_history.clear() + log.info("Emptied recycle bin and cleared delete history") except OSError as e: - log.error(f"Failed to empty recycle bin: {e}") + log.exception(f"Failed to empty recycle bin: {e}") @Slot() def edit_in_photoshop(self): @@ -821,10 +866,10 @@ def edit_in_photoshop(self): if raw_path and raw_path.exists(): current_image_path = raw_path - log.info(f"Using RAW file for Photoshop: {raw_path}") + log.info("Using RAW file for Photoshop: %s", raw_path) else: current_image_path = image_file.path - log.info(f"Using JPG file for Photoshop: {current_image_path}") + log.info("Using JPG file for Photoshop: %s", current_image_path) photoshop_exe = config.get('photoshop', 'exe') photoshop_args = config.get('photoshop', 'args') @@ -838,13 +883,13 @@ def edit_in_photoshop(self): if not is_valid: self.update_status_message(f"Photoshop validation failed: {error_msg}") - log.error(f"Photoshop executable 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(f"Image file not found or not a file: {current_image_path}") + log.error("Image file not found or not a file: %s", current_image_path) return try: @@ -859,7 +904,7 @@ def edit_in_photoshop(self): parsed_args = shlex.split(photoshop_args, posix=(os.name != 'nt')) command.extend(parsed_args) except ValueError as e: - log.error(f"Invalid photoshop_args format: {e}") + log.error("Invalid photoshop_args format: %s", e) self.update_status_message("Invalid Photoshop arguments configured") return @@ -877,13 +922,13 @@ def edit_in_photoshop(self): close_fds=True # Close unused file descriptors ) self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") - log.info(f"Launched Photoshop with: {command}") - except (OSError, subprocess.SubprocessError) as e: - self.update_status_message(f"Failed to open in Photoshop: {e}") - log.exception(f"Error launching Photoshop: {e}") + log.info("Launched Photoshop with: %s", command) except FileNotFoundError as e: self.update_status_message(f"Photoshop executable not found: {e}") log.exception(f"Photoshop executable not found: {e}") + except (OSError, subprocess.SubprocessError) as e: + self.update_status_message(f"Failed to open in Photoshop: {e}") + log.exception(f"Error launching Photoshop: {e}") @Slot() def copy_path_to_clipboard(self): @@ -894,7 +939,7 @@ def copy_path_to_clipboard(self): 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(f"Copied path to clipboard: {current_image_path}") + log.info("Copied path to clipboard: %s", current_image_path) @Slot() def reset_zoom_pan(self): @@ -923,7 +968,7 @@ def start_drag_current_image(self): file_path = self.image_files[self.current_index].path if not file_path.exists(): - log.error(f"File does not exist, cannot start drag: {file_path}") + log.error("File does not exist, cannot start drag: %s", file_path) return if self.main_window is None: @@ -954,7 +999,7 @@ def start_drag_current_image(self): # hotspot = center of image drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) - log.info(f"Starting drag for {file_path}") + log.info("Starting drag for %s", file_path) drag.exec(Qt.CopyAction) def _get_stack_info(self, index: int) -> str: @@ -967,7 +1012,7 @@ def _get_stack_info(self, index: int) -> str: break if not info and self.stack_start_index is not None and self.stack_start_index == index: info = "Stack Start Marked" - log.info(f"_get_stack_info for index {index}: {info}") + log.info("_get_stack_info for index %d: %s", index, info) return info def get_stack_summary(self) -> str: @@ -985,10 +1030,7 @@ def is_stacked(self) -> bool: meta = self.sidecar.get_metadata(stem) return meta.stacked -def main( - image_dir: Optional[Path] = typer.Argument(None, help="Directory of images to view"), - debug: bool = typer.Option(False, "--debug", help="Enable debug logging and timing information") -): +def main(image_dir: str = "", debug: bool = False): """FastStack Application Entry Point""" global _debug_mode _debug_mode = debug @@ -996,16 +1038,16 @@ def main( t0 = time.perf_counter() setup_logging(debug) if debug: - log.info(f"Startup: after setup_logging: {time.perf_counter() - t0:.3f}s") + log.info("Startup: after setup_logging: %.3fs", time.perf_counter() - t0) log.info("Starting FastStack") os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" app = QApplication(sys.argv) # Moved here if debug: - log.info(f"Startup: after QApplication: {time.perf_counter() - t0:.3f}s") + log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) - if image_dir is None: + 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.") @@ -1014,19 +1056,21 @@ def main( log.error("No image directory selected. Exiting.") sys.exit(1) image_dir_str = selected_dir - image_dir = Path(image_dir_str) + image_dir_path = Path(image_dir_str) + else: + image_dir_path = Path(image_dir) - if not image_dir.is_dir(): - log.error(f"Image directory not found: {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() - controller = AppController(image_dir, engine) + controller = AppController(image_dir_path, engine) if debug: - log.info(f"Startup: after AppController: {time.perf_counter() - t0:.3f}s") + log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) image_provider = ImageProvider(controller) engine.addImageProvider("provider", image_provider) @@ -1038,7 +1082,7 @@ def main( qml_file = Path(__file__).parent / "qml" / "Main.qml" engine.load(QUrl.fromLocalFile(str(qml_file))) if debug: - log.info(f"Startup: after engine.load(QML): {time.perf_counter() - t0:.3f}s") + log.info("Startup: after engine.load(QML): %.3fs", time.perf_counter() - t0) if not engine.rootObjects(): log.error("Failed to load QML.") @@ -1052,12 +1096,20 @@ def main( # Load data and start services controller.load() if debug: - log.info(f"Startup: after controller.load(): {time.perf_counter() - t0:.3f}s") + 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") + args = parser.parse_args() + main(image_dir=args.image_dir, debug=args.debug) + if __name__ == "__main__": - typer.run(main) + cli() diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index b82297d..857f55c 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -19,19 +19,40 @@ # ---- 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 + def get_monitor_profile(): - """Dynamically load monitor ICC profile based on current config.""" - try: - monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() - if monitor_icc_path: - profile = ImageCms.ImageCmsProfile(monitor_icc_path) - log.debug(f"Loaded monitor ICC profile: {monitor_icc_path}") - return profile - else: + """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() + + # 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") - return None - except Exception as e: - log.warning(f"Failed to load monitor ICC profile: {e}") + _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 + return 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 None @@ -47,12 +68,19 @@ def apply_saturation_compensation( arr: 1D uint8 array of length height * bytes_per_line width, height, bytes_per_line: dimensions of the image stored in arr - factor: 1.0 = no change, <1.0 = less saturated, >1.0 = more saturated + 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 @@ -86,33 +114,60 @@ def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_r ) 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 def set_image_files(self, image_files: List[ImageFile]): if self.image_files != image_files: self.image_files = image_files self.cancel_all() - def update_prefetch(self, current_index: int): - """Updates the prefetching queue based on the current image index.""" + def update_prefetch(self, current_index: int, is_navigation: bool = False): + """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.) + """ self.generation += 1 - log.debug(f"Updating prefetch for index {current_index}, generation {self.generation}") + + # 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 + + log.debug("Updating prefetch for index %d, generation %d, radius %d", current_index, self.generation, effective_radius) # Cancel stale futures stale_keys = [] for index, future in self.futures.items(): - if not self._is_in_prefetch_range(index, current_index): + if not self._is_in_prefetch_range(index, current_index, effective_radius): future.cancel() stale_keys.append(index) for key in stale_keys: del self.futures[key] - # Submit new tasks - start = max(0, current_index - self.prefetch_radius) - end = min(len(self.image_files), current_index + self.prefetch_radius + 1) + # Submit new tasks (with deduplication) + start = max(0, current_index - effective_radius) + end = min(len(self.image_files), current_index + effective_radius + 1) + + wanted = set(range(start, end)) + scheduled = self._scheduled.setdefault(self.generation, set()) + new_indices = wanted - scheduled - for i in range(start, end): + for i in new_indices: if i not in self.futures: self.submit_task(i, self.generation) + scheduled.add(i) def submit_task(self, index: int, generation: int) -> Optional[Future]: """Submits a decoding task for a given index.""" @@ -124,7 +179,7 @@ def submit_task(self, index: int, generation: int) -> Optional[Future]: future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) self.futures[index] = future - log.debug(f"Submitted prefetch task for index {index}") + log.debug("Submitted prefetch task for index %d", 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]]: @@ -132,7 +187,7 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, local_generation = self.generation # Capture current generation for this worker if generation != local_generation: - log.debug(f"Skipping stale task for index {index} (gen {generation} != {local_generation})") + log.debug("Skipping stale task for index %d (gen %d != %d)", index, generation, local_generation) return None try: @@ -157,16 +212,16 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, if icc_bytes: try: src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) - log.debug(f"Using embedded ICC profile from {image_file.path}") - except Exception as e: - log.warning(f"Failed to parse ICC profile from {image_file.path}: {e}") + 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 - log.debug(f"No embedded profile, assuming sRGB for {image_file.path}") + log.debug("No embedded profile, assuming sRGB for %s", image_file.path) # Convert from source profile to monitor profile - log.debug(f"Converting image from source to monitor profile") + log.debug("Converting image from source to monitor profile") img = ImageCms.profileToProfile( img, src_profile, @@ -212,12 +267,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, try: factor = float(config.get('color', 'saturation_factor', fallback="1.0")) apply_saturation_compensation(arr, w, h, bytes_per_line, factor) - except Exception as e: - log.warning(f"Failed to apply saturation compensation: {e}") + except (ValueError, AssertionError) as e: + log.warning("Failed to apply saturation compensation: %s", e) # Re-check generation before caching if self.generation != local_generation: - log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") + log.debug("Generation changed for index %d before caching. Skipping cache_put.", index) return None decoded_image = DecodedImage( @@ -229,17 +284,25 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, ) cache_key = f"{index}_{display_generation}" self.cache_put(cache_key, decoded_image) - log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}") + log.debug("Successfully decoded and cached image at index %d for display gen %d", index, display_generation) return index, display_generation except Exception as e: - log.error(f"Error decoding image {image_file.path} at index {index}: {e}") + log.error("Error decoding image %s at index %d: %s", image_file.path, index, e) return None - def _is_in_prefetch_range(self, index: int, current_index: int) -> bool: - """Checks if an index is within the current prefetch window.""" - return abs(index - current_index) <= self.prefetch_radius + 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.""" @@ -248,6 +311,7 @@ def cancel_all(self): 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.""" diff --git a/faststack/faststack/io/indexer.py b/faststack/faststack/io/indexer.py index 1b34128..595c7c4 100644 --- a/faststack/faststack/io/indexer.py +++ b/faststack/faststack/io/indexer.py @@ -20,7 +20,7 @@ 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(f"Scanning directory for images: {directory}") + log.info("Scanning directory for images: %s", directory) jpgs: List[Tuple[Path, os.stat_result]] = [] raws: Dict[str, List[Tuple[Path, os.stat_result]]] = {} @@ -37,7 +37,7 @@ def find_images(directory: Path) -> List[ImageFile]: raws[stem] = [] raws[stem].append((p, entry.stat())) except OSError as e: - log.error(f"Error scanning directory {directory}: {e}") + log.error("Error scanning directory %s: %s", directory, e) return [] # Sort JPGs by filename @@ -53,11 +53,12 @@ def find_images(directory: Path) -> List[ImageFile]: )) elapsed = time.perf_counter() - t_start - # Import debug flag from app module - from faststack.app import _debug_mode - if _debug_mode: - log.info(f"find_images: found {len(image_files)} images in {elapsed:.3f}s") - log.info(f"Found {len(image_files)} JPG files and paired {sum(1 for im in image_files if im.raw_pair)} with RAWs.") + paired_count = sum(1 for im in image_files if im.raw_pair) + + # Log timing info if DEBUG level is enabled + if log.isEnabledFor(logging.DEBUG): + log.info("find_images: found %d images in %.3fs", len(image_files), elapsed) + log.info("Found %d JPG files and paired %d with RAWs.", len(image_files), paired_count) return image_files def _find_raw_pair( @@ -79,9 +80,5 @@ def _find_raw_pair( min_dt = dt best_match = raw_path - if best_match: - log.debug(f"Paired {jpg_path.name} with {best_match.name} (dt={min_dt:.3f}s)") - else: - log.debug(f"No close RAW match found for {jpg_path.name}") - + # Removed per-pair debug logging to reduce noise - summary is logged at end of find_images() return best_match diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index e9a71d0..4b61da5 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -64,21 +64,21 @@ ApplicationWindow { color: root.currentTextColor } Label { - text: ` | File: ${uiState.currentFilename || 'N/A'}` + text: uiState.imageCount > 0 ? ` | File: ${uiState.currentFilename || 'N/A'}` : " | File: N/A" color: root.currentTextColor } Label { - text: ` | Flag: ${uiState.isFlagged}` - color: uiState.isFlagged ? "lightgreen" : root.currentTextColor + text: uiState.imageCount > 0 ? ` | Flag: ${uiState.isFlagged}` : " | Flag: false" + color: (uiState.imageCount > 0 && uiState.isFlagged) ? "lightgreen" : root.currentTextColor } Label { - text: ` | Rejected: ${uiState.isRejected}` - color: uiState.isRejected ? "red" : root.currentTextColor + text: uiState.imageCount > 0 ? ` | Rejected: ${uiState.isRejected}` : " | Rejected: false" + color: (uiState.imageCount > 0 && uiState.isRejected) ? "red" : root.currentTextColor } Label { text: ` | Stacked: ${uiState.stackedDate}` color: "lightgreen" - visible: uiState.isStacked + visible: uiState.imageCount > 0 && uiState.isStacked } Label { text: ` | Filter: "${uiState.filterString}"` @@ -102,14 +102,14 @@ ApplicationWindow { } Rectangle { Layout.fillWidth: true - color: uiState.stackInfoText ? "orange" : "transparent" // Brighter background + color: (uiState.imageCount > 0 && uiState.stackInfoText) ? "orange" : "transparent" // Brighter background radius: 3 implicitWidth: stackInfoLabel.implicitWidth + 10 implicitHeight: stackInfoLabel.implicitHeight + 5 Label { id: stackInfoLabel anchors.centerIn: parent - text: `Stack: ${uiState.stackInfoText || 'N/A'}` + text: uiState.imageCount > 0 ? `Stack: ${uiState.stackInfoText || 'N/A'}` : "Stack: N/A" color: "black" // Black text for contrast on orange font.bold: true font.pixelSize: 16 diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 725dcda..ae7feca 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -152,26 +152,38 @@ def currentImageSource(self): @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 isFlagged(self): + if not self.app_controller.image_files: + return False return self.app_controller.get_current_metadata().get("flag", False) @Property(bool, notify=metadataChanged) def isRejected(self): + if not self.app_controller.image_files: + return False return self.app_controller.get_current_metadata().get("reject", False) @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(str, notify=stackSummaryChanged) diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 074bc6d..d1a36fd 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -23,13 +23,12 @@ dependencies = [ "numpy>=2.0,<3.0", "cachetools>=5.0,<6.0", "watchdog>=4.0,<5.0", - "typer>=0.12,<1.0", "Pillow>=10.0,<11.0", "pytest>=8.0,<9.0", ] [project.scripts] -faststack = "faststack.app:main" +faststack = "faststack.app:cli" [tool.setuptools] packages = ["faststack"]