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