Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions faststack/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# ChangeLog

## Version 0.4

### New Features
- **Two-tier caching system:** Implemented a two-tier caching system to prefetch display-sized images, significantly improving performance and reducing GPU memory usage.
- **"Preload All Images" feature:** Added a new menu option under "Actions" to preload all images in the current directory into the cache, ensuring quick access even for unviewed images.
- **Progress bar for preloading:** Introduced a visual progress bar in the footer to display the status of the "Preload All Images" operation.

### Changes
- **Theming improvements:** Adjusted the Material theme to ensure the menubar background is black in dark mode, providing a more consistent user experience.
- **Window behavior:** Changed the application window to a borderless fullscreen mode, allowing for normal Alt-Tab behavior and better integration with the operating system.

## Version 0.3

### New Features
Expand Down
2 changes: 1 addition & 1 deletion faststack/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# FastStack

# Version 0.3 - November 1, 2025
# Version 0.4 - November 2, 2025
# By Alan Rockefeller

Ultra-fast, caching JPG viewer designed for culling and selecting RAW files for focus stacking.
Expand Down
115 changes: 102 additions & 13 deletions faststack/faststack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import os
import typer
import concurrent.futures
from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
from PySide6.QtCore import QUrl, QTimer, QObject, QEvent, Signal
from PySide6.QtWidgets import QApplication, QFileDialog
from PySide6.QtQml import QQmlApplicationEngine

Expand All @@ -25,9 +25,15 @@
from faststack.ui.provider import ImageProvider, UIState
from faststack.ui.keystrokes import Keybinder

import threading

log = logging.getLogger(__name__)

class AppController(QObject):
class ProgressReporter(QObject):
progress_updated = Signal(int)
finished = Signal()

def __init__(self, image_dir: Path, engine: QQmlApplicationEngine):
super().__init__()
self.image_dir = image_dir
Expand All @@ -37,6 +43,10 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine):
self.main_window: Optional[QObject] = None
self.engine = engine

self.display_width = 0
self.display_height = 0
self.display_generation = 0

# -- Backend Components --
self.watcher = Watcher(self.image_dir, self.refresh_image_list)
self.sidecar = SidecarManager(self.image_dir, self.watcher)
Expand All @@ -48,8 +58,10 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine):
self.prefetcher = Prefetcher(
image_files=self.image_files,
cache_put=self.image_cache.__setitem__,
prefetch_radius=config.getint('core', 'prefetch_radius', 4)
prefetch_radius=config.getint('core', 'prefetch_radius', 4),
get_display_info=self.get_display_info
)
self.preload_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="PreloadAll")

# -- UI State --
self.ui_state = UIState(self)
Expand All @@ -60,6 +72,24 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine):
self.stacks: List[List[int]] = []
self.selected_raws: set[Path] = set()

def get_display_info(self):
return self.display_width, self.display_height, self.display_generation

def get_display_info(self):
return self.display_width, self.display_height, self.display_generation

def on_display_size_changed(self, width: int, height: int):
if self.display_width == width and self.display_height == height:
return # No change

log.info(f"Display size changed to: {width}x{height}")
self.display_width = width
self.display_height = height
self.display_generation += 1
self.image_cache.clear()
self.prefetcher.update_prefetch(self.current_index)
self.sync_ui_state() # To refresh the image
Comment on lines +89 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear stale futures before re-prefetching after display changes.

After self.image_cache.clear() the Prefetcher.futures dict still contains completed entries from the previous display generation. Because update_prefetch() only submits when an index is missing from self.futures, none of those images get re-decoded for the new display size, so navigation becomes synchronous (and slow) until each image is forced. Call self.prefetcher.cancel_all() (or otherwise clear/cancel the futures) before invoking update_prefetch() so the new display size is prefetched immediately.

