From 6119d927b40b22c4ceaf5e4370b08c04de7752bd Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 2 Nov 2025 22:51:41 -0800 Subject: [PATCH] =?UTF-8?q?Release=20v0.4=20=E2=80=94=20more=20improvement?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/ChangeLog.md | 11 +++ faststack/README.md | 2 +- faststack/faststack/app.py | 115 +++++++++++++++++++++--- faststack/faststack/imaging/jpeg.py | 19 ++++ faststack/faststack/imaging/prefetch.py | 20 +++-- faststack/faststack/logging_setup.py | 3 +- faststack/faststack/qml/Components.qml | 18 ++++ faststack/faststack/qml/Main.qml | 79 ++++++++++++++-- faststack/faststack/ui/provider.py | 32 +++++++ faststack/pyproject.toml | 2 +- 10 files changed, 269 insertions(+), 32 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 940cfef..73bd74a 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -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 diff --git a/faststack/README.md b/faststack/README.md index 54c3e89..7a65297 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -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. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index ba09e10..43601c6 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -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 @@ -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 @@ -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) @@ -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) @@ -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 + 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) @@ -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 @@ -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 @@ -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() @@ -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: diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index ab2cf6a..1bcb390 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -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 diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 33fdaa4..4e01487 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -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" @@ -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 @@ -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: @@ -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}") diff --git a/faststack/faststack/logging_setup.py b/faststack/faststack/logging_setup.py index b452e49..470959e 100644 --- a/faststack/faststack/logging_setup.py +++ b/faststack/faststack/logging_setup.py @@ -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) diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index b11219d..7a626ee 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -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 { diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 0dc6232..813075e 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Window import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 import QtQuick.Layouts 1.15 import "." @@ -8,10 +9,14 @@ ApplicationWindow { id: root width: Screen.width height: Screen.height - visibility: Window.Maximized + x: 0 + y: 0 + visibility: Window.Windowed flags: Qt.FramelessWindowHint title: "FastStack" + Material.theme: isDarkTheme ? Material.Dark : Material.Light + property bool isDarkTheme: uiState.get_theme() === 0 property color currentBackgroundColor: isDarkTheme ? "#000000" : "white" property color currentTextColor: isDarkTheme ? "white" : "black" @@ -75,6 +80,20 @@ ApplicationWindow { color: "lightgreen" visible: uiState.isStacked } + Rectangle { + visible: uiState.isPreloading + Layout.preferredWidth: 200 + height: 10 // give it some height + color: "gray" + border.color: "red" + border.width: 1 + + Rectangle { + color: "lightblue" + width: parent.width * (uiState.preloadProgress / 100) + height: parent.height + } + } Rectangle { Layout.fillWidth: true color: uiState.stackInfoText ? "orange" : "transparent" // Brighter background @@ -119,21 +138,63 @@ ApplicationWindow { - MenuBar { + MenuBar { + + + + + + + + id: menuBar - id: menuBar + + + + + + + Layout.preferredWidth: 300 // Give it some width - Layout.preferredWidth: 300 // Give it some width + + + - palette.buttonText: root.currentTextColor + background: Rectangle { - palette.button: root.currentBackgroundColor + - palette.window: root.currentBackgroundColor + color: root.currentBackgroundColor + + - palette.text: root.currentTextColor + } + + + + + + + + palette.buttonText: root.currentTextColor + + + + palette.button: root.currentBackgroundColor + + + + palette.window: root.currentBackgroundColor + + + + palette.text: root.currentTextColor + + + + @@ -187,6 +248,8 @@ ApplicationWindow { Action { text: "Show Stacks"; onTriggered: showStacksDialog.open() } + Action { text: "Preload All Images"; onTriggered: uiState.preloadAllImages() } + } Menu { diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index c8dd4ba..940a0b4 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -57,10 +57,34 @@ class UIState(QObject): currentImageSourceChanged = Signal() metadataChanged = Signal() themeChanged = Signal() + preloadingStateChanged = Signal() + preloadProgressChanged = Signal() def __init__(self, app_controller): super().__init__() self.app_controller = app_controller + self._is_preloading = False + self._preload_progress = 0 + + @Property(bool, notify=preloadingStateChanged) + def isPreloading(self): + return self._is_preloading + + @isPreloading.setter + def isPreloading(self, value): + if self._is_preloading != value: + self._is_preloading = value + self.preloadingStateChanged.emit() + + @Property(int, notify=preloadProgressChanged) + def preloadProgress(self): + return self._preload_progress + + @preloadProgress.setter + def preloadProgress(self, value): + if self._preload_progress != value: + self._preload_progress = value + self.preloadProgressChanged.emit() @Property(int, notify=currentIndexChanged) def currentIndex(self): @@ -184,3 +208,11 @@ def set_default_directory(self, path): @Slot(result=str) def open_directory_dialog(self): return self.app_controller.open_directory_dialog() + + @Slot() + def preloadAllImages(self): + self.app_controller.preload_all_images() + + @Slot(int, int) + def onDisplaySizeChanged(self, width: int, height: int): + self.app_controller.on_display_size_changed(width, height) diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 4ffeb8a..ea635e3 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "0.3" +version = "0.4" authors = [ { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ]