diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..687aa76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Virtual environments +.venv/ +venv/ + +# Python cache +__pycache__/ +*.pyc + +# Build outputs +dist/ +build/ +*.spec + +# Logs +*.log + +# OS cruft +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +prompt.md diff --git a/README.md b/README.md deleted file mode 100644 index 1744aeb..0000000 --- a/README.md +++ /dev/null @@ -1 +0,0 @@ -FastStack Project Root diff --git a/faststack/LICENSE b/faststack/LICENSE new file mode 100644 index 0000000..57713b7 --- /dev/null +++ b/faststack/LICENSE @@ -0,0 +1,25 @@ +The MIT License (MIT) +===================== + +Copyright © 2025 Alan Rockefeller + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/faststack/README.md b/faststack/README.md new file mode 100644 index 0000000..ddeff30 --- /dev/null +++ b/faststack/README.md @@ -0,0 +1,43 @@ +# FastStack + +# Version 0.1 - October 31, 2025 +# By Alan Rockefeller + +Ultra-fast, caching JPG viewer designed for culling and selecting RAW 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. + +## 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`: Add/Remove current RAW to/from selection set +- `[`: Begin new stack group +- `]`: End current stack group +- `Space`: Toggle Flag +- `X`: Toggle Reject +- `Enter`: Launch Helicon Focus with selected RAWs diff --git a/faststack/faststack/__init__.py b/faststack/faststack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py new file mode 100644 index 0000000..d948038 --- /dev/null +++ b/faststack/faststack/app.py @@ -0,0 +1,275 @@ +"""Main application entry point for FastStack.""" + +import logging +import sys +from pathlib import Path +from typing import Optional, List, Dict + +import os +import typer +from PySide6.QtCore import QUrl, QTimer, QObject, QEvent +from PySide6.QtGui import QGuiApplication +from PySide6.QtQml import QQmlApplicationEngine + +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.imaging.cache import ByteLRUCache, get_decoded_image_size +from faststack.imaging.prefetch import Prefetcher +from faststack.ui.provider import ImageProvider, UIState +from faststack.ui.keystrokes import Keybinder + +log = logging.getLogger(__name__) + +class AppController(QObject): + def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): + super().__init__() + self.image_dir = image_dir + self.image_files: List[ImageFile] = [] + self.current_index: int = 0 + self.ui_refresh_generation = 0 + self.main_window: Optional[QObject] = None + self.engine = engine + + # -- Backend Components -- + self.watcher = Watcher(self.image_dir, self.refresh_image_list) + self.sidecar = SidecarManager(self.image_dir, self.watcher) + + # -- Caching & Prefetching -- + cache_size_bytes = config.getint('core', 'cache_bytes', int(1.5 * 1024**3)) + self.image_cache = ByteLRUCache(max_bytes=cache_size_bytes, size_of=get_decoded_image_size) + self.prefetcher = Prefetcher( + image_files=self.image_files, + cache_put=self.image_cache.__setitem__, + prefetch_radius=config.getint('core', 'prefetch_radius', 4) + ) + + # -- UI State -- + self.ui_state = UIState(self) + self.keybinder = Keybinder(self) + + # -- Stacking State -- + self.stack_start_index: Optional[int] = None + self.stacks: List[List[int]] = [] + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if watched == self.main_window and event.type() == QEvent.Type.KeyPress: + self.keybinder.handle_key_press(event) + return True + return super().eventFilter(watched, event) + + def load(self): + """Loads images, sidecar data, and starts services.""" + self.refresh_image_list() + self.current_index = self.sidecar.data.last_index + self.stacks = self.sidecar.data.stacks # Load stacks from sidecar + self.watcher.start() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + def refresh_image_list(self): + """Rescans the directory for images.""" + self.image_files = find_images(self.image_dir) + self.prefetcher.set_image_files(self.image_files) + self.ui_state.imageCountChanged.emit() + + def get_decoded_image(self, index: int) -> Optional[DecodedImage]: + """Retrieves a decoded image, from cache or by decoding.""" + if index in self.image_cache: + return self.image_cache[index] + + # 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}. Forcing synchronous load.") + future = self.prefetcher.submit_task(index, self.prefetcher.generation) + if future: + # Wait for the result and then retrieve from cache + decoded_index = future.result() + if decoded_index is not None and decoded_index in self.image_cache: + return self.image_cache[decoded_index] + return None + + def sync_ui_state(self): + """Forces the UI to update by emitting all state change signals.""" + self.ui_refresh_generation += 1 + self.ui_state.currentIndexChanged.emit() + self.ui_state.currentImageSourceChanged.emit() + self.ui_state.metadataChanged.emit() + + # --- Actions --- + + 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.sync_ui_state() + + def prev_image(self): + if self.current_index > 0: + self.current_index -= 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + def toggle_grid_view(self): + log.warning("Grid view not implemented yet.") + + def get_current_metadata(self) -> Dict: + if not self.image_files: + return {} + + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + stack_info = self._get_stack_info(self.current_index) + + return { + "filename": self.image_files[self.current_index].path.name, + "flag": meta.flag, + "reject": meta.reject, + "stack_info_text": stack_info, + } + + def toggle_current_flag(self): + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + meta.flag = not meta.flag + self.sidecar.save() + self.ui_state.metadataChanged.emit() + + def toggle_current_reject(self): + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + meta.reject = not meta.reject + self.sidecar.save() + self.ui_state.metadataChanged.emit() + + def begin_new_stack(self): + self.stack_start_index = self.current_index + log.info(f"Stack start marked at index {self.stack_start_index}") + self.ui_state.metadataChanged.emit() # Update UI to show start marker + + def end_current_stack(self): + log.info(f"end_current_stack called. stack_start_index: {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(f"Defined new stack: [{start}, {end}]") + self.stack_start_index = None + self.ui_state.metadataChanged.emit() + else: + log.warning("No stack start marked. Press '[' first.") + + def launch_helicon(self): + if not self.stacks: + log.warning("No stacks defined to launch Helicon Focus.") + return + + all_raw_files = [] + for i, (start, end) in enumerate(self.stacks): + for idx in range(start, end + 1): + if idx < len(self.image_files) and self.image_files[idx].raw_pair: + all_raw_files.append(self.image_files[idx].raw_pair) + + if all_raw_files: + log.info(f"Launching Helicon Focus with {len(all_raw_files)} RAW files from all stacks.") + success, tmp_path = launch_helicon_focus(all_raw_files) + if success and tmp_path: + # Schedule delayed deletion of the temporary file + QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) + else: + log.warning("No valid RAW files found in any defined stack.") + + def _delete_temp_file(self, tmp_path: Path): + if tmp_path.exists(): + try: + os.remove(tmp_path) + log.info(f"Deleted temporary file: {tmp_path}") + except OSError as e: + log.error(f"Error deleting temporary file {tmp_path}: {e}") + + def clear_all_stacks(self): + log.info("Clearing all defined stacks.") + self.stacks = [] + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + self.ui_state.metadataChanged.emit() # Refresh UI to show no stacks + + def shutdown(self): + log.info("Application shutting down.") + # Clear QML context property to prevent TypeErrors during shutdown + if self.engine: + log.info("Clearing uiState context property in QML.") + self.engine.rootContext().setContextProperty("uiState", None) + 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 _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.info(f"_get_stack_info for index {index}: {info}") + return info + +def main(image_dir: Path = typer.Argument(..., help="Directory of images to view")): + """FastStack Application Entry Point""" + setup_logging() + log.info("Starting FastStack") + + if not image_dir.is_dir(): + log.error(f"Image directory not found: {image_dir}") + sys.exit(1) + + app = QGuiApplication(sys.argv) + app.setOrganizationName("FastStack") + app.setOrganizationDomain("faststack.dev") + app.setApplicationName("FastStack") + + engine = QQmlApplicationEngine() + controller = AppController(image_dir, engine) + 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) + + qml_file = Path(__file__).parent / "qml" / "Main.qml" + engine.load(QUrl.fromLocalFile(str(qml_file))) + + 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() + + # Graceful shutdown + app.aboutToQuit.connect(controller.shutdown) + + sys.exit(app.exec()) + +if __name__ == "__main__": + typer.run(main) diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py new file mode 100644 index 0000000..2b1e456 --- /dev/null +++ b/faststack/faststack/config.py @@ -0,0 +1,75 @@ +"""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_bytes": str(int(1.5 * 1024**3)), # 1.5 GB + "prefetch_radius": "4", + }, + "helicon": { + "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", + "args": "", + }, +} + +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/cache.py b/faststack/faststack/imaging/cache.py new file mode 100644 index 0000000..b760ceb --- /dev/null +++ b/faststack/faststack/imaging/cache.py @@ -0,0 +1,38 @@ +"""Byte-aware LRU cache for storing decoded image data (CPU and GPU).""" + +import logging +from typing import Any, Callable + +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): + super().__init__(maxsize=max_bytes, getsizeof=size_of) + 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") + + 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") + # 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): + return item.buffer.nbytes + return 1 # Should not happen diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py new file mode 100644 index 0000000..ab2cf6a --- /dev/null +++ b/faststack/faststack/imaging/jpeg.py @@ -0,0 +1,74 @@ +"""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, TJFLAG_FASTDCT, TJPF_RGB + jpeg_decoder = TurboJPEG() + TURBO_AVAILABLE = True + log.info("PyTurboJPEG is available. Using for JPEG decoding.") +except ImportError: + jpeg_decoder = None + TURBO_AVAILABLE = False + log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") + +def decode_jpeg_rgb(jpeg_bytes: bytes) -> Optional[np.ndarray]: + """Decodes JPEG bytes into an RGB numpy array.""" + if TURBO_AVAILABLE and jpeg_decoder: + try: + # The flags prevent upsampling of chroma channels, which is faster. + return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=TJFLAG_FASTDCT) + except Exception as e: + log.error(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.error(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) + + return jpeg_decoder.decode(jpeg_bytes, scaling_factor=scaling_factor, pixel_format=TJPF_RGB, flags=TJFLAG_FASTDCT) + except Exception as e: + log.error(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.error(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.""" + # libjpeg-turbo supports scaling factors of N/8 for N in [1, 16] + for n in range(8, 0, -1): + if (width * n / 8) <= max_dim and (height * n / 8) <= max_dim: + return (n, 8) + return None # Should not happen if max_dim is reasonable diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py new file mode 100644 index 0000000..5304a6b --- /dev/null +++ b/faststack/faststack/imaging/prefetch.py @@ -0,0 +1,108 @@ +"""Handles prefetching and decoding of adjacent images in a background thread pool.""" + +import logging +import os +from concurrent.futures import ThreadPoolExecutor, Future +from typing import List, Dict, Optional, Callable + +from faststack.models import ImageFile, DecodedImage +from faststack.imaging.jpeg import decode_jpeg_rgb + +log = logging.getLogger(__name__) + +class Prefetcher: + def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int): + self.image_files = image_files + self.cache_put = cache_put + self.prefetch_radius = prefetch_radius + self.executor = ThreadPoolExecutor( + max_workers=min(4, os.cpu_count() or 1), + thread_name_prefix="Prefetcher" + ) + self.futures: Dict[int, Future] = {} + self.generation = 0 + + def set_image_files(self, image_files: List[ImageFile]): + self.image_files = image_files + self.cancel_all() + + def update_prefetch(self, current_index: int): + """Updates the prefetching queue based on the current image index.""" + self.generation += 1 + log.debug(f"Updating prefetch for index {current_index}, generation {self.generation}") + + # Cancel stale futures + stale_keys = [] + for index, future in self.futures.items(): + if not self._is_in_prefetch_range(index, current_index): + future.cancel() + stale_keys.append(index) + for key in stale_keys: + del self.futures[key] + + # Submit new tasks + start = max(0, current_index - self.prefetch_radius) + end = min(len(self.image_files), current_index + self.prefetch_radius + 1) + + for i in range(start, end): + if i not in self.futures: + self.submit_task(i, self.generation) + + def submit_task(self, index: int, generation: int) -> Optional[Future]: + """Submits a decoding task for a given index.""" + if index in self.futures and not self.futures[index].done(): + return self.futures[index] # Already submitted + + image_file = self.image_files[index] + future = self.executor.submit(self._decode_and_cache, image_file, index, generation) + self.futures[index] = future + log.debug(f"Submitted prefetch task for index {index}") + return future + + def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int) -> Optional[int]: + """The actual work done by the thread pool.""" + if generation != self.generation: + log.debug(f"Skipping stale task for index {index} (gen {generation} != {self.generation})") + return None + + try: + with open(image_file.path, "rb") as f: + jpeg_bytes = f.read() + + buffer = decode_jpeg_rgb(jpeg_bytes) + if buffer is not 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 + ) + self.cache_put(index, decoded_image) + log.debug(f"Successfully decoded and cached image at index {index}") + return index + except Exception as e: + log.error(f"Error decoding image {image_file.path} at index {index}: {e}") + + return None + + def _is_in_prefetch_range(self, index: int, current_index: int) -> bool: + """Checks if an index is within the current prefetch window.""" + return abs(index - current_index) <= self.prefetch_radius + + def cancel_all(self): + """Cancels all pending prefetch tasks.""" + log.info("Cancelling all prefetch tasks.") + self.generation += 1 + for future in self.futures.values(): + future.cancel() + self.futures.clear() + + def shutdown(self): + """Shuts down the thread pool executor.""" + log.info("Shutting down prefetcher thread pool.") + self.cancel_all() + self.executor.shutdown(wait=False) diff --git a/faststack/faststack/io/helicon.py b/faststack/faststack/io/helicon.py new file mode 100644 index 0000000..f5dcf0d --- /dev/null +++ b/faststack/faststack/io/helicon.py @@ -0,0 +1,52 @@ +"""Handles launching Helicon Focus with a list of RAW files.""" + +import logging +import subprocess +import tempfile +from pathlib import Path +from typing import List, Optional, Tuple + +from faststack.config import config + +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: + True if the process was launched successfully, False otherwise. + """ + helicon_exe = config.get("helicon", "exe") + if not Path(helicon_exe).is_file(): + log.error(f"Helicon Focus executable not found at: {helicon_exe}") + # In a real app, this would trigger a dialog to find the exe. + 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: + tmp.write(f"{f}\n") + tmp_path = Path(tmp.name) + + log.info(f"Temporary file for Helicon Focus: {tmp_path}") + + args = [helicon_exe, "-i", str(tmp_path)] + extra_args = config.get("helicon", "args") + if extra_args: + args.extend(extra_args.split()) + + log.info(f"Launching Helicon Focus with {len(raw_files)} files.") + log.info(f"Helicon Focus command: {args}") # Log the full command + log.debug(f"Command: {args}") + subprocess.Popen(args) + return True, tmp_path + except Exception as e: + log.error(f"Failed to launch Helicon Focus: {e}") + return False, None diff --git a/faststack/faststack/io/indexer.py b/faststack/faststack/io/indexer.py new file mode 100644 index 0000000..9ad1aed --- /dev/null +++ b/faststack/faststack/io/indexer.py @@ -0,0 +1,80 @@ +"""Scans directories for JPGs and pairs them with corresponding RAW files.""" + +import logging +import os +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.""" + log.info(f"Scanning directory for images: {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.error(f"Error scanning directory {directory}: {e}") + return [] + + # Sort JPGs by filename + jpgs.sort(key=lambda x: 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, + )) + + 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 + +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 + + 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}") + + return best_match diff --git a/faststack/faststack/io/sidecar.py b/faststack/faststack/io/sidecar.py new file mode 100644 index 0000000..770d0b1 --- /dev/null +++ b/faststack/faststack/io/sidecar.py @@ -0,0 +1,90 @@ +"""Manages reading and writing the faststack.json sidecar file.""" + +import json +import logging +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): + self.path = directory / "faststack.json" + self.watcher = watcher + 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: + 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() + + # 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 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 new file mode 100644 index 0000000..623061e --- /dev/null +++ b/faststack/faststack/io/watcher.py @@ -0,0 +1,57 @@ +"""Filesystem watcher to detect changes in the image directory.""" + +import logging +from pathlib import Path + +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_any_event(self, event): + # Ignore temporary files created during atomic saves and the sidecar file itself + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected filesystem change: {event}. Triggering refresh.") + self.callback() + +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 new file mode 100644 index 0000000..b452e49 --- /dev/null +++ b/faststack/faststack/logging_setup.py @@ -0,0 +1,35 @@ +"""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(): + """Sets up logging to a rotating file in the app data directory.""" + 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() + root_logger.setLevel(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) diff --git a/faststack/faststack/models.py b/faststack/faststack/models.py new file mode 100644 index 0000000..62fdede --- /dev/null +++ b/faststack/faststack/models.py @@ -0,0 +1,39 @@ +"""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.""" + flag: bool = False + reject: bool = False + + +@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/qml/Components.qml b/faststack/faststack/qml/Components.qml new file mode 100644 index 0000000..75f1041 --- /dev/null +++ b/faststack/faststack/qml/Components.qml @@ -0,0 +1,83 @@ +import QtQuick + +// 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 + + // The main image display + Image { + id: mainImage + anchors.fill: parent + source: uiState && uiState.currentImageSource ? uiState.currentImageSource : "" + fillMode: Image.PreserveAspectFit + cache: false // We do our own caching in Python + + // Zoom and Pan logic would go here + // For example, using PinchArea or MouseArea + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + + // Simple drag-to-pan placeholder + property real lastX: 0 + property real lastY: 0 + + onPressed: { + lastX = mouseX + lastY = mouseY + } + + onPositionChanged: { + if (pressed) { + mainImage.x += (mouseX - lastX) + mainImage.y += (mouseY - lastY) + lastX = mouseX + lastY = mouseY + } + } + + // Wheel for zoom + onWheel: { + // A real implementation would be more complex, zooming + // into the cursor position. + var scaleFactor = wheel.angleDelta.y > 0 ? 1.2 : 1 / 1.2; + mainImage.scale *= scaleFactor; + } + } + } + + // Overlay for metadata + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 40 + color: "#80000000" // Semi-transparent black + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 10 + spacing: 15 + + Text { + text: uiState && uiState.currentFilename ? uiState.currentFilename : "" + color: "white" + font.pixelSize: 14 + } + Text { + text: uiState && uiState.isFlagged ? `[${uiState.isFlagged ? 'F' : ''}]` : "" + color: "lightgreen" + font.bold: true + } + Text { + text: uiState && uiState.isRejected ? `[${uiState.isRejected ? 'X' : ''}]` : "" + color: "red" + font.bold: true + } + } + } +} diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml new file mode 100644 index 0000000..37c2753 --- /dev/null +++ b/faststack/faststack/qml/Main.qml @@ -0,0 +1,116 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls 2.15 + +ApplicationWindow { + id: root + width: 1280 + height: 720 + visible: true + title: "FastStack - " + (uiState && uiState.currentFilename ? uiState.currentFilename : "No folder loaded") + + // Expose the Python UIState object to QML + // This is set from Python via setContextProperty("uiState", ...) + + // Main view: either the loupe viewer or the grid + Loader { + id: mainViewLoader + anchors.fill: parent + source: "Components.qml" + } + + // Keyboard focus and event handling + + // Status bar + footer: Rectangle { + Row { + spacing: 10 + Label { + text: `Image: ${uiState && uiState.currentIndex !== null ? uiState.currentIndex + 1 : 'N/A'} / ${uiState && uiState.imageCount !== null ? uiState.imageCount : 'N/A'}` + } + Label { + text: ` | File: ${uiState && uiState.currentFilename ? uiState.currentFilename : 'N/A'}` + } + Label { + text: ` | Flag: ${uiState && uiState.isFlagged ? uiState.isFlagged : 'N/A'}` + color: uiState && uiState.isFlagged ? "lightgreen" : "white" + } + Label { + text: ` | Rejected: ${uiState && uiState.isRejected ? uiState.isRejected : 'N/A'}` + color: uiState && uiState.isRejected ? "red" : "white" + } + Rectangle { + color: uiState && uiState.stackInfoText ? "#404000" : "transparent" // Dark yellow background + radius: 3 + implicitWidth: stackInfoLabel.implicitWidth + 10 + implicitHeight: stackInfoLabel.implicitHeight + 5 + Label { + id: stackInfoLabel + anchors.centerIn: parent + text: `Stack: ${uiState && uiState.stackInfoText ? uiState.stackInfoText : 'N/A'}` + color: uiState && uiState.stackInfoText ? "yellow" : "white" + onTextChanged: function() { console.log("Stack info text changed:", stackInfoLabel.text) } + } + } + } + } + + // Menu Bar + menuBar: MenuBar { + Menu { + title: "&File" + Action { + text: "&Open Folder..." + onTriggered: { + // This would trigger a file dialog in Python + } + } + Action { + text: "&Settings..." + } + + Action { + text: "&Exit" + onTriggered: Qt.quit() + } + } + Menu { + title: "&Help" + Action { + text: "&About" + onTriggered: aboutDialog.open() + } + } + } + + Dialog { + id: aboutDialog + title: "About FastStack" + standardButtons: Dialog.Ok + modal: true + width: 400 + height: 300 + + 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
" + + "  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" + padding: 10 + wrapMode: Text.WordWrap + } + } +} diff --git a/faststack/faststack/tests/test_cache.py b/faststack/faststack/tests/test_cache.py new file mode 100644 index 0000000..e3f90a1 --- /dev/null +++ b/faststack/faststack/tests/test_cache.py @@ -0,0 +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 diff --git a/faststack/faststack/tests/test_pairing.py b/faststack/faststack/tests/test_pairing.py new file mode 100644 index 0000000..bfe2bb1 --- /dev/null +++ b/faststack/faststack/tests/test_pairing.py @@ -0,0 +1,74 @@ +"""Tests for the RAW-JPG pairing logic.""" + +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from faststack.io.indexer import find_images, _find_raw_pair + +@pytest.fixture +def mock_image_dir(tmp_path: Path): + """Creates a temporary directory with mock image files.""" + # JPGs + (tmp_path / "IMG_0001.JPG").touch() + time.sleep(0.01) + (tmp_path / "IMG_0002.jpg").touch() + time.sleep(0.01) + (tmp_path / "IMG_0003.jpeg").touch() + time.sleep(0.01) + + # Raws (CR3) + (tmp_path / "IMG_0001.CR3").touch() # Perfect match + # Match for 0002, but with a slight time diff + two_cr3 = (tmp_path / "IMG_0002.CR3") + two_cr3.touch() + # Change timestamp slightly + os.utime(two_cr3, (two_cr3.stat().st_atime, two_cr3.stat().st_mtime + 0.5)) + + # A raw with no JPG + (tmp_path / "IMG_0004.CR3").touch() + + return tmp_path + +def test_find_images(mock_image_dir: Path): + """Tests the main find_images function.""" + images = find_images(mock_image_dir) + + assert len(images) == 3 + assert images[0].path.name == "IMG_0001.JPG" + assert images[0].raw_pair is not None + assert images[0].raw_pair.name == "IMG_0001.CR3" + + assert images[1].path.name == "IMG_0002.jpg" + assert images[1].raw_pair is not None + assert images[1].raw_pair.name == "IMG_0002.CR3" + + assert images[2].path.name == "IMG_0003.jpeg" + assert images[2].raw_pair is None + +def test_raw_pairing_logic(): + """Unit tests the _find_raw_pair function specifically.""" + jpg_path = Path("IMG_01.JPG") + jpg_stat = MagicMock(); jpg_stat.st_mtime = 1000.0 + + # Case 1: Perfect match + raw1_path = Path("IMG_01.CR3"); raw1_stat = MagicMock(); raw1_stat.st_mtime = 1000.1 + potentials = [(raw1_path, raw1_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw1_path + + # Case 2: No match (time delta too large) + raw2_path = Path("IMG_01.CR3"); raw2_stat = MagicMock(); raw2_stat.st_mtime = 1003.0 + potentials = [(raw2_path, raw2_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) is None + + # Case 3: Closest match is chosen + raw3_path = Path("IMG_01_A.CR3"); raw3_stat = MagicMock(); raw3_stat.st_mtime = 1000.5 + raw4_path = Path("IMG_01_B.CR3"); raw4_stat = MagicMock(); raw4_stat.st_mtime = 1001.8 + potentials = [(raw3_path, raw3_stat), (raw4_path, raw4_stat)] + assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw3_path + + # Case 4: No potential RAWs + assert _find_raw_pair(jpg_path, jpg_stat, []) is None diff --git a/faststack/faststack/tests/test_sidecar.py b/faststack/faststack/tests/test_sidecar.py new file mode 100644 index 0000000..ece199b --- /dev/null +++ b/faststack/faststack/tests/test_sidecar.py @@ -0,0 +1,74 @@ +"""Tests for the SidecarManager.""" + +import json +from pathlib import Path + +import pytest + +from faststack.io.sidecar import SidecarManager +from faststack.types import EntryMetadata + +@pytest.fixture +def mock_sidecar_dir(tmp_path: Path): + """Creates a temp dir and can pre-populate a sidecar file.""" + def _create(content: dict = None): + if content: + (tmp_path / "faststack.json").write_text(json.dumps(content)) + return tmp_path + return _create + +def test_sidecar_load_non_existent(mock_sidecar_dir): + """Tests loading when no sidecar file exists.""" + d = mock_sidecar_dir() + sm = SidecarManager(d) + assert sm.data.version == 1 + assert sm.data.last_index == 0 + assert not sm.data.entries + +def test_sidecar_load_existing(mock_sidecar_dir): + """Tests loading a valid, existing sidecar file.""" + content = { + "version": 1, + "last_index": 42, + "entries": { + "IMG_0001": { "flag": True, "reject": False, "stack_id": 1 }, + "IMG_0002": { "flag": False, "reject": True, "stack_id": None }, + } + } + d = mock_sidecar_dir(content) + sm = SidecarManager(d) + + assert sm.data.last_index == 42 + assert len(sm.data.entries) == 2 + assert sm.data.entries["IMG_0001"].flag is True + assert sm.data.entries["IMG_0001"].stack_id == 1 + assert sm.data.entries["IMG_0002"].reject is True + +def test_sidecar_save(mock_sidecar_dir): + """Tests saving data back to the JSON file.""" + d = mock_sidecar_dir() + sm = SidecarManager(d) + + # Modify data + sm.set_last_index(10) + meta = sm.get_metadata("IMG_TEST") + meta.flag = True + meta.stack_id = 5 + + # Save + sm.save() + + # Verify file content + saved_data = json.loads((d / "faststack.json").read_text()) + assert saved_data["last_index"] == 10 + assert saved_data["entries"]["IMG_TEST"]["flag"] is True + assert saved_data["entries"]["IMG_TEST"]["stack_id"] == 5 + +def test_sidecar_get_metadata_creates_new(mock_sidecar_dir): + """Tests that get_metadata creates a new entry if one doesn't exist.""" + d = mock_sidecar_dir() + sm = SidecarManager(d) + assert "NEW_IMG" not in sm.data.entries + meta = sm.get_metadata("NEW_IMG") + assert isinstance(meta, EntryMetadata) + assert "NEW_IMG" in sm.data.entries diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py new file mode 100644 index 0000000..5c6e6a0 --- /dev/null +++ b/faststack/faststack/ui/keystrokes.py @@ -0,0 +1,44 @@ +"""Maps Qt Key events to application actions.""" + +import logging +from PySide6.QtCore import Qt + +log = logging.getLogger(__name__) + +class Keybinder: + def __init__(self, main_window): + self.main_window = main_window + self.key_map = { + # Navigation + Qt.Key.Key_J: self.main_window.next_image, + Qt.Key.Key_Right: self.main_window.next_image, + Qt.Key.Key_K: self.main_window.prev_image, + Qt.Key.Key_Left: self.main_window.prev_image, + + # View Mode + Qt.Key.Key_G: self.main_window.toggle_grid_view, + + # Metadata + Qt.Key.Key_Space: self.main_window.toggle_current_flag, + Qt.Key.Key_X: self.main_window.toggle_current_reject, + + # Stacking + Qt.Key.Key_BracketLeft: self.main_window.begin_new_stack, + Qt.Key.Key_BracketRight: self.main_window.end_current_stack, + + # Actions + Qt.Key.Key_S: self.main_window.launch_helicon, + Qt.Key.Key_Enter: self.main_window.launch_helicon, + + # Stack Management + Qt.Key.Key_C: self.main_window.clear_all_stacks, + } + + def handle_key_press(self, event): + """Handles a key press event from the main window.""" + log.info(f"Key pressed: {event.key()}") + action = self.key_map.get(event.key()) + if action: + action() + return True + return False diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py new file mode 100644 index 0000000..3ab2888 --- /dev/null +++ b/faststack/faststack/ui/provider.py @@ -0,0 +1,106 @@ +"""QML Image Provider and application state bridge.""" + +import logging +from typing import Optional + +import numpy as np +from PySide6.QtCore import QObject, Signal, Property, QUrl, Slot, Qt +from PySide6.QtGui import QImage +from PySide6.QtQuick import QQuickImageProvider + +from faststack.models import DecodedImage + +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 + + # The ID is expected to be the image index + try: + image_index_str = id.split('/')[0] + index = int(image_index_str) + image_data = self.app_controller.get_decoded_image(index) + + if image_data: + # Zero-copy QImage from numpy buffer + qimg = QImage( + image_data.buffer, + image_data.width, + image_data.height, + image_data.bytes_per_line, + QImage.Format.Format_RGB888 + ) + # Keep a reference to the original buffer to prevent garbage collection + 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() + + def __init__(self, app_controller): + super().__init__() + self.app_controller = app_controller + + @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): + # The source is the provider ID, which we tie to the index and a generation counter + # to force QML to request a new image even if the index is the same. + return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" + + # --- Metadata Properties --- + @Property(str, notify=metadataChanged) + def currentFilename(self): + return self.app_controller.get_current_metadata().get("filename", "") + + @Property(bool, notify=metadataChanged) + def isFlagged(self): + return self.app_controller.get_current_metadata().get("flag", False) + + @Property(bool, notify=metadataChanged) + def isRejected(self): + return self.app_controller.get_current_metadata().get("reject", False) + + @Property(str, notify=metadataChanged) + def stackInfoText(self): + return self.app_controller.get_current_metadata().get("stack_info_text", "") + + # --- 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 toggleFlag(self): + self.app_controller.toggle_current_flag() diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml new file mode 100644 index 0000000..9d5c9d3 --- /dev/null +++ b/faststack/pyproject.toml @@ -0,0 +1,35 @@ + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "faststack" +version = "0.1.0" +authors = [ + { name="Gemini", email="gemini@google.com" }, +] +description = "Ultra-fast JPG Viewer for RAW 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", + "typer>=0.12,<1.0", + "Pillow>=10.0,<11.0", + "pytest>=8.0,<9.0", +] + +[project.scripts] +faststack = "faststack.app:main" + +[tool.setuptools] +packages = ["faststack"] diff --git a/faststack/requirements.txt b/faststack/requirements.txt new file mode 100644 index 0000000..f67333a --- /dev/null +++ b/faststack/requirements.txt @@ -0,0 +1,9 @@ +PySide6==6.10.* +PyTurboJPEG==1.* +numpy==2.* +cachetools==5.* +watchdog==4.* +typer==0.12.* +Pillow==10.* # fallback decode; keep it +pyinstaller==6.* +pytest==8.* diff --git a/faststack/test.py b/faststack/test.py new file mode 100644 index 0000000..7fa5174 --- /dev/null +++ b/faststack/test.py @@ -0,0 +1,5 @@ +import os +from turbojpeg import TurboJPEG +print("TURBOJPEG =", os.environ.get("TURBOJPEG")) +jpeg = TurboJPEG(lib_path=os.environ.get("TURBOJPEG")) +print("TurboJPEG loaded OK") \ No newline at end of file