🤖 Prompt for AI Agents
In faststack/faststack/app.py around lines 89 to 91, after clearing
self.image_cache you must also clear or cancel existing Prefetcher futures so
completed/stale futures from the previous display don't prevent re-decoding;
call self.prefetcher.cancel_all() (or otherwise clear self.prefetcher.futures)
before self.prefetcher.update_prefetch(self.current_index) so the prefetcher
will submit new decode tasks for the current display size, then call
self.sync_ui_state() as before.


def eventFilter(self, watched: QObject, event: QEvent) -> bool:
if watched == self.main_window and event.type() == QEvent.Type.KeyPress:
handled = self.keybinder.handle_key_press(event)
Expand Down Expand Up @@ -92,26 +122,27 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]:
log.warning("get_decoded_image called with empty image_files.")
return None

if index in self.image_cache:
return self.image_cache[index]
_, _, display_gen = self.get_display_info()
cache_key = f"{index}_{display_gen}"

if cache_key in self.image_cache:
return self.image_cache[cache_key]

# 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.")
log.warning(f"Cache miss for index {index} (gen: {display_gen}). Forcing synchronous load.")
future = self.prefetcher.submit_task(index, self.prefetcher.generation)
if future:
try:
# Wait for the result and then retrieve from cache
decoded_index = future.result()
if decoded_index is not None and decoded_index in self.image_cache:
return self.image_cache[decoded_index]
result = future.result()
if result:
decoded_index, decoded_display_gen = result
cache_key = f"{decoded_index}_{decoded_display_gen}"
if cache_key in self.image_cache:
return self.image_cache[cache_key]
except concurrent.futures.CancelledError:
log.warning(f"Prefetch task for index {index} was cancelled. Attempting synchronous load.")
# Fallback to synchronous load if task was cancelled
# This requires direct access to the decoding logic, which Prefetcher encapsulates.
# For now, we'll re-submit and wait, which might still hit a cancelled error if rapid.
# A more robust solution would be to have a direct synchronous decode method.
# For simplicity, let's just return None for now if cancelled, and rely on UI to re-request.
return None
return None

Expand Down Expand Up @@ -327,6 +358,61 @@ def open_directory_dialog(self):
return dialog.selectedFiles()[0]
return ""

def preload_all_images(self):
if self.ui_state.isPreloading:
log.info("Preloading is already in progress.")
return

log.info("Starting to preload all images.")
self.ui_state.isPreloading = True
self.ui_state.preloadProgress = 0

self.reporter = self.ProgressReporter()
self.reporter.progress_updated.connect(self._update_preload_progress)
self.reporter.finished.connect(self._finish_preloading)

def _preload_and_report_progress():
log.info(f"Preloading images.")

futures = []
for i in range(len(self.image_files)):
future = self.prefetcher.submit_task(i, self.prefetcher.generation)
if future:
futures.append(future)

num_futures = len(futures)
if num_futures == 0:
self.reporter.finished.emit()
return

log.info(f"Submitted {num_futures} preloading tasks.")
completed_count = 0
lock = threading.Lock()

def _on_future_done(future):
nonlocal completed_count
with lock:
completed_count += 1
progress = int((completed_count / num_futures) * 100)
self.reporter.progress_updated.emit(progress)

if completed_count == num_futures:
self.reporter.finished.emit()

for future in futures:
future.add_done_callback(_on_future_done)

self.preload_executor.submit(_preload_and_report_progress)

def _update_preload_progress(self, progress: int):
log.debug(f"Updating preload progress in UI: {progress}%")
self.ui_state.preloadProgress = progress

def _finish_preloading(self):
self.ui_state.isPreloading = False
self.ui_state.preloadProgress = 0
log.info("Finished preloading all images.")

def shutdown(self):
log.info("Application shutting down.")
# Clear QML context property to prevent TypeErrors during shutdown
Expand All @@ -336,6 +422,7 @@ def shutdown(self):

self.watcher.stop()
self.prefetcher.shutdown()
self.preload_executor.shutdown(wait=False)
self.sidecar.set_last_index(self.current_index)
self.sidecar.save()

