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
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Virtual environments
# Virtual environments
.venv/
venv/

Expand All @@ -22,4 +22,4 @@ Thumbs.db
.vscode/
.idea/

prompt.md
prompt.md
2 changes: 1 addition & 1 deletion faststack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
AlanRockefeller marked this conversation as resolved.
- `[`: Begin new stack group
- `]`: End current stack group
- `Space`: Toggle Flag
Expand Down
90 changes: 71 additions & 19 deletions faststack/faststack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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]

Expand All @@ -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):
Expand All @@ -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 ---

Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment thread
AlanRockefeller marked this conversation as resolved.
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():
Expand Down
16 changes: 12 additions & 4 deletions faststack/faststack/imaging/prefetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion faststack/faststack/io/helicon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions faststack/faststack/io/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
from pathlib import Path
from typing import Optional

from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
Expand Down
1 change: 1 addition & 0 deletions faststack/faststack/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 3 additions & 32 deletions faststack/faststack/qml/Components.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
}
}
}
Loading