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