Expand All @@ -357,6 +444,8 @@ def main(image_dir: Optional[Path] = typer.Argument(None, help="Directory of ima
setup_logging()
log.info("Starting FastStack")

os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material"

app = QApplication(sys.argv) # Moved here

if image_dir is None:
Expand Down
19 changes: 19 additions & 0 deletions faststack/faststack/imaging/jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,22 @@ def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Opti
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


def decode_jpeg_resized(
jpeg_bytes: bytes, width: int, height: int
) -> Optional[np.ndarray]:
"""Decodes and resizes a JPEG to fit within the given dimensions."""
if width == 0 or height == 0:
# Fallback to full decode if size is not specified
return decode_jpeg_rgb(jpeg_bytes)

try:
from io import BytesIO

img = Image.open(BytesIO(jpeg_bytes))
img.thumbnail((width, height), Image.Resampling.LANCZOS) # High quality downsampling
return np.array(img.convert("RGB"))
except Exception as e:
log.error(f"Pillow failed to decode and resize image: {e}")
return None
20 changes: 12 additions & 8 deletions faststack/faststack/imaging/prefetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@
from typing import List, Dict, Optional, Callable

from faststack.models import ImageFile, DecodedImage
from faststack.imaging.jpeg import decode_jpeg_rgb
from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized

log = logging.getLogger(__name__)

class Prefetcher:
def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int):
def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable):
self.image_files = image_files
self.cache_put = cache_put
self.prefetch_radius = prefetch_radius
self.get_display_info = get_display_info
self.executor = ThreadPoolExecutor(
max_workers=min(4, os.cpu_count() or 1),
thread_name_prefix="Prefetcher"
Expand Down Expand Up @@ -55,12 +56,14 @@ def submit_task(self, index: int, generation: int) -> Optional[Future]:
return self.futures[index] # Already submitted

image_file = self.image_files[index]
future = self.executor.submit(self._decode_and_cache, image_file, index, generation)
display_width, display_height, display_generation = self.get_display_info()

future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation)
self.futures[index] = future
log.debug(f"Submitted prefetch task for index {index}")
return future

def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int) -> Optional[int]:
def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[int, int]]:
"""The actual work done by the thread pool."""
local_generation = self.generation # Capture current generation for this worker

Expand All @@ -72,7 +75,7 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int)
with open(image_file.path, "rb") as f:
jpeg_bytes = f.read()

buffer = decode_jpeg_rgb(jpeg_bytes)
buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height)
if buffer is not None:
# Re-check generation before caching to prevent race conditions
if self.generation != local_generation:
Expand All @@ -89,9 +92,10 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int)
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
cache_key = f"{index}_{display_generation}"
self.cache_put(cache_key, decoded_image)
log.debug(f"Successfully decoded and cached image at index {index} for display gen {display_generation}")
return index, display_generation
except Exception as e:
log.error(f"Error decoding image {image_file.path} at index {index}: {e}")

Expand Down
3 changes: 2 additions & 1 deletion faststack/faststack/logging_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ def setup_logging():
handler.setFormatter(formatter)

root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.setLevel(logging.DEBUG)
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)
logging.getLogger("PIL").setLevel(logging.INFO)
18 changes: 18 additions & 0 deletions faststack/faststack/qml/Components.qml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ Item {
fillMode: Image.PreserveAspectFit
cache: false // We do our own caching in Python

Component.onCompleted: {
if (width > 0 && height > 0) {
uiState.onDisplaySizeChanged(width, height)
}
}

onWidthChanged: {
if (width > 0 && height > 0) {
uiState.onDisplaySizeChanged(width, height)
}
}

onHeightChanged: {
if (width > 0 && height > 0) {
uiState.onDisplaySizeChanged(width, height)
}
}

// Zoom and Pan logic would go here
// For example, using PinchArea or MouseArea
MouseArea {
Expand Down
Loading