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
10 changes: 10 additions & 0 deletions faststack/ChangeLog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# ChangeLog

## [0.6.0] - 2025-11-03

### Fixed
- Resolved an issue where the prefetch range was not being applied correctly after changing the prefetch radius in settings.
- Corrected `decode_jpeg_thumb_rgb` to ensure that thumbnails generated by PyTurboJPEG do not exceed the `max_dim` by falling back to Pillow resizing when necessary.
- Addressed excessive metadata queries during application startup by deferring UI synchronization until after images are loaded.
- Fixed a bug where the zoom state callback was not firing, leading to low-resolution images being served when zoomed in.
- Resolved a QML error "Cannot assign to non-existent property 'scaleTransform'" by correctly placing the scale change handlers within the `Scale` transform.
- Handled the empty image files case in preloading to prevent unnecessary processing and correctly update the UI.

## [0.5.0] - 2025-11-03

### Added
Expand Down
67 changes: 55 additions & 12 deletions faststack/faststack/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,38 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine):
self.stacks: List[List[int]] = []
self.selected_raws: set[Path] = set()

self._metadata_cache = {}
self._metadata_cache_index = -1

self.resize_timer = QTimer()
self.resize_timer.setSingleShot(True)
self.resize_timer.timeout.connect(self._handle_resize)
self.pending_width = None
self.pending_height = None

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

def on_display_size_changed(self, width: int, height: int):
"""Debounces display size change events to prevent spamming resizes."""
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
return

# Debounce resize events
self.pending_width = width
self.pending_height = height
self.resize_timer.start(150) # 150ms debounce

def _handle_resize(self):
"""Actual resize handler, called after debounce period."""
log.info(f"Display size changed to: {self.pending_width}x{self.pending_height}")
self.display_width = self.pending_width
self.display_height = self.pending_height
self.display_generation += 1
self.image_cache.clear()
self.prefetcher.cancel_all() # Clear existing prefetch tasks
self.prefetcher.cancel_all()
self.prefetcher.update_prefetch(self.current_index)
self.sync_ui_state() # To refresh the image

Expand Down Expand Up @@ -119,13 +136,16 @@ def load(self):
self.stacks = self.sidecar.data.stacks # Load stacks from sidecar
self.watcher.start()
self.prefetcher.update_prefetch(self.current_index)

# Defer initial UI sync until after images are loaded
self.sync_ui_state()


def refresh_image_list(self):
"""Rescans the directory for images."""
self.image_files = find_images(self.image_dir)
self.prefetcher.set_image_files(self.image_files)
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.ui_state.imageCountChanged.emit()

def get_decoded_image(self, index: int) -> Optional[DecodedImage]:
Expand Down Expand Up @@ -161,6 +181,7 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]:
def sync_ui_state(self):
"""Forces the UI to update by emitting all state change signals."""
self.ui_refresh_generation += 1
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.ui_state.currentIndexChanged.emit()
self.ui_state.currentImageSourceChanged.emit()
self.ui_state.metadataChanged.emit()
Expand Down Expand Up @@ -189,37 +210,47 @@ def get_current_metadata(self) -> Dict:
log.debug("get_current_metadata: image_files is empty, returning {}.")
return {}

# Cache hit check
cache_key = (self.current_index, self.ui_refresh_generation)
if cache_key == self._metadata_cache_index:
return self._metadata_cache

# Compute and cache
stem = self.image_files[self.current_index].path.stem
meta = self.sidecar.get_metadata(stem)

stack_info = self._get_stack_info(self.current_index)

return {
self._metadata_cache = {
"filename": self.image_files[self.current_index].path.name,
"flag": meta.flag,
"reject": meta.reject,
"stack_info_text": stack_info,
"stacked": meta.stacked,
"stacked_date": meta.stacked_date,
"stacked_date": meta.stacked_date or "",
"stack_info_text": stack_info
}
self._metadata_cache_index = cache_key
return self._metadata_cache

def toggle_current_flag(self):
stem = self.image_files[self.current_index].path.stem
meta = self.sidecar.get_metadata(stem)
meta.flag = not meta.flag
self.sidecar.save()
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.ui_state.metadataChanged.emit()

def toggle_current_reject(self):
stem = self.image_files[self.current_index].path.stem
meta = self.sidecar.get_metadata(stem)
meta.reject = not meta.reject
self.sidecar.save()
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.ui_state.metadataChanged.emit()

def begin_new_stack(self):
self.stack_start_index = self.current_index
log.info(f"Stack start marked at index {self.stack_start_index}")
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.ui_state.metadataChanged.emit() # Update UI to show start marker

def end_current_stack(self):
Expand All @@ -233,6 +264,7 @@ def end_current_stack(self):
self.sidecar.save()
log.info(f"Defined new stack: [{start}, {end}]")
self.stack_start_index = None
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.ui_state.metadataChanged.emit()
else:
log.warning("No stack start marked. Press '[' first.")
Expand Down Expand Up @@ -305,6 +337,7 @@ def _launch_helicon_with_files(self, raw_files: List[Path]):
meta.stacked_date = today
break
self.sidecar.save()
self._metadata_cache_index = (-1, -1) # Invalidate cache

def _delete_temp_file(self, tmp_path: Path):
if tmp_path.exists():
Expand All @@ -319,6 +352,7 @@ def clear_all_stacks(self):
self.stacks = []
self.sidecar.data.stacks = self.stacks
self.sidecar.save()
self._metadata_cache_index = (-1, -1) # Invalidate cache
self.ui_state.metadataChanged.emit() # Refresh UI to show no stacks

def get_helicon_path(self):
Expand Down Expand Up @@ -352,6 +386,8 @@ def get_prefetch_radius(self):
def set_prefetch_radius(self, radius):
config.set('core', 'prefetch_radius', radius)
config.save()
self.prefetcher.prefetch_radius = radius
self.prefetcher.update_prefetch(self.current_index)

def get_theme(self):
return 0 if config.get('core', 'theme') == 'dark' else 1
Expand Down Expand Up @@ -391,9 +427,16 @@ def preload_all_images(self):

# Use existing prefetch executor (better resource utilization)
total = len(self.image_files)

if total == 0:
log.info("No images to preload.")
self.reporter.progress_updated.emit(100) # Or 0, depending on desired UX
self.reporter.finished.emit()
return

completed = 0

def _on_done(future):
def _on_done(_future):
nonlocal completed
completed += 1
progress = int((completed / total) * 100)
Expand Down
12 changes: 11 additions & 1 deletion faststack/faststack/imaging/jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,17 @@ def decode_jpeg_thumb_rgb(
# Find the best scaling factor
scaling_factor = _get_turbojpeg_scaling_factor(width, height, max_dim)

return jpeg_decoder.decode(jpeg_bytes, scaling_factor=scaling_factor, pixel_format=TJPF_RGB, flags=TJFLAG_FASTDCT)
decoded = jpeg_decoder.decode(
jpeg_bytes,
scaling_factor=scaling_factor,
pixel_format=TJPF_RGB,
flags=TJFLAG_FASTDCT,
)
if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim:
img = Image.fromarray(decoded)
img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
return np.array(img)
return decoded
except Exception as e:
log.exception(f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow.")

Expand Down
4 changes: 3 additions & 1 deletion faststack/faststack/qml/Components.qml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Item {
}
}

onScaleChanged: {
function updateZoomState() {
if (scaleTransform.xScale > 1.1 && !uiState.isZoomed) {
uiState.setZoomed(true);
} else if (scaleTransform.xScale <= 1.0 && uiState.isZoomed) {
Expand All @@ -46,6 +46,8 @@ Item {
id: scaleTransform
origin.x: mainImage.width / 2
origin.y: mainImage.height / 2
onXScaleChanged: mainImage.updateZoomState()
onYScaleChanged: mainImage.updateZoomState()
},
Translate {
id: panTransform
Expand Down
2 changes: 1 addition & 1 deletion faststack/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "faststack"
version = "0.5"
version = "0.6"
authors = [
{ name="Alan Rockefeller", email="alanrockefeller@gmail.com" },
]
Expand Down