From 0d157746bde7f95d02023984283d93a26cb1c5ea Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 1 Nov 2025 11:31:16 -0700 Subject: [PATCH] =?UTF-8?q?Release=20v0.2=20=E2=80=94=20working=20Helicon?= =?UTF-8?q?=20Focus=20integration=20and=20viewer=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- faststack/README.md | 2 +- faststack/faststack/app.py | 90 ++++++++++++++---- faststack/faststack/imaging/prefetch.py | 16 +++- faststack/faststack/io/helicon.py | 7 +- faststack/faststack/io/watcher.py | 1 + faststack/faststack/models.py | 1 + faststack/faststack/qml/Components.qml | 35 +------ faststack/faststack/qml/Main.qml | 108 +++++++++++++++++++--- faststack/faststack/tests/test_sidecar.py | 4 +- faststack/faststack/ui/keystrokes.py | 3 +- faststack/faststack/ui/provider.py | 19 ++++ faststack/pyproject.toml | 4 +- faststack/test.py | 5 - 14 files changed, 215 insertions(+), 84 deletions(-) delete mode 100644 faststack/test.py diff --git a/.gitignore b/.gitignore index 687aa76..9cdc77f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Virtual environments +# Virtual environments .venv/ venv/ @@ -22,4 +22,4 @@ Thumbs.db .vscode/ .idea/ -prompt.md +prompt.md \ No newline at end of file diff --git a/faststack/README.md b/faststack/README.md index ddeff30..123f6f9 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -35,7 +35,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `J` / `Right Arrow`: Next Image - `K` / `Left Arrow`: Previous Image - `G`: Toggle Grid View -- `S`: Add/Remove current RAW to/from selection set +- `S`: Stack all of the selected stacks with Helicon Focus - `[`: Begin new stack group - `]`: End current stack group - `Space`: Toggle Flag diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index d948038..061ee31 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -7,6 +7,7 @@ import os import typer +import concurrent.futures from PySide6.QtCore import QUrl, QTimer, QObject, QEvent from PySide6.QtGui import QGuiApplication from PySide6.QtQml import QQmlApplicationEngine @@ -55,17 +56,22 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): # -- Stacking State -- self.stack_start_index: Optional[int] = None self.stacks: List[List[int]] = [] + self.selected_raws: set[Path] = set() 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 + handled = self.keybinder.handle_key_press(event) + if handled: + 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 + if not self.image_files: + self.current_index = 0 + else: + self.current_index = max(0, min(self.sidecar.data.last_index, len(self.image_files) - 1)) self.stacks = self.sidecar.data.stacks # Load stacks from sidecar self.watcher.start() self.prefetcher.update_prefetch(self.current_index) @@ -79,6 +85,10 @@ def refresh_image_list(self): def get_decoded_image(self, index: int) -> Optional[DecodedImage]: """Retrieves a decoded image, from cache or by decoding.""" + if not self.image_files: # Handle empty image list + log.warning("get_decoded_image called with empty image_files.") + return None + if index in self.image_cache: return self.image_cache[index] @@ -87,10 +97,19 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: 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] + 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] + 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 def sync_ui_state(self): @@ -99,6 +118,8 @@ def sync_ui_state(self): self.ui_state.currentIndexChanged.emit() self.ui_state.currentImageSourceChanged.emit() self.ui_state.metadataChanged.emit() + log.debug(f"UI State Synced: Index={self.ui_state.currentIndex}, Count={self.ui_state.imageCount}") + log.debug(f"Metadata Synced: Filename={self.ui_state.currentFilename}, Flagged={self.ui_state.isFlagged}, Rejected={self.ui_state.isRejected}, StackInfo='{self.ui_state.stackInfoText}'") # --- Actions --- @@ -119,6 +140,7 @@ def toggle_grid_view(self): def get_current_metadata(self) -> Dict: if not self.image_files: + log.debug("get_current_metadata: image_files is empty, returning {}.") return {} stem = self.image_files[self.current_index].path.stem @@ -167,25 +189,55 @@ def end_current_stack(self): 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.") + def toggle_selection(self): + """Toggles the selection status of the current image's RAW file.""" + if not self.image_files: 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) + image_file = self.image_files[self.current_index] + if image_file.raw_pair: + if image_file.raw_pair in self.selected_raws: + self.selected_raws.remove(image_file.raw_pair) + log.info(f"Removed {image_file.raw_pair.name} from selection.") + else: + self.selected_raws.add(image_file.raw_pair) + log.info(f"Added {image_file.raw_pair.name} to selection.") - 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) + # In a real app, we'd update a selection indicator in the UI. + # For now, we just log and can use it for batch operations. + self.sync_ui_state() # This will trigger a UI refresh + + + def launch_helicon(self): + """Launches Helicon Focus with selected RAWs or all RAWs in defined stacks.""" + raw_files_to_process = [] + if self.selected_raws: + log.info(f"Launching Helicon with {len(self.selected_raws)} selected RAW files.") + raw_files_to_process.extend(sorted(list(self.selected_raws))) # Sort for consistent order + elif self.stacks: + log.info("No selection, launching Helicon with all defined stacks.") + 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: + raw_files_to_process.append(self.image_files[idx].raw_pair) + else: + log.warning("No selection or stacks defined to launch Helicon Focus.") + return + + if raw_files_to_process: + log.info(f"Launching Helicon Focus with {len(raw_files_to_process)} RAW files.") + # Remove duplicates that might arise from stacks + unique_raw_files = sorted(list(set(raw_files_to_process))) + success, tmp_path = launch_helicon_focus(unique_raw_files) if success and tmp_path: # Schedule delayed deletion of the temporary file QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) + + # Clear selection after launching + self.selected_raws.clear() + self.sync_ui_state() else: - log.warning("No valid RAW files found in any defined stack.") + log.warning("No valid RAW files found to launch Helicon.") def _delete_temp_file(self, tmp_path: Path): if tmp_path.exists(): diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 5304a6b..33fdaa4 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -23,8 +23,9 @@ def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_r self.generation = 0 def set_image_files(self, image_files: List[ImageFile]): - self.image_files = image_files - self.cancel_all() + if self.image_files != image_files: + self.image_files = image_files + self.cancel_all() def update_prefetch(self, current_index: int): """Updates the prefetching queue based on the current image index.""" @@ -61,8 +62,10 @@ def submit_task(self, index: int, generation: int) -> Optional[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})") + local_generation = self.generation # Capture current generation for this worker + + if generation != local_generation: + log.debug(f"Skipping stale task for index {index} (gen {generation} != {local_generation})") return None try: @@ -71,6 +74,11 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int) buffer = decode_jpeg_rgb(jpeg_bytes) if buffer is not None: + # Re-check generation before caching to prevent race conditions + if self.generation != local_generation: + log.debug(f"Generation changed for index {index} before caching. Skipping cache_put.") + return None + h, w, _ = buffer.shape # In a real Qt app, we would create the QImage here in the main thread # For now, we'll just store the raw buffer data. diff --git a/faststack/faststack/io/helicon.py b/faststack/faststack/io/helicon.py index f5dcf0d..5784952 100644 --- a/faststack/faststack/io/helicon.py +++ b/faststack/faststack/io/helicon.py @@ -20,7 +20,12 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: True if the process was launched successfully, False otherwise. """ helicon_exe = config.get("helicon", "exe") - if not Path(helicon_exe).is_file(): + if not helicon_exe or not isinstance(helicon_exe, str): + log.error("Helicon Focus executable path not configured or invalid.") + return False, None + + helicon_path = Path(helicon_exe) + if not helicon_path.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 diff --git a/faststack/faststack/io/watcher.py b/faststack/faststack/io/watcher.py index 623061e..1062138 100644 --- a/faststack/faststack/io/watcher.py +++ b/faststack/faststack/io/watcher.py @@ -2,6 +2,7 @@ import logging from pathlib import Path +from typing import Optional from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer diff --git a/faststack/faststack/models.py b/faststack/faststack/models.py index 62fdede..968f380 100644 --- a/faststack/faststack/models.py +++ b/faststack/faststack/models.py @@ -16,6 +16,7 @@ class EntryMetadata: """Sidecar metadata for a single image entry.""" flag: bool = False reject: bool = False + stack_id: Optional[int] = None @dataclasses.dataclass diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 75f1041..7786218 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -10,8 +10,9 @@ Item { // The main image display Image { id: mainImage - anchors.fill: parent - source: uiState && uiState.currentImageSource ? uiState.currentImageSource : "" + width: parent.width + height: parent.height + source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" fillMode: Image.PreserveAspectFit cache: false // We do our own caching in Python @@ -49,35 +50,5 @@ Item { } } - // 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 index 37c2753..f4c8fd7 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -4,11 +4,28 @@ import QtQuick.Controls 2.15 ApplicationWindow { id: root - width: 1280 - height: 720 - visible: true + width: Screen.width + height: Screen.height + visibility: Window.FullScreen title: "FastStack - " + (uiState && uiState.currentFilename ? uiState.currentFilename : "No folder loaded") + property color currentBackgroundColor: "#212121" // Default dark background + property color currentTextColor: "white" // Default light text + + background: Rectangle { color: root.currentBackgroundColor } + + function toggleTheme() { + if (root.currentBackgroundColor === "#212121") { // Currently dark + root.currentBackgroundColor = "white" + root.currentTextColor = "black" + } else { // Currently light + root.currentBackgroundColor = "#212121" + root.currentTextColor = "white" + } + // Update colors of specific elements if needed, e.g., footer labels + // For now, rely on default text colors or explicit bindings + } + // Expose the Python UIState object to QML // This is set from Python via setContextProperty("uiState", ...) @@ -23,32 +40,46 @@ ApplicationWindow { // Status bar footer: Rectangle { + id: footerRect + implicitHeight: footerRow.implicitHeight + 10 // Add some padding + anchors.left: parent.left + anchors.right: parent.right + color: "#80000000" // Semi-transparent black + Row { + id: footerRow spacing: 10 + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 10 Label { - text: `Image: ${uiState && uiState.currentIndex !== null ? uiState.currentIndex + 1 : 'N/A'} / ${uiState && uiState.imageCount !== null ? uiState.imageCount : 'N/A'}` + text: `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` + color: root.currentTextColor } Label { - text: ` | File: ${uiState && uiState.currentFilename ? uiState.currentFilename : 'N/A'}` + text: ` | File: ${uiState.currentFilename || 'N/A'}` + color: root.currentTextColor } Label { - text: ` | Flag: ${uiState && uiState.isFlagged ? uiState.isFlagged : 'N/A'}` - color: uiState && uiState.isFlagged ? "lightgreen" : "white" + text: ` | Flag: ${uiState.isFlagged}` + color: uiState.isFlagged ? "lightgreen" : root.currentTextColor } Label { - text: ` | Rejected: ${uiState && uiState.isRejected ? uiState.isRejected : 'N/A'}` - color: uiState && uiState.isRejected ? "red" : "white" + text: ` | Rejected: ${uiState.isRejected}` + color: uiState.isRejected ? "red" : root.currentTextColor } Rectangle { - color: uiState && uiState.stackInfoText ? "#404000" : "transparent" // Dark yellow background + color: uiState.stackInfoText ? "orange" : "transparent" // Brighter background radius: 3 implicitWidth: stackInfoLabel.implicitWidth + 10 implicitHeight: stackInfoLabel.implicitHeight + 5 Label { id: stackInfoLabel anchors.centerIn: parent - text: `Stack: ${uiState && uiState.stackInfoText ? uiState.stackInfoText : 'N/A'}` - color: uiState && uiState.stackInfoText ? "yellow" : "white" + text: `Stack: ${uiState.stackInfoText || 'N/A'}` + color: "black" // Black text for contrast on orange + font.bold: true + font.pixelSize: 16 onTextChanged: function() { console.log("Stack info text changed:", stackInfoLabel.text) } } } @@ -74,10 +105,32 @@ ApplicationWindow { onTriggered: Qt.quit() } } + Menu { + title: "&View" + Action { + text: "Toggle Light/Dark Mode" + onTriggered: root.toggleTheme() + } + } + Menu { + title: "&Actions" + Action { + text: "Run Stacks" + onTriggered: uiState.launch_helicon() + } + Action { + text: "Clear Stacks" + onTriggered: uiState.clear_all_stacks() + } + Action { + text: "Show Stacks" + onTriggered: showStacksDialog.open() + } + } Menu { title: "&Help" Action { - text: "&About" + text: "&Key Bindings" onTriggered: aboutDialog.open() } } @@ -85,11 +138,15 @@ ApplicationWindow { Dialog { id: aboutDialog - title: "About FastStack" + title: "Key Bindings" standardButtons: Dialog.Ok modal: true width: 400 - height: 300 + height: 400 + + background: Rectangle { + color: root.currentBackgroundColor + } contentItem: Text { text: "FastStack Keyboard and Mouse Commands

" + @@ -111,6 +168,27 @@ ApplicationWindow { "  Enter: Launch Helicon Focus" padding: 10 wrapMode: Text.WordWrap + color: root.currentTextColor + } + } + + Dialog { + id: showStacksDialog + title: "Stack Information" + standardButtons: Dialog.Ok + modal: true + width: 400 + height: 300 + + background: Rectangle { + color: root.currentBackgroundColor + } + + contentItem: Text { + text: uiState.get_stack_summary // Access property directly + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor } } } diff --git a/faststack/faststack/tests/test_sidecar.py b/faststack/faststack/tests/test_sidecar.py index ece199b..99f0ec3 100644 --- a/faststack/faststack/tests/test_sidecar.py +++ b/faststack/faststack/tests/test_sidecar.py @@ -6,7 +6,7 @@ import pytest from faststack.io.sidecar import SidecarManager -from faststack.types import EntryMetadata +from faststack.models import EntryMetadata @pytest.fixture def mock_sidecar_dir(tmp_path: Path): @@ -21,7 +21,7 @@ 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.version == 2 assert sm.data.last_index == 0 assert not sm.data.entries diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 5c6e6a0..812a764 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -27,8 +27,9 @@ def __init__(self, main_window): Qt.Key.Key_BracketRight: self.main_window.end_current_stack, # Actions - Qt.Key.Key_S: self.main_window.launch_helicon, + Qt.Key.Key_S: self.main_window.toggle_selection, Qt.Key.Key_Enter: self.main_window.launch_helicon, + Qt.Key.Key_Return: self.main_window.launch_helicon, # Stack Management Qt.Key.Key_C: self.main_window.clear_all_stacks, diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 3ab2888..4318586 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -92,6 +92,17 @@ def isRejected(self): def stackInfoText(self): return self.app_controller.get_current_metadata().get("stack_info_text", "") + @Property(str, notify=metadataChanged) + def get_stack_summary(self): + if not self.app_controller.stacks: + return "No stacks defined." + + summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" + for i, (start, end) in enumerate(self.app_controller.stacks): + count = end - start + 1 + summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" + return summary + # --- Slots for QML to call --- @Slot() def nextImage(self): @@ -104,3 +115,11 @@ def prevImage(self): @Slot() def toggleFlag(self): self.app_controller.toggle_current_flag() + + @Slot() + def launch_helicon(self): + self.app_controller.launch_helicon() + + @Slot() + def clear_all_stacks(self): + self.app_controller.clear_all_stacks() diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 9d5c9d3..27efac1 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,9 +5,9 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "0.1.0" +version = "0.2" authors = [ - { name="Gemini", email="gemini@google.com" }, + { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ] description = "Ultra-fast JPG Viewer for RAW Stacking Selection" readme = "README.md" diff --git a/faststack/test.py b/faststack/test.py deleted file mode 100644 index 7fa5174..0000000 --- a/faststack/test.py +++ /dev/null @@ -1,5 +0,0 @@ -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