diff --git a/.gitignore b/.gitignore index f4267af..d912df8 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ test_image* green.txt OpenFocus/ +faststack-fix/ diff --git a/ChangeLog.md b/ChangeLog.md index 4f15455..6dd4fe4 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,14 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. +## 1.6.3 (2026-04-16) + +- Reworked quick auto-adjust and crop into one shared live edit session for the current image instead of saving on every keypress. +- `l` now runs quick auto-levels, `L` runs auto white balance plus auto-levels, `A` runs quick auto white balance, and `-` / `=` keep pushing highlights or shadows in 7-point steps inside that live session. +- Crop and editor edits now keep accumulating in memory on the current image instead of forcing an immediate save or backup churn. +- The live session is persisted once when you navigate away, start a drag, explicitly save, or quit, so preview stays responsive while drag-out and navigation still get the latest pixels. +- Updated README/help text and kept the version at 1.6.3. + ## 1.6.2 (2026-03-28) - Added a reusable soft-mask subsystem for local adjustments (mask model, mask engine, masked operations). diff --git a/README.md b/README.md index e4ebc12..4ee1ba1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 1.6.2 - March 28, 2026 +# Version 1.6.3 - April 16, 2026 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. @@ -17,7 +17,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. - **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) - **Background Darkening:** Mask-based background darkening tool (K key) with smart edge detection, subject protection, and multiple modes. Paint rough background hints and the tool refines them into natural-looking dark backgrounds. -- **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance, load the raw into Photoshop with the P key. +- **Quick Auto Adjust:** Press `l` for quick auto-levels, `L` for auto white balance + auto-levels together, `A` for auto white balance, `-` to keep darkening the highlight/white side in 7-point steps, and `=` to deepen the shadow side in 7-point steps. These update the live in-memory edit session immediately and save once when you navigate away, start a drag, or explicitly save. - **Photoshop / Gimp Integration:** Edit current image in Photoshop or Gimp (P key) - always uses RAW files when available. - **Clipboard Support:** Copy image path to clipboard (Ctrl+C) - **Image Filtering:** Filter images by filename @@ -135,12 +135,15 @@ If you do nothing, FastStack will still run, but JPEG decoding and thumbnail gen - `Ctrl+S`: Toggle stacked flag - `Enter`: Launch Helicon Focus with selected RAWs - `P`: Edit in Photoshop or Gimp (uses RAW file when available) -- `O` (or Right-Click): Toggle crop mode (Enter to execute, Esc to cancel) +- `O` (or Right-Click): Toggle crop mode (Enter to apply crop to the live session, Esc to cancel) - `Delete` / `Backspace`: Move image to recycle bin -- `Ctrl+Z`: Undo last action (delete, auto white balance, or crop) -- `A`: Quick auto white balance (saves automatically) +- `Ctrl+Z`: Undo last saved action (delete or saved edit) +- `A`: Quick auto white balance (live session; saved on navigation, drag, or Ctrl+S) - `Ctrl+Shift+B`: Quick auto white balance (alternate) -- `L`: Quick auto levels (saves automatically) +- `l`: Quick auto levels (live session; saved on navigation, drag, or Ctrl+S) +- `L`: Quick auto white balance + auto levels (live session; saved on navigation, drag, or Ctrl+S) +- `-`: Darken the current auto-adjust highlights/whites by 7 points in the live session +- `=`: Deepen the current auto-adjust shadows/background by 7 points in the live session - `E`: Toggle Image Editor - `Esc`: Close active dialog, editor, cancel crop, or exit fullscreen - `H`: Toggle histogram window diff --git a/faststack/app.py b/faststack/app.py index e57bbd0..601a5eb 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -16,6 +16,7 @@ import uuid import functools from collections import deque +from dataclasses import dataclass from itertools import pairwise # Must set before importing PySide6 @@ -109,6 +110,8 @@ # LABEL: below this the direction word becomes "neutral" in the status message. _AWB_NOOP_EPS = 0.005 _AWB_LABEL_EPS = 0.002 +_AUTO_ADJUST_HIGHLIGHT_STEP = 0.07 +_AUTO_ADJUST_BLACK_STEP = 0.07 def _awb_direction(value: float, pos_label: str, neg_label: str) -> str: @@ -118,6 +121,26 @@ def _awb_direction(value: float, pos_label: str, neg_label: str) -> str: return pos_label if value > 0 else neg_label +@dataclass +class ActiveAutoAdjustState: + active_path_key: str + session_id: Optional[str] + base_blacks: float + base_whites: float + p_low: float + p_high: float + extra_highlight_steps: int = 0 + extra_black_steps: int = 0 + + +@dataclass +class LiveEditSessionState: + active_path_key: str + session_id: Optional[str] + persisted_revision: int + submitted_revision: int + + def make_hdrop(paths): """ Build a real CF_HDROP (DROPFILES) payload for Windows drag-and-drop. @@ -248,12 +271,21 @@ def __init__( self._last_auto_levels_msg: str = ( "" # Detail message from last auto_levels() call ) + self._active_auto_adjust_state: Optional[ActiveAutoAdjustState] = None + self._auto_adjust_save_pending_action: Optional[str] = None + self._auto_adjust_save_in_progress: bool = False + self._live_edit_session_state: Optional[LiveEditSessionState] = None + # target_path -> save request awaiting retry. Set when a background save + # for a session the user has navigated away from fails permanently; + # flushed synchronously on shutdown so unsaved edits are not lost. + self._pending_save_recovery: Dict[str, dict] = {} + self._latest_save_tokens: Dict[str, Any] = {} + self._last_save_prepare_error: Optional[str] = None + self._shutdown_flush_prepared = False # Deferred-init state: set to safe defaults, populated later by their methods - self._saves_in_flight: set = ( - set() - ) # canonical target paths currently being saved - self._saving_keys: set = set() # keys of images with active saves + self._saves_in_flight: Dict[str, int] = {} + self._saving_keys: Dict[str, int] = {} self._batch_indices_cache: set = set() self._batch_indices_cache_key: Optional[tuple] = None self.recycle_bin_dir: Optional[Path] = None @@ -497,6 +529,16 @@ def __init__( self._emit_debounced_metadata_signals ) + # Debounce timer for auto-adjust saves triggered by rapid '-' / '=' keys. + # Preview updates immediately; the save coalesces into one disk write + # per burst so repeated keypresses don't stall the UI. + self._auto_adjust_save_timer = QTimer(self) + self._auto_adjust_save_timer.setSingleShot(True) + self._auto_adjust_save_timer.setInterval(200) + self._auto_adjust_save_timer.timeout.connect( + self._fire_auto_adjust_save_debounce + ) + # Debounce timer for EXIF reads — only fires after user stops scrolling self._exif_debounce_timer = QTimer(self) self._exif_debounce_timer.setSingleShot(True) @@ -535,27 +577,47 @@ def _on_editor_open_changed(self, is_open: bool): timeout=8000, ) else: + keep_preview = False # Cleanup large memory buffers when editor closes if self.image_editor: + session_info = self._get_current_live_edit_session_info() # If a save is active for this session, preserve the memory # so the user can re-open/retry if the background task fails. - current_key = ( - self._key(self.image_editor.current_filepath) - if self.image_editor.current_filepath - else None + current_key = None + if 0 <= self.current_index < len(self.image_files): + try: + current_key = self._key( + self.image_files[self.current_index].path + ) + except (OSError, TypeError, ValueError): + current_key = None + if current_key is None and session_info is not None: + current_key = session_info[0] + elif current_key is None and self.image_editor.current_filepath: + try: + current_key = self._key(self.image_editor.current_filepath) + except (OSError, TypeError, ValueError): + current_key = None + + save_in_progress = bool( + current_key and current_key in self._saving_keys + ) + keep_preview = ( + save_in_progress or self._is_current_live_edit_session_dirty() ) - if current_key and current_key in self._saving_keys: + if save_in_progress: log.debug( "Editor closed but save in progress for %s; keeping session memory", current_key, ) + elif keep_preview: + log.debug("Editor closed with unsaved live edits; keeping preview") else: - log.debug("Editor closed, clearing editor memory buffers") - self.image_editor.clear() + log.debug("Editor closed, preserving live editor session") - # Also clear the cached preview rendering - with self._preview_lock: - self._last_rendered_preview = None + if not keep_preview: + with self._preview_lock: + self._last_rendered_preview = None def is_valid_working_tif(self, path: Path) -> bool: """Checks if a working TIFF path is valid for editing.""" @@ -1727,6 +1789,16 @@ def sync_ui_state(self): self.ui_refresh_generation += 1 self._metadata_cache_index = (-1, -1) # Invalidate cache + # Keep the editor preview reachable at the new generation so callers + # that bump ui_refresh_generation for non-image reasons (batch toggle, + # metadata refresh, etc.) don't cause the provider to drop a valid + # edited preview in favour of the stale decode cache. + if ( + self._last_rendered_preview is not None + and self._last_rendered_preview_index == self.current_index + ): + self._last_rendered_preview_gen = self.ui_refresh_generation + # Essential signals - emit immediately for responsive image display self.ui_state.currentIndexChanged.emit() self.ui_state.currentImageSourceChanged.emit() @@ -1783,297 +1855,1116 @@ def get_variant_save_hint(self) -> str: return "Saving will restore to main image (backup will be created)." return "" - # --- Image Editor Integration --- - - def _get_save_target_path_for_current_view(self) -> Optional[Path]: - """Determine the target path for saving edits based on current view. - - If we are viewing a variant (backup or developed) via override, we want - to save changes "as" the main image so that a NEW backup of the main - image is created (Policy A). - """ - if not self.view_override_path: + def _get_current_auto_adjust_path(self) -> Optional[Path]: + """Return the currently viewed file path used as the auto-adjust source.""" + if not self.image_files or not ( + 0 <= self.current_index < len(self.image_files) + ): return None - - # Policy Change: When editing a "developed" variant (e.g. from RawTherapee), - # we want to save IN-PLACE (overwrite the developed file) rather than - # overwriting the Main source file. This prevents accidental data loss/confusion. - # Editing a "backup" still targets the Main file (restore behavior). - if self.view_override_kind == "developed": + try: + if self.view_override_path: + return Path(self.view_override_path) + return self.get_active_edit_path(self.current_index) + except (IndexError, OSError, TypeError, ValueError): return None - if self.current_index is not None and 0 <= self.current_index < len( - self.image_files - ): - return self.image_files[self.current_index].path - return None + def _schedule_auto_adjust_save(self, action_type: str) -> None: + """Legacy no-op: quick auto-adjust saves are now session-based.""" + self._auto_adjust_save_pending_action = None - @Slot() - def save_edited_image(self): - """Saves the edited image in a background thread to keep UI responsive. + def _cancel_pending_auto_adjust_save(self) -> None: + """Legacy no-op: quick auto-adjust saves are now session-based.""" + self._auto_adjust_save_pending_action = None - All export-critical state is captured as an immutable snapshot on the - main thread BEFORE the editor is closed or the background worker starts. - The background worker operates only on the snapshot — it never reads - live editor state for export data. - """ - if not self.image_editor.original_image: - return + def _flush_pending_auto_adjust_save(self) -> None: + """Legacy no-op: quick auto-adjust saves are now session-based.""" + self._auto_adjust_save_pending_action = None - # Determine the actual target path for duplicate-save protection. - # Normalize to a canonical string so Path vs str never causes a miss. - save_target_path = self._get_save_target_path_for_current_view() - raw_target = save_target_path or self.image_editor.current_filepath - effective_target = str(Path(raw_target).resolve()) if raw_target else None - if effective_target and effective_target in self._saves_in_flight: - self.update_status_message( - "This image is still saving. Please wait a moment.", timeout=3000 - ) - return + @Slot() + def _fire_auto_adjust_save_debounce(self) -> None: + self._auto_adjust_save_pending_action = None - # Capture state needed for save before we start - write_sidecar = self.current_edit_source_mode == "raw" - dev_path = None - if write_sidecar and 0 <= self.current_index < len(self.image_files): - dev_path = self.image_files[self.current_index].developed_jpg_path + def _get_current_live_edit_session_info( + self, + ) -> Optional[tuple[str, Optional[str], int]]: + """Return the active path key, session id, and edit revision for the live editor session.""" + if self.image_editor is None or self.image_editor.current_filepath is None: + return None + if self.image_editor.original_image is None: + return None + + editor_path = Path(self.image_editor.current_filepath) + active_path = self._get_current_auto_adjust_path() + if active_path is None: + return None - # --- CRITICAL: Snapshot export state BEFORE closing editor or submitting --- - # This runs on the main thread and captures an immutable copy of everything - # needed for the export: source image, edits, darken settings, mask data, EXIF. try: - export_snapshot = self.image_editor.snapshot_for_export( - write_developed_jpg=write_sidecar, - developed_path=dev_path, - save_target_path=save_target_path, - ) - except RuntimeError as e: - self.update_status_message(str(e)) - return + active_key = self._key(active_path) + editor_key = self._key(editor_path) + except (OSError, TypeError, ValueError): + return None - # Capture save context NOW — these are frozen into the result dict so - # _on_save_finished can make cleanup decisions without reading mutable - # controller/editor fields that may change during the background save. - editor_was_open = self.ui_state.isEditorOpen - save_image_key = ( - self._key(self.image_files[self.current_index].path) - if 0 <= self.current_index < len(self.image_files) - else None - ) - session_token = ( - save_image_key, - getattr(self, "view_override_kind", None), - self.image_editor.session_id if self.image_editor else None, - # Include edit revision so _on_save_finished does not clear state - # if the user continued editing after save was submitted. - getattr(self.image_editor, "_edits_rev", None), - ) - save_metadata_path = ( - self.image_files[self.current_index].path - if 0 <= self.current_index < len(self.image_files) - else save_target_path or self.image_editor.current_filepath + if active_key != editor_key: + return None + + return ( + active_key, + getattr(self.image_editor, "session_id", None), + int(getattr(self.image_editor, "_edits_rev", 0)), ) - if save_image_key and save_image_key in self._saving_keys: - self.update_status_message( - "This image is still saving. Please wait a moment.", timeout=3000 - ) + def _ensure_live_edit_session_state(self, *, force_reset: bool = False) -> None: + """Bind dirty-session tracking to the currently loaded editor session.""" + info = self._get_current_live_edit_session_info() + if info is None: + if force_reset: + self._live_edit_session_state = None return - # Track in-flight save by target path - if effective_target: - self._saves_in_flight.add(effective_target) - if save_image_key: - self._saving_keys.add(save_image_key) + active_path_key, session_id, revision = info + state = self._live_edit_session_state + if ( + force_reset + or state is None + or state.active_path_key != active_path_key + or state.session_id != session_id + ): + self._live_edit_session_state = LiveEditSessionState( + active_path_key=active_path_key, + session_id=session_id, + persisted_revision=revision, + submitted_revision=revision, + ) - # Show saving indicator (stays until save finishes — no auto-clear timeout) - self.ui_state.isSaving = True - self.ui_state.statusMessage = "Saving..." + def _clear_live_edit_session_state(self) -> None: + """Drop dirty-session tracking for the current image.""" + self._live_edit_session_state = None - # Compute restore-override flag - # We are restoring if we have an override path AND kind is NOT developed (i.e. it's a backup) - started_from_restore_override = ( - bool(self.view_override_path) - and getattr(self, "view_override_kind", None) != "developed" + def _current_live_session_has_meaningful_edits(self) -> bool: + """Return True when the current editor session would change persisted pixels.""" + if self.image_editor is None: + return False + if ( + self.image_editor.current_filepath is None + or self.image_editor.original_image is None + ): + return False + + edits = getattr(self.image_editor, "current_edits", None) or {} + numeric_keys = ( + "brightness", + "contrast", + "saturation", + "white_balance_by", + "white_balance_mg", + "sharpness", + "exposure", + "highlights", + "shadows", + "vibrance", + "vignette", + "blacks", + "whites", + "clarity", + "texture", + "straighten_angle", ) + for key in numeric_keys: + try: + if abs(float(edits.get(key, 0.0))) > 0.001: + return True + except (TypeError, ValueError): + return True - # Build the base context that every result dict carries - _ctx = { - "target": effective_target, - "editor_was_open": editor_was_open, - "save_image_key": save_image_key, - "session_token": session_token, - "save_directory_key": self._key(self.image_dir), - "save_metadata_path": ( - str(save_metadata_path) if save_metadata_path else None - ), - "started_from_restore_override": started_from_restore_override, - # Keep metadata writes bound to the sidecar that owned the save - # request. The user can navigate to another folder before the - # background save completes, and self.sidecar may change. - "save_sidecar": self.sidecar, - } + try: + if int(edits.get("rotation", 0)) % 360 != 0: + return True + except (TypeError, ValueError): + return True - # Submit save work to background thread — operates only on the snapshot - def do_save(): - """Worker function that runs in background thread.""" + crop_box = edits.get("crop_box") + if crop_box is not None: try: - result = self.image_editor.save_from_snapshot(export_snapshot) - return {"success": True, "result": result, **_ctx} - except RuntimeError as e: - return {"success": False, "error": str(e), **_ctx} - except Exception as e: - log.exception("Unexpected error during save: %s", e) - return { - "success": False, - "error": "Failed to save image", - **_ctx, - } + normalized_crop_box = tuple(int(v) for v in crop_box) + except (TypeError, ValueError): + return True + if normalized_crop_box != (0, 0, 1000, 1000): + return True - def on_done(future): - """Callback when background save completes - emits signal to hop to main thread.""" - if self._shutting_down: - return - try: - result = future.result() - except Exception as e: - result = {"success": False, "error": str(e), **_ctx} - self._saveFinished.emit(result) + darken_settings = edits.get("darken_settings") + if darken_settings is not None and getattr(darken_settings, "enabled", False): + mask_id = getattr(darken_settings, "mask_id", "") + mask_data = self.image_editor._mask_assets.get(mask_id) + if mask_data is None or mask_data.has_strokes(): + return True - try: - future = self._save_executor.submit(do_save) - except Exception as e: - log.error("Failed to submit save to background executor: %s", e) - # Rollback save bookkeeping: submission failed, save never started. - if effective_target: - self._saves_in_flight.discard(effective_target) - if save_image_key: - self._saving_keys.discard(save_image_key) - self.ui_state.isSaving = False - self.update_status_message(f"Failed to start background save: {e}") - # Do NOT close editor if submission failed, as the save never started. - # Return early to avoid the isEditorOpen = False block below. + return False + + def _is_current_live_edit_session_dirty(self) -> bool: + """Return True when the current session has unsaved edits beyond the latest submitted save.""" + state = self._live_edit_session_state + info = self._get_current_live_edit_session_info() + if state is None or info is None: + return False + + active_path_key, session_id, revision = info + if state.active_path_key != active_path_key or state.session_id != session_id: + return False + if revision <= max(state.persisted_revision, state.submitted_revision): + return False + return self._current_live_session_has_meaningful_edits() + + def _mark_current_live_edit_session_submitted(self, revision: int) -> None: + """Record that a save request has been queued for the current session revision.""" + state = self._live_edit_session_state + info = self._get_current_live_edit_session_info() + if state is None or info is None: return - try: - future.add_done_callback(on_done) - except Exception as e: - # Submission succeeded, so the save IS running — do not roll back - # _saves_in_flight / _saving_keys. Instead, spin up a minimal - # daemon thread to await the future and deliver the result via - # on_done(), so _saveFinished is still emitted and cleanup runs. - log.error( - "Failed to register save callback; using fallback watcher thread: %s", e - ) - def _fallback_watcher(fut=future): - concurrent.futures.wait([fut]) - on_done(fut) + active_path_key, session_id, _ = info + if state.active_path_key == active_path_key and state.session_id == session_id: + state.submitted_revision = max(state.submitted_revision, revision) - t = threading.Thread( - target=_fallback_watcher, daemon=True, name="SaveCallbackFallback" + def _mark_current_live_edit_session_persisted(self, revision: int) -> None: + """Record that the current session revision has been written to disk.""" + state = self._live_edit_session_state + info = self._get_current_live_edit_session_info() + if state is None or info is None: + return + + active_path_key, session_id, _ = info + if state.active_path_key == active_path_key and state.session_id == session_id: + state.persisted_revision = max(state.persisted_revision, revision) + state.submitted_revision = max( + state.submitted_revision, + state.persisted_revision, ) - t.start() - # Close editor UI immediately to allow the user to continue working. - # The background worker uses the frozen export_snapshot, so it doesn't - # need the live editor UI to remain open. - # If the save fails later, memory is preserved due to the guard in - # _on_editor_open_changed, allowing the same session to be re-opened. - if self.ui_state.isEditorOpen: - self.ui_state.isEditorOpen = False + def _mark_current_live_edit_session_clean(self) -> None: + """Treat the current revision as clean without writing a file.""" + info = self._get_current_live_edit_session_info() + if info is None: + self._live_edit_session_state = None + return - @Slot(object) - def _on_save_finished(self, save_result: dict): - """Handle save completion on main thread (called via signal from background).""" - # Guard against callbacks during/after shutdown - if self._shutting_down: + _, _, revision = info + self._ensure_live_edit_session_state() + state = self._live_edit_session_state + if state is not None: + state.persisted_revision = revision + state.submitted_revision = revision + + def _mark_current_live_edit_session_save_failed(self, revision: int) -> None: + """Rollback the submitted revision watermark when the latest save fails.""" + state = self._live_edit_session_state + info = self._get_current_live_edit_session_info() + if state is None or info is None: return - # Remove completed target from in-flight set, then clear the saving - # indicator only when no exports remain in progress. - target = save_result.get("target") + active_path_key, session_id, _ = info + if ( + state.active_path_key == active_path_key + and state.session_id == session_id + and revision >= state.submitted_revision + ): + state.submitted_revision = state.persisted_revision + + def _note_latest_save_token( + self, + *, + target: Optional[str], + session_token: Any, + ) -> None: + """Record the newest captured save token for a target path.""" + if target and session_token is not None: + self._latest_save_tokens[target] = session_token + + def _save_target_is_in_flight(self, target: Optional[str]) -> bool: + """Return True when a save is already active for the resolved target path.""" + return bool(target and self._saves_in_flight.get(target, 0) > 0) + + def _reject_save_while_target_busy( + self, + *, + target: Optional[str], + session_token: Any, + status_message: Optional[str], + ) -> bool: + """Decline a save when another request for the same target is already active.""" + if not self._save_target_is_in_flight(target): + return False + + self._note_latest_save_token(target=target, session_token=session_token) + log.info( + "Skipping save request for %s; another save is already in flight", target + ) + if status_message: + self.update_status_message(status_message, timeout=3000) + return True + + def _increment_save_tracking( + self, + *, + target: Optional[str], + save_image_key: Optional[str], + ) -> None: + """Increment in-flight save counters for the target path and image key.""" if target: - self._saves_in_flight.discard(target) + self._saves_in_flight[target] = self._saves_in_flight.get(target, 0) + 1 + if save_image_key: + self._saving_keys[save_image_key] = ( + self._saving_keys.get(save_image_key, 0) + 1 + ) - save_key = save_result.get("save_image_key") - if save_key: - self._saving_keys.discard(save_key) - if not self._saves_in_flight: - self.ui_state.isSaving = False + def _decrement_save_tracking( + self, + *, + target: Optional[str], + save_image_key: Optional[str], + ) -> None: + """Decrement in-flight save counters for the target path and image key.""" + if target: + remaining = self._saves_in_flight.get(target, 0) - 1 + if remaining > 0: + self._saves_in_flight[target] = remaining + else: + self._saves_in_flight.pop(target, None) + if save_image_key: + remaining = self._saving_keys.get(save_image_key, 0) - 1 + if remaining > 0: + self._saving_keys[save_image_key] = remaining + else: + self._saving_keys.pop(save_image_key, None) - if not save_result.get("success"): - error_msg = save_result.get("error", "Save failed") - self.update_status_message(f"Save failed: {error_msg}", timeout=5000) - return + def _clear_active_auto_adjust_state( + self, + reason: str = "", + *, + clear_editor: bool = False, + ) -> None: + """Drop transient auto-adjust state and optionally discard the hidden session.""" + self._cancel_pending_auto_adjust_save() + had_state = self._active_auto_adjust_state is not None + self._active_auto_adjust_state = None - result = save_result.get("result") - if isinstance(result, tuple) and len(result) == 2: - saved_path, backup_path = result + if had_state and reason: + log.debug("Cleared active auto-adjust state: %s", reason) - # --- Post-Save Cleanup --- + if clear_editor and self.image_editor: + self.image_editor.clear() - # Read frozen save context — these were captured at save-initiation - # time and are immune to editor/navigation changes during the save. - editor_was_open = save_result.get("editor_was_open", False) - save_session_token = save_result.get("session_token") + def _has_valid_active_auto_adjust_state(self) -> bool: + """Return True when the transient state still matches the current session.""" + state = self._active_auto_adjust_state + if state is None or self.image_editor is None: + return False - # Check whether the user is still viewing the identical session - # they saved (same image, same variant, same editor underlying data) - current_image_key = ( - self._key(self.image_files[self.current_index].path) - if 0 <= self.current_index < len(self.image_files) - else None - ) - current_session_token = ( - current_image_key, - getattr(self, "view_override_kind", None), - self.image_editor.session_id if self.image_editor else None, - getattr(self.image_editor, "_edits_rev", None), - ) + active_path = self._get_current_auto_adjust_path() + editor_path = getattr(self.image_editor, "current_filepath", None) + if active_path is None or editor_path is None: + return False - # Check whether the user is still viewing the same image/session - # (image key, variant kind, and session_id matching) - still_on_same_session = ( - save_session_token is not None - and current_session_token is not None - and len(save_session_token) >= 3 - and current_session_token[:3] == save_session_token[:3] - ) + try: + active_path_key = self._key(active_path) + editor_path_key = self._key(editor_path) + except (OSError, TypeError, ValueError): + return False - # Check if it is the EXACT identical revision (no user edits since save started) - still_on_identical_revision = ( - still_on_same_session - and len(save_session_token) >= 4 - and current_session_token[3] == save_session_token[3] - ) + return ( + state.active_path_key == active_path_key + and editor_path_key == active_path_key + and state.session_id == getattr(self.image_editor, "session_id", None) + ) - if still_on_same_session: - # 1. Editor Cleanup (only if revision is unchanged) - if still_on_identical_revision: - if editor_was_open: - if self.ui_state.isEditorOpen: - self.ui_state.isEditorOpen = False - # Closing triggers _on_editor_open_changed -> image_editor.clear() - # but we call it explicitly here just in case they closed it manually. - self.image_editor.clear() + def _capture_source_exif_for_active_image(self) -> Optional[bytes]: + """Capture source EXIF when editing from RAW mode.""" + if self.current_edit_source_mode != "raw": + return None + if not (0 <= self.current_index < len(self.image_files)): + return None - # Also clear variant override if we started from one - if save_result.get("started_from_restore_override"): - self._clear_variant_override() + image_file = self.image_files[self.current_index] + jpeg_path = image_file.path + if jpeg_path.suffix.lower() in (".tif", ".tiff") or not jpeg_path.exists(): + return None - # Record current path to stay on it after refresh (since index may shift) - preserved_path = None - if 0 <= self.current_index < len(self.image_files): - preserved_path = self.image_files[self.current_index].path + try: + with Image.open(jpeg_path) as src_im: + return src_im.info.get("exif") + except Exception as e: + log.warning("Failed to capture source EXIF from %s: %s", jpeg_path, e) + return None - # 1. Update sidecar metadata FIRST so all following refreshes see it - if saved_path: - save_sidecar = save_result.get("save_sidecar") or self.sidecar - metadata_path = ( - Path(save_result["save_metadata_path"]) - if save_result.get("save_metadata_path") - else saved_path - ) - metadata_before = self._mark_image_edited_in_sidecar( + def _ensure_active_image_loaded_for_auto_adjust( + self, + *, + force_reload: bool = False, + ) -> Optional[Path]: + """Ensure the currently viewed image is loaded for auto-adjust operations.""" + active_path = self._get_current_auto_adjust_path() + if active_path is None: + self.update_status_message("No image to adjust") + return None + + if force_reload: + self.image_editor.clear() + + editor_path = getattr(self.image_editor, "current_filepath", None) + paths_match = False + if editor_path: + try: + paths_match = Path(editor_path).resolve() == active_path.resolve() + except (OSError, ValueError): + paths_match = str(editor_path) == str(active_path) + + has_buffers = ( + self.image_editor.original_image is not None + and self.image_editor.float_image is not None + ) + if paths_match and has_buffers: + if self._has_valid_active_auto_adjust_state(): + self._ensure_live_edit_session_state() + return active_path + + try: + current_mtime = active_path.stat().st_mtime + except OSError: + current_mtime = 0.0 + + if math.isclose( + current_mtime, + getattr(self.image_editor, "current_mtime", 0.0), + rel_tol=0.0, + abs_tol=1e-6, + ): + self._ensure_live_edit_session_state() + return active_path + + cached_preview = self.get_decoded_image(self.current_index) + if self.image_editor.load_image( + str(active_path), + cached_preview=cached_preview, + source_exif=self._capture_source_exif_for_active_image(), + ): + self._ensure_live_edit_session_state(force_reset=True) + return active_path + + self.update_status_message("Failed to load image") + return None + + def _compute_auto_levels_recommendation(self) -> dict[str, Any]: + """Compute the current baseline auto-level recommendation.""" + blacks, whites, p_low, p_high = self.image_editor.analyze_auto_levels( + self.auto_level_threshold, + reset_levels=True, + ) + + dynamic_range = p_high - p_low + if self.auto_level_strength_auto: + if dynamic_range < 1.0: + strength = 0.0 + else: + stretch_full = 255.0 / dynamic_range + stretch_cap = 4.0 + if stretch_full <= stretch_cap: + strength = 1.0 + else: + strength = (stretch_cap - 1.0) / (stretch_full - 1.0) + strength = max(0.0, min(1.0, strength)) + + log.debug( + "Auto levels: p_low=%0.1f, p_high=%0.1f, range=%0.1f, stretch_full=%0.2f, strength=%0.3f", + p_low, + p_high, + dynamic_range, + stretch_full, + strength, + ) + else: + strength = self.auto_level_strength + + base_blacks = blacks * strength + base_whites = whites * strength + noop_reason = None + if dynamic_range < 1.0: + noop_reason = "flat image" + elif p_low <= 0.0 and p_high >= 255.0: + noop_reason = "already full range" + + return { + "base_blacks": base_blacks, + "base_whites": base_whites, + "p_low": p_low, + "p_high": p_high, + "dynamic_range": dynamic_range, + "strength": strength, + "noop_reason": noop_reason, + } + + @staticmethod + def _derive_auto_adjust_levels( + state: ActiveAutoAdjustState, + ) -> tuple[float, float]: + """Translate transient auto-adjust state into final blacks/whites values.""" + blacks = state.base_blacks - (_AUTO_ADJUST_BLACK_STEP * state.extra_black_steps) + whites = state.base_whites - ( + _AUTO_ADJUST_HIGHLIGHT_STEP * state.extra_highlight_steps + ) + return blacks, whites + + def _format_auto_levels_detail( + self, + *, + p_low: float, + p_high: float, + blacks: float, + whites: float, + extra_highlight_steps: int = 0, + extra_black_steps: int = 0, + ) -> str: + """Build a human-readable status line for the current levels state.""" + if abs(blacks) <= 0.001 and abs(whites) <= 0.001: + msg = "Auto levels: no change" + elif p_high >= 255.0 and abs(whites) <= 0.001: + msg = ( + f"Auto levels: highlights clipped; shadows only (blacks {blacks:+.1f})" + ) + elif p_low <= 0.0 and abs(blacks) <= 0.001: + msg = ( + f"Auto levels: shadows clipped; highlights only (whites {whites:+.1f})" + ) + else: + dynamic_range = max(1.0, p_high - p_low) + gain = 255.0 / dynamic_range + msg = ( + f"Auto levels: blacks {blacks:+.1f}, whites {whites:+.1f} " + f"(range {p_low:.0f}-{p_high:.0f}, gain {gain:.2f})" + ) + + suffixes = [] + if extra_highlight_steps > 0: + suffixes.append(f"highlights -{extra_highlight_steps * 7}pt") + if extra_black_steps > 0: + suffixes.append(f"blacks -{extra_black_steps * 7}pt") + if suffixes: + msg = f"{msg}; {', '.join(suffixes)}" + return msg + + def _apply_levels_to_editor( + self, + *, + blacks: float, + whites: float, + kick_preview: bool, + ) -> bool: + """Apply derived levels to the editor and synchronize the UI state.""" + changed_blacks = self.image_editor.set_edit_param("blacks", blacks) + changed_whites = self.image_editor.set_edit_param("whites", whites) + + self.ui_state.blacks = blacks + self.ui_state.whites = whites + + changed = changed_blacks or changed_whites + if changed and kick_preview: + self._kick_preview_worker() + if self.ui_state.isHistogramVisible: + self.update_histogram() + return changed + + def _build_active_auto_adjust_state( + self, + recommendation: dict[str, Any], + ) -> ActiveAutoAdjustState: + """Create transient auto-adjust state for the current loaded session.""" + active_path = self._get_current_auto_adjust_path() + active_path_key = ( + self._key(active_path) + if active_path is not None + else self._key(self.image_editor.current_filepath) + ) + return ActiveAutoAdjustState( + active_path_key=active_path_key, + session_id=getattr(self.image_editor, "session_id", None), + base_blacks=float(recommendation["base_blacks"]), + base_whites=float(recommendation["base_whites"]), + p_low=float(recommendation["p_low"]), + p_high=float(recommendation["p_high"]), + ) + + def _save_current_auto_adjust( + self, + *, + action_type: str, + detail_msg: str, + ) -> bool: + """Save the current auto-adjusted image once and keep the session alive.""" + t_start = time.perf_counter() + save_target_path = self._get_save_target_path_for_current_view() + + try: + # Safe optimization only for true levels-only sessions. If AWB, crop, + # rotate, or any other edit is active, the helper declines by + # returning None and we fall back to the general save path. + save_result = self.image_editor.save_image_uint8_levels( + save_target_path=save_target_path + ) + if save_result is None: + save_result = self.image_editor.save_image( + save_target_path=save_target_path + ) + except RuntimeError as e: + log.warning("save_current_auto_adjust: Save failed: %s", e) + self.update_status_message(f"Failed to save image: {e}") + return False + except Exception as e: + log.exception( + "save_current_auto_adjust: Unexpected error during save: %s", e + ) + self.update_status_message("Failed to save image") + return False + + if not save_result: + self.update_status_message("Failed to save image") + return False + + saved_path, backup_path = save_result + metadata_path = ( + self.image_files[self.current_index].path + if 0 <= self.current_index < len(self.image_files) + else saved_path + ) + metadata_before = self._mark_image_edited_in_sidecar( + self.sidecar, + metadata_path, + ) + self.undo_history.append( + ( + action_type, + self._build_edit_undo_data( + saved_path, + backup_path, + metadata_path=metadata_path, + metadata_before=metadata_before, + sidecar=self.sidecar, + ), + time.time(), + ) + ) + + editor_path = getattr(self.image_editor, "current_filepath", None) + if editor_path is not None: + try: + if Path(editor_path).resolve() == saved_path.resolve(): + self.image_editor.current_mtime = saved_path.stat().st_mtime + except (OSError, ValueError): + pass + + self.refresh_image_list() + self._reindex_after_save(saved_path) + self._bump_display_generation() + self.image_cache.pop_path(saved_path) + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + if self.ui_state.isHistogramVisible: + self.update_histogram() + + total_ms = int((time.perf_counter() - t_start) * 1000) + saved_msg = ( + f"{detail_msg} — saved ({total_ms} ms)" + if detail_msg + else f"Auto-adjust saved ({total_ms} ms)" + ) + self.update_status_message(saved_msg, timeout=9000) + return True + + # --- Image Editor Integration --- + + def _get_save_target_path_for_current_view(self) -> Optional[Path]: + """Determine the target path for saving edits based on current view. + + If we are viewing a variant (backup or developed) via override, we want + to save changes "as" the main image so that a NEW backup of the main + image is created (Policy A). + """ + if not self.view_override_path: + return None + + # Policy Change: When editing a "developed" variant (e.g. from RawTherapee), + # we want to save IN-PLACE (overwrite the developed file) rather than + # overwriting the Main source file. This prevents accidental data loss/confusion. + # Editing a "backup" still targets the Main file (restore behavior). + if self.view_override_kind == "developed": + return None + + if self.current_index is not None and 0 <= self.current_index < len( + self.image_files + ): + return self.image_files[self.current_index].path + return None + + def _prepare_current_session_save_request( + self, + *, + editor_was_open: bool, + success_message: Optional[str], + ) -> Optional[dict[str, Any]]: + """Capture an immutable save request for the current live editor session.""" + self._last_save_prepare_error = None + if not self.image_editor.original_image: + return None + + self._ensure_live_edit_session_state() + session_info = self._get_current_live_edit_session_info() + if session_info is None: + return None + + _, _, save_revision = session_info + live_state = self._live_edit_session_state + if not self._current_live_session_has_meaningful_edits(): + self._mark_current_live_edit_session_clean() + return None + if live_state is not None and save_revision <= live_state.submitted_revision: + return None + + save_target_path = self._get_save_target_path_for_current_view() + raw_target = save_target_path or self.image_editor.current_filepath + effective_target = str(Path(raw_target).resolve()) if raw_target else None + + write_sidecar = self.current_edit_source_mode == "raw" + dev_path = None + if write_sidecar and 0 <= self.current_index < len(self.image_files): + dev_path = self.image_files[self.current_index].developed_jpg_path + + save_image_key = ( + self._key(self.image_files[self.current_index].path) + if 0 <= self.current_index < len(self.image_files) + else None + ) + session_token = ( + save_image_key, + getattr(self, "view_override_kind", None), + self.image_editor.session_id if self.image_editor else None, + save_revision, + ) + try: + export_snapshot = self.image_editor.snapshot_for_export( + write_developed_jpg=write_sidecar, + developed_path=dev_path, + save_target_path=save_target_path, + ) + except RuntimeError as e: + self._last_save_prepare_error = f"Failed to prepare save: {e}" + log.warning( + "Failed to capture save snapshot for %s: %s", effective_target, e + ) + self.update_status_message(self._last_save_prepare_error, timeout=5000) + return None + except Exception as e: + self._last_save_prepare_error = "Failed to prepare save" + log.exception( + "Unexpected error capturing save snapshot for %s: %s", + effective_target, + e, + ) + self.update_status_message(self._last_save_prepare_error, timeout=5000) + return None + + self._note_latest_save_token( + target=effective_target, session_token=session_token + ) + save_metadata_path = ( + self.image_files[self.current_index].path + if 0 <= self.current_index < len(self.image_files) + else save_target_path or self.image_editor.current_filepath + ) + started_from_restore_override = ( + bool(self.view_override_path) + and getattr(self, "view_override_kind", None) != "developed" + ) + + return { + "snapshot": export_snapshot, + "context": { + "target": effective_target, + "editor_was_open": editor_was_open, + "save_action_type": "save_edit", + "save_image_key": save_image_key, + "save_revision": save_revision, + "session_token": session_token, + "save_directory_key": self._key(self.image_dir), + "save_metadata_path": ( + str(save_metadata_path) if save_metadata_path else None + ), + "started_from_restore_override": started_from_restore_override, + "save_sidecar": self.sidecar, + "success_message": success_message, + }, + } + + def _submit_save_request_async( + self, + request: dict[str, Any], + *, + saving_status: Optional[str] = None, + ) -> bool: + """Submit a captured save request to the background save executor.""" + context = request["context"] + export_snapshot = request["snapshot"] + target = context.get("target") + save_image_key = context.get("save_image_key") + session_token = context.get("session_token") + + if self._reject_save_while_target_busy( + target=target, + session_token=session_token, + status_message="Save already in progress for this image.", + ): + return False + + self._increment_save_tracking(target=target, save_image_key=save_image_key) + self._mark_current_live_edit_session_submitted(context["save_revision"]) + self.ui_state.isSaving = True + if saving_status: + self.ui_state.statusMessage = saving_status + + def do_save(): + try: + result = self.image_editor.save_from_snapshot(export_snapshot) + return { + "success": True, + "result": result, + "request": request, + **context, + } + except RuntimeError as e: + return { + "success": False, + "error": str(e), + "request": request, + **context, + } + except Exception as e: + log.exception("Unexpected error during save: %s", e) + return { + "success": False, + "error": "Failed to save image", + "request": request, + **context, + } + + def on_done(future): + if self._shutting_down: + return + try: + result = future.result() + except Exception as e: + result = {"success": False, "error": str(e), **context} + self._saveFinished.emit(result) + + try: + future = self._save_executor.submit(do_save) + except Exception as e: + self._decrement_save_tracking(target=target, save_image_key=save_image_key) + if not self._saves_in_flight: + self.ui_state.isSaving = False + self._mark_current_live_edit_session_save_failed(context["save_revision"]) + self.update_status_message(f"Failed to start background save: {e}") + return False + + try: + future.add_done_callback(on_done) + except Exception as e: + log.error( + "Failed to register save callback; using fallback watcher thread: %s", e + ) + + def _fallback_watcher(fut=future): + concurrent.futures.wait([fut]) + on_done(fut) + + t = threading.Thread( + target=_fallback_watcher, daemon=True, name="SaveCallbackFallback" + ) + t.start() + + return True + + def _run_save_request_sync( + self, + request: dict[str, Any], + *, + saving_status: Optional[str] = None, + ) -> bool: + """Run a captured save request synchronously on the main thread.""" + context = request["context"] + export_snapshot = request["snapshot"] + target = context.get("target") + save_image_key = context.get("save_image_key") + session_token = context.get("session_token") + + if self._reject_save_while_target_busy( + target=target, + session_token=session_token, + status_message=( + "Save already in progress for this image." + if saving_status is not None + else None + ), + ): + if saving_status is None and target: + self._pending_save_recovery[target] = request + log.info( + "Deferring latest save for %s until shutdown recovery; " + "an older save is still in flight", + target, + ) + return False + + self._increment_save_tracking(target=target, save_image_key=save_image_key) + self._mark_current_live_edit_session_submitted(context["save_revision"]) + self.ui_state.isSaving = True + if saving_status: + self.update_status_message(saving_status) + + try: + result = self.image_editor.save_from_snapshot(export_snapshot) + save_result = { + "success": True, + "result": result, + "request": request, + **context, + } + except RuntimeError as e: + save_result = { + "success": False, + "error": str(e), + "request": request, + **context, + } + except Exception as e: + log.exception("Unexpected error during save: %s", e) + save_result = { + "success": False, + "error": "Failed to save image", + "request": request, + **context, + } + + self._on_save_finished(save_result) + return bool(save_result.get("success")) + + def _flush_current_live_edit_session_for_navigation(self) -> bool: + """Queue a background save for the current dirty session before switching images.""" + request = self._prepare_current_session_save_request( + editor_was_open=False, + success_message=None, + ) + if request is None: + return self._last_save_prepare_error is None + return self._submit_save_request_async(request) + + def _flush_current_live_edit_session_for_drag( + self, + *, + saving_status: Optional[str] = "Preparing drag...", + ) -> bool: + """Synchronously save the current dirty session before drag-out begins.""" + request = self._prepare_current_session_save_request( + editor_was_open=False, + success_message=None, + ) + if request is None: + return self._last_save_prepare_error is None + return self._run_save_request_sync(request, saving_status=saving_status) + + @Slot(result=bool) + def prepare_for_app_close(self) -> bool: + """Flush the live session before allowing the main window to close.""" + if self._shutting_down: + return True + + self._shutdown_flush_prepared = False + request = self._prepare_current_session_save_request( + editor_was_open=False, + success_message=None, + ) + if request is None: + return self._last_save_prepare_error is None + + target = request.get("context", {}).get("target") + if self._run_save_request_sync(request, saving_status=None): + self._shutdown_flush_prepared = True + return True + if target and self._pending_save_recovery.get(target) is request: + self._shutdown_flush_prepared = True + return True + return False + + @Slot() + def save_edited_image(self): + """Save the current live editor session in the background.""" + request = self._prepare_current_session_save_request( + editor_was_open=self.ui_state.isEditorOpen, + success_message="Image saved", + ) + if request is None: + if self._last_save_prepare_error is not None: + return + self.update_status_message("No unsaved edits to save", timeout=3000) + return + + if not self._submit_save_request_async(request, saving_status="Saving..."): + return + + if self.ui_state.isEditorOpen: + self.ui_state.isEditorOpen = False + + @Slot(object) + def _on_save_finished(self, save_result: dict): + """Handle save completion on main thread (called via signal from background).""" + # Guard against callbacks during/after shutdown + if self._shutting_down: + return + + # Remove completed target from in-flight set, then clear the saving + # indicator only when no exports remain in progress. + target = save_result.get("target") + save_key = save_result.get("save_image_key") + self._decrement_save_tracking(target=target, save_image_key=save_key) + if not self._saves_in_flight: + self.ui_state.isSaving = False + + save_session_token = save_result.get("session_token") + current_image_key = ( + self._key(self.image_files[self.current_index].path) + if 0 <= self.current_index < len(self.image_files) + else None + ) + current_session_token = ( + current_image_key, + getattr(self, "view_override_kind", None), + self.image_editor.session_id if self.image_editor else None, + getattr(self.image_editor, "_edits_rev", None), + ) + still_on_same_session = ( + save_session_token is not None + and current_session_token is not None + and len(save_session_token) >= 3 + and current_session_token[:3] == save_session_token[:3] + ) + still_on_identical_revision = ( + still_on_same_session + and len(save_session_token) >= 4 + and current_session_token[3] == save_session_token[3] + ) + save_revision = save_result.get("save_revision") + + if not save_result.get("success"): + if still_on_same_session and save_revision is not None: + self._mark_current_live_edit_session_save_failed(int(save_revision)) + error_msg = save_result.get("error", "Save failed") + request = save_result.get("request") + target = save_result.get("target") + attempts = ( + int(request.get("attempts", 1)) if isinstance(request, dict) else 1 + ) + latest_save_token = ( + self._latest_save_tokens.get(target) + if target and save_session_token is not None + else None + ) + if ( + latest_save_token is not None + and latest_save_token != save_session_token + ): + log.info( + "Skipping retry for stale save request %s (rev=%s); newer save token exists", + target, + save_revision, + ) + return + # The captured snapshot is fully self-contained — auto-retry once + # before declaring permanent failure. If the user has navigated away + # we still need to preserve the snapshot so edits aren't lost. + if isinstance(request, dict) and attempts < 2: + request["attempts"] = attempts + 1 + log.warning( + "Background save failed for %s (attempt %d): %s — retrying", + target, + attempts, + error_msg, + ) + self._submit_save_request_async(request) + return + if isinstance(request, dict) and target and not still_on_same_session: + self._pending_save_recovery[target] = request + log.error( + "Background save for %s failed permanently after %d attempts: %s — " + "queued for shutdown flush", + target, + attempts, + error_msg, + ) + self.update_status_message( + f"Background save failed for {Path(target).name}: {error_msg}. " + "Will retry on quit.", + timeout=10000, + ) + else: + self.update_status_message(f"Save failed: {error_msg}", timeout=5000) + return + + # Success — drop any prior recovery snapshot for this target. + target = save_result.get("target") + if target and target in self._pending_save_recovery: + self._pending_save_recovery.pop(target, None) + + result = save_result.get("result") + if isinstance(result, tuple) and len(result) == 2: + saved_path, backup_path = result + + # --- Post-Save Cleanup --- + + # Read frozen save context — these were captured at save-initiation + # time and are immune to editor/navigation changes during the save. + editor_was_open = save_result.get("editor_was_open", False) + if still_on_same_session and save_revision is not None: + self._mark_current_live_edit_session_persisted(int(save_revision)) + + if still_on_same_session: + # 1. Editor Cleanup (only if revision is unchanged) + if still_on_identical_revision: + if editor_was_open: + if self.ui_state.isEditorOpen: + self.ui_state.isEditorOpen = False + # Closing triggers _on_editor_open_changed -> image_editor.clear() + # but we call it explicitly here just in case they closed it manually. + self._clear_active_auto_adjust_state( + "background save replaced the live editor session", + clear_editor=False, + ) + self.image_editor.clear() + + # Also clear variant override if we started from one + if save_result.get("started_from_restore_override"): + self._clear_variant_override() + + # Record current path to stay on it after refresh (since index may shift) + preserved_path = None + if 0 <= self.current_index < len(self.image_files): + preserved_path = self.image_files[self.current_index].path + + # 1. Update sidecar metadata FIRST so all following refreshes see it + if saved_path: + save_sidecar = save_result.get("save_sidecar") or self.sidecar + metadata_path = ( + Path(save_result["save_metadata_path"]) + if save_result.get("save_metadata_path") + else saved_path + ) + metadata_before = self._mark_image_edited_in_sidecar( save_sidecar, metadata_path ) save_directory_key = save_result.get("save_directory_key") @@ -2083,9 +2974,10 @@ def _on_save_finished(self, save_result: dict): or save_directory_key == current_directory_key ) if should_record_undo: + action_type = save_result.get("save_action_type", "save_edit") self.undo_history.append( ( - "save_edit", + action_type, self._build_edit_undo_data( saved_path, backup_path, @@ -2124,7 +3016,9 @@ def _on_save_finished(self, save_result: dict): if self.ui_state: self.ui_state.variantBadgesChanged.emit() - self.update_status_message("Image saved") + success_message = save_result.get("success_message") + if success_message: + self.update_status_message(success_message) else: # Success reported but result shape unexpected log.warning("Save finished with unexpected result shape: %r", result) @@ -2147,6 +3041,20 @@ def _set_current_index( if index < 0 or index >= len(self.image_files): return + if not self._flush_current_live_edit_session_for_navigation(): + return + + self._clear_active_auto_adjust_state( + "navigated to a different image", + clear_editor=True, + ) + self._clear_live_edit_session_state() + self._clear_variant_override() + self._reset_crop_settings() + self._reset_darken_on_navigation() + + self.current_index = index # Set index first so signals pick up correct image + # Reset source mode to JPEG unless new image is strictly RAW-only # (This implements the "Default state on navigation" requirement) img = self.image_files[index] @@ -2161,14 +3069,6 @@ def _set_current_index( self.current_edit_source_mode = new_mode self.editSourceModeChanged.emit(new_mode) - self.current_index = index # Set index first so signals pick up correct image - - # Clear variant override on navigation - self._clear_variant_override() - - self._reset_crop_settings() - self._reset_darken_on_navigation() - if self.debug_cache: _t_prefetch = time.perf_counter() print( @@ -3201,6 +4101,48 @@ def add_uploaded_to_batch(self): f"All {len(indices_to_add)} uploaded image(s) already in batch." ) + def add_edited_to_batch(self): + """Add all edited-flagged images in the current directory to the batch.""" + if not self.image_files: + self.update_status_message("No images loaded.") + return + + indices_to_add = [] + for i, img in enumerate(self.image_files): + meta = self.sidecar.get_metadata(img.path, create=False) + if meta and meta.edited: + indices_to_add.append(i) + + if not indices_to_add: + self.update_status_message("No edited images found.") + return + + added_count = 0 + for idx in indices_to_add: + in_batch = any(start <= idx <= end for start, end in self.batches) + if not in_batch: + self.batches.append([idx, idx]) + added_count += 1 + + if added_count > 0: + self._normalize_batches() + self._invalidate_batch_cache() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + if hasattr(self, "_thumbnail_model") and self._thumbnail_model: + self._thumbnail_model.refresh() + + self.update_status_message( + f"Added {added_count} edited image(s) to batch ({len(indices_to_add)} total edited)" + ) + log.info("Added %d edited image(s) to batch", added_count) + else: + self.update_status_message( + f"All {len(indices_to_add)} edited image(s) already in batch." + ) + def remove_from_batch_or_stack(self): """Remove current image from any batch or stack it's in.""" if not self.image_files or self.current_index >= len(self.image_files): @@ -4039,6 +4981,10 @@ def _switch_to_directory( self._batch_indices_cache_key = None # Clear editor state if open + self._clear_active_auto_adjust_state( + "folder-level state reset", + clear_editor=False, + ) self.image_editor.clear() # Clear thumbnail cache BEFORE refresh to avoid stale thumbs @@ -5336,17 +6282,30 @@ def _post_undo_refresh_and_select( self, target: Path, *, update_hist: bool = False ) -> None: """Centralized logic for refreshing state after an undo action.""" + # Clear stale editor/preview state so the provider doesn't serve a + # pre-undo preview frame. image_editor.clear() leaves current_edits + # populated; reset_edits() zeroes them out. + if self.image_editor: + self.image_editor.reset_edits() + self._clear_live_edit_session_state() + with self._preview_lock: + self._last_rendered_preview = None + self.refresh_image_list() - # Find index of restored image - target_resolve = target.resolve() - for i, img_file in enumerate(self.image_files): - try: - if img_file.path.resolve() == target_resolve: + # Use _key-based lookup (consistent with _reindex_after_save) for + # robust cross-platform path matching on WSL/Windows. + target_key = self._key(target) + new_idx = self._path_to_index.get(target_key) + if new_idx is not None: + self.current_index = new_idx + else: + # Fallback: name-based match + target_name = target.name + for i, img_file in enumerate(self.image_files): + if img_file.path.name == target_name: self.current_index = i break - except OSError: - continue self._bump_display_generation() self.image_cache.clear() @@ -5360,6 +6319,35 @@ def _post_undo_refresh_and_select( @Slot() def undo_delete(self): """Unified undo that handles delete, pending_delete, and edit operations.""" + if self._auto_adjust_save_pending_action is not None: + try: + self._flush_pending_auto_adjust_save() + except Exception: + log.exception("Failed to flush pending auto-adjust save before undo") + + # If the live session has unsaved edits (e.g. crop or auto-levels + # applied as preview-only), revert them before touching undo_history. + if self._is_current_live_edit_session_dirty(): + self._clear_active_auto_adjust_state( + "undo reverted unsaved live edits", + clear_editor=False, + ) + if self.image_editor: + self.image_editor.reset_edits() + self._clear_live_edit_session_state() + with self._preview_lock: + self._last_rendered_preview = None + self._bump_display_generation() + if self.image_cache and 0 <= self.current_index < len(self.image_files): + self.image_cache.pop_path(self.image_files[self.current_index].path) + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + if self.ui_state.isHistogramVisible: + self.update_histogram() + self.update_status_message("Undid unsaved edits") + return + if not self.undo_history: self.update_status_message("Nothing to undo.") return @@ -5523,6 +6511,7 @@ def undo_delete(self): "save_edit", "auto_white_balance", "auto_levels", + "auto_adjust", "crop", }: try: @@ -5543,6 +6532,10 @@ def undo_delete(self): restore_metadata_path = ( Path(metadata_path) if metadata_path else Path(saved_path) ) + self._clear_active_auto_adjust_state( + "undo restored a prior image state", + clear_editor=True, + ) self._restore_metadata_snapshot( restore_sidecar, restore_metadata_path, metadata_before ) @@ -5556,6 +6549,8 @@ def undo_delete(self): self.update_status_message("Undid auto white balance") elif action_type == "auto_levels": self.update_status_message("Undid auto levels") + elif action_type == "auto_adjust": + self.update_status_message("Undid auto adjust") else: self.update_status_message("Undid crop") except (FileNotFoundError, OSError, shutil.Error) as e: @@ -5565,6 +6560,22 @@ def undo_delete(self): def shutdown_qt(self): """Shutdown Qt objects only - MUST run on main/Qt thread.""" + # Persist the current live edit session before tearing down Qt so + # preview-only quick actions and editor changes are not lost on exit. + if not self._shutdown_flush_prepared: + try: + flushed = self._flush_current_live_edit_session_for_drag( + saving_status=None + ) + if not flushed and self._last_save_prepare_error is not None: + log.error( + "Shutdown proceeding without a live-session flush: %s", + self._last_save_prepare_error, + ) + except Exception: + log.exception("Failed to flush live edit session on shutdown") + self._shutdown_flush_prepared = False + self._shutting_down = True # set EARLY to make all slots no-op self._exif_pending_path = None log.info("Application shutting down (Qt cleanup).") @@ -5574,6 +6585,7 @@ def shutdown_qt(self): self._metadata_debounce_timer.stop() self._exif_debounce_timer.stop() self._watcher_debounce_timer.stop() + self._auto_adjust_save_timer.stop() self.histogram_timer.stop() self.resize_timer.stop() if hasattr(self, "_thumb_summary_timer"): @@ -5669,6 +6681,31 @@ def shutdown_nonqt(self): self._delete_executor, "delete", wait=True, cancel_futures=False ) + # Final attempt to persist snapshots whose background save previously + # failed, or whose latest revision was deferred at shutdown because an + # older save for the same target was still in flight. Run this only + # after the save executor drains so we do not race the same file twice. + for tgt, req in list(self._pending_save_recovery.items()): + try: + self.image_editor.save_from_snapshot(req["snapshot"]) + except Exception: + log.exception("Final shutdown retry failed for %s", tgt) + continue + try: + ctx = req.get("context", {}) + meta_path_str = ctx.get("save_metadata_path") or tgt + meta_sidecar = ctx.get("save_sidecar") or self.sidecar + if meta_path_str and meta_sidecar is not None: + self._mark_image_edited_in_sidecar( + meta_sidecar, Path(meta_path_str) + ) + log.info("Recovered pending save for %s on shutdown", tgt) + except Exception: + log.exception( + "Recovered save for %s but sidecar bookkeeping failed", tgt + ) + self._pending_save_recovery.clear() + # Shutdown prefetcher try: self.prefetcher.shutdown() @@ -6053,7 +7090,7 @@ def _restore_metadata_snapshot( def _is_image_saving(self, file_path_str: str) -> bool: if not file_path_str or not hasattr(self, "_saving_keys"): return False - return self._key(Path(file_path_str)) in self._saving_keys + return self._saving_keys.get(self._key(Path(file_path_str)), 0) > 0 def _block_if_saving(self, *paths) -> bool: """Helper to block actions if any of the given paths are currently saving.""" @@ -6070,12 +7107,6 @@ def start_drag_current_image(self): if not self.image_files or self.current_index >= len(self.image_files): return - # (Check moved below after batch resolution) - path_to_check = self.image_files[self.current_index].path - # We still check the current image early as a fast-fail - if self._block_if_saving(path_to_check): - return - # Collect files to drag: batch files if any batches exist, otherwise current image files_to_drag = set() @@ -6095,6 +7126,14 @@ def start_drag_current_image(self): idx for idx in file_indices if self.image_files[idx].path.exists() ] + current_path = self.image_files[self.current_index].path + current_in_drag = self.current_index in existing_indices + if current_in_drag and self._is_current_live_edit_session_dirty(): + if self._block_if_saving(current_path): + return + if not self._flush_current_live_edit_session_for_drag(): + return + # Check if ANY of the resolved files are currently saving for idx in existing_indices: if self._block_if_saving(self.image_files[idx].path): @@ -6379,6 +7418,7 @@ def load_image_for_editing(self): log.debug( "load_image_for_editing: Reusing existing session for %s", filepath ) + self._ensure_live_edit_session_state() # Ensure the background renderer is current and notify UI to refresh # Also synchronize sliders/crop state to the backend session. self._sync_editor_state_from_session() @@ -6410,6 +7450,11 @@ def load_image_for_editing(self): if self.image_editor.load_image( filepath, cached_preview=cached_preview, source_exif=source_exif ): + self._clear_active_auto_adjust_state( + "editor session reloaded", + clear_editor=False, + ) + self._ensure_live_edit_session_state(force_reset=True) # Notify UIState to update bindings # We do this via signals or by calling the update function on UIState if available # But UIState listens to editor signals? @@ -6507,6 +7552,10 @@ def set_edit_parameter(self, key: str, value: Any): # Trigger a refresh of the image to show the edit, ONLY if something changed # Uses gate pattern: runs immediately if not inflight, else queues for next if changed: + self._clear_active_auto_adjust_state( + f"manual edit changed '{key}'", + clear_editor=False, + ) self._kick_preview_worker() except Exception as e: log.error("Error setting edit parameter %s=%s: %s", key, value, e) @@ -6522,6 +7571,10 @@ def set_crop_box(self, left: int, top: int, right: int, bottom: int): def reset_edit_parameters(self): """Resets all editing parameters in the editor.""" self.image_editor.reset_edits() + self._clear_active_auto_adjust_state( + "editor parameters reset", + clear_editor=False, + ) if hasattr(self.ui_state, "reset_editor_state"): self.ui_state.reset_editor_state() @@ -6649,11 +7702,19 @@ def _ensure_darken_state(self): ds = DarkenSettings(enabled=True) self.image_editor.current_edits["darken_settings"] = ds self.image_editor._edits_rev += 1 + self._clear_active_auto_adjust_state( + "darken tool state created", + clear_editor=False, + ) else: ds = self.image_editor.current_edits["darken_settings"] if not ds.enabled: ds.enabled = True self.image_editor._edits_rev += 1 + self._clear_active_auto_adjust_state( + "darken tool enabled", + clear_editor=False, + ) @Slot(float, float, str) def start_darken_stroke(self, x_norm: float, y_norm: float, stroke_type: str): @@ -6704,6 +7765,10 @@ def finish_darken_stroke(self): # Bump editor revision and refresh preview self.image_editor._edits_rev += 1 + self._clear_active_auto_adjust_state( + "darken stroke committed", + clear_editor=False, + ) self._kick_preview_worker() self._update_darken_overlay() @@ -6715,6 +7780,10 @@ def undo_darken_stroke(self): return mask_data.undo_last_stroke() self.image_editor._edits_rev += 1 + self._clear_active_auto_adjust_state( + "darken stroke undone", + clear_editor=False, + ) self._kick_preview_worker() self._update_darken_overlay() @@ -6726,6 +7795,10 @@ def clear_darken_strokes(self): return mask_data.clear_strokes() self.image_editor._edits_rev += 1 + self._clear_active_auto_adjust_state( + "darken strokes cleared", + clear_editor=False, + ) self._kick_preview_worker() self._update_darken_overlay() @@ -6756,6 +7829,10 @@ def set_darken_param(self, key: str, value: float): setattr(self.ui_state, ui_prop, value) self.image_editor._edits_rev += 1 + self._clear_active_auto_adjust_state( + f"darken param changed '{key}'", + clear_editor=False, + ) self._kick_preview_worker() self._update_darken_overlay() @@ -6768,6 +7845,10 @@ def set_darken_mode(self, mode: str): ds.mode = mode self.ui_state.darkenMode = mode self.image_editor._edits_rev += 1 + self._clear_active_auto_adjust_state( + "darken mode changed", + clear_editor=False, + ) self._kick_preview_worker() self._update_darken_overlay() @@ -7446,7 +8527,7 @@ def stack_source_raws(self): @Slot() def execute_crop(self): - """Execute the crop operation: crop image, save, backup, and refresh.""" + """Commit the crop into the live editor session without saving yet.""" if not self.image_files or self.current_index >= len(self.image_files): self.update_status_message("No image to crop") return @@ -7454,12 +8535,6 @@ def execute_crop(self): if not self.ui_state.isCropping: return - # Capture current rotation (straighten_angle) from editor state BEFORE any reload - # This is the single source of truth since set_straighten_angle updates it live. - current_rotation = float( - self.image_editor.current_edits.get("straighten_angle", 0.0) - ) - crop_box_raw = self.ui_state.currentCropBox # Normalize crop_box_raw to a tuple of 4 ints @@ -7497,19 +8572,11 @@ def execute_crop(self): self.update_status_message("No crop area selected") return - # Restoration means viewing a backup; crop should target the main image. - # We must resolve this BEFORE potentially reloading or saving. - save_target_path = self._get_save_target_path_for_current_view() - is_restoring = save_target_path is not None - - # Ensure image is loaded in editor. - # For crop, we use the CURRENTLY VIEWED file (which might be a variant). if self.view_override_path: filepath = Path(self.view_override_path) else: filepath = self.get_active_edit_path(self.current_index) - # Robust path comparison editor_path = self.image_editor.current_filepath paths_match = False if editor_path: @@ -7518,99 +8585,65 @@ def execute_crop(self): except (OSError, ValueError): paths_match = str(editor_path) == str(filepath) - if not paths_match: + has_buffers = ( + self.image_editor.original_image is not None + and self.image_editor.float_image is not None + ) + if not paths_match or not has_buffers: log.debug( f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}" ) - # get_decoded_image() honors variants/overrides. cached_preview = self.get_decoded_image(self.current_index) if not self.image_editor.load_image( - str(filepath), cached_preview=cached_preview + str(filepath), + cached_preview=cached_preview, + source_exif=self._capture_source_exif_for_active_image(), ): self.update_status_message("Failed to load image for cropping") return + self._ensure_live_edit_session_state(force_reset=True) + else: + self._ensure_live_edit_session_state() - self.image_editor.set_crop_box(crop_box_raw) + current_rotation = float( + self.image_editor.current_edits.get("straighten_angle", 0.0) + ) - # Re-apply the captured rotation. - # This handles cases where we reloaded the image (resetting edits) or where UI state sync was flaky. + self.image_editor.set_crop_box(crop_box_raw) self.image_editor.set_edit_param("straighten_angle", current_rotation) - # Save via ImageEditor (passing the resolved target for variant-save policy) + # Render the cropped preview synchronously and publish it as the + # current loupe image before clearing crop mode. This guarantees the + # QML side re-requests the image URL and the ImageProvider serves the + # cropped preview instead of a stale cached full-size frame. try: - save_result = self.image_editor.save_image( - save_target_path=save_target_path - ) - except RuntimeError as e: - log.warning("execute_crop: Save failed: %s", e) - self.update_status_message(f"Failed to save cropped image: {e}") - return - except Exception as e: - log.exception("execute_crop: Unexpected error during save: %s", e) - self.update_status_message("Failed to save cropped image") - return - - if save_result: - saved_path, backup_path = save_result - metadata_path = self.image_files[self.current_index].path - metadata_before = self._mark_image_edited_in_sidecar( - self.sidecar, metadata_path - ) - - # IF we were restoring from a variant, clear the override now that it's "the truth" - if is_restoring: - self._clear_variant_override() - - timestamp = time.time() - self.undo_history.append( - ( - "crop", - self._build_edit_undo_data( - saved_path, - backup_path, - metadata_path=metadata_path, - metadata_before=metadata_before, - sidecar=self.sidecar, - ), - timestamp, - ) - ) - - # Exit crop mode - self.ui_state.isCropping = False - self.ui_state.currentCropBox = (0, 0, 1000, 1000) - - # Refresh the view - self.refresh_image_list() - - # Find the edited image - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path: - self.current_index = i - break - - # Invalidate cache and refresh display - self._bump_display_generation() - self.image_cache.pop_path(saved_path) - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Reset zoom/pan - self.ui_state.resetZoomPan() - - if self.ui_state.isHistogramVisible: - self.update_histogram() - - self.update_status_message("Image cropped and saved") - log.info("Crop operation completed for %s", saved_path) + decoded = self.image_editor.get_preview_data_cached(allow_compute=True) + except Exception: + log.exception("execute_crop: synchronous preview render failed") + decoded = None - # Force reload of editor to ensure subsequent edits operate on the cropped image - self.image_editor.clear() - self.reset_edit_parameters() + if decoded is not None: + with self._preview_lock: + self._last_rendered_preview = decoded + self.ui_refresh_generation += 1 + self._last_rendered_preview_index = self.current_index + self._last_rendered_preview_gen = self.ui_refresh_generation + self.ui_state.isCropping = False + # Do NOT assign ui_state.currentCropBox here — its setter syncs back + # into image_editor.set_crop_box(), which would overwrite the crop we + # just committed. The crop overlay is already hidden by + # `visible: isCropping` in QML, and re-entering crop mode resets the + # box via toggle_crop_mode -> _reset_crop_only. + self.ui_state.resetZoomPan() + if decoded is not None: + self.ui_state.currentImageSourceChanged.emit() else: - self.update_status_message("Failed to save cropped image") + self._kick_preview_worker() + if self.ui_state.isHistogramVisible: + self.update_histogram() + self.update_status_message("Crop applied", timeout=5000) + log.info("Crop applied to live session for %s", filepath) @Slot() def auto_levels(self): @@ -7620,129 +8653,36 @@ def auto_levels(self): return False t_al_start = time.perf_counter() - - image_file = self.image_files[self.current_index] - filepath = str(image_file.path) - - # Ensure image is loaded in editor - if ( - not self.image_editor.current_filepath - or str(self.image_editor.current_filepath) != filepath - ): - cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image( - filepath, cached_preview=cached_preview - ): - self.update_status_message("Failed to load image") - return False + active_path = self._ensure_active_image_loaded_for_auto_adjust() + if active_path is None: + return False t_al_load = time.perf_counter() - - # Calculate auto levels - now returns (blacks, whites, p_low, p_high) - blacks, whites, p_low, p_high = self.image_editor.auto_levels( - self.auto_level_threshold - ) + recommendation = self._compute_auto_levels_recommendation() t_al_calc = time.perf_counter() - # Auto-strength computation using stretch-factor capping - # - # Philosophy: threshold_percent defines acceptable clipping (e.g., 0.1% at each end). - # Auto-strength should NOT prevent that clipping - it's intentional. - # Instead, auto-strength prevents INSANE levels on low-dynamic-range images. - # - # Approach: Cap the stretch factor to a reasonable maximum (e.g., 3-4x). - # - Full strength: stretch = 255 / (p_high - p_low) - # - If stretch is reasonable (<= cap), use full strength - # - If stretch is extreme (> cap), blend to limit effective stretch to cap - # - if self.auto_level_strength_auto: - # Calculate full-strength stretch factor - dynamic_range = p_high - p_low - if dynamic_range < 1.0: - # Degenerate case: nearly flat image - strength = 0.0 - log.debug( - f"Auto levels: degenerate dynamic range ({dynamic_range:.2f}), strength=0" - ) - else: - stretch_full = 255.0 / dynamic_range - - # Cap stretch to prevent insane levels - # E.g., if image spans only 50-200 (range=150), full stretch would be 255/150 = 1.7x (fine) - # But if image spans 100-110 (range=10), full stretch would be 255/10 = 25.5x (insane!) - STRETCH_CAP = 4.0 # Maximum allowed stretch factor - - if stretch_full <= STRETCH_CAP: - # Reasonable stretch, use full strength - strength = 1.0 - else: - # Excessive stretch - blend to cap it - # effective_stretch = 1 + strength * (stretch_full - 1) = STRETCH_CAP - # solving for strength: strength = (STRETCH_CAP - 1) / (stretch_full - 1) - strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) - strength = max(0.0, min(1.0, strength)) - - log.debug( - f"Auto levels: p_low={p_low:.1f}, p_high={p_high:.1f}, " - f"range={dynamic_range:.1f}, stretch_full={stretch_full:.2f}, strength={strength:.3f}" - ) - else: - strength = self.auto_level_strength - - # Apply strength scaling to blacks and whites parameters - blacks *= strength - whites *= strength - - # Detect no-op before applying: flat image or already full range - dynamic_range = p_high - p_low - if dynamic_range < 1.0: - msg = "Auto levels: no change (flat image)" - self.update_status_message(f"{msg} (preview only)", timeout=9000) - self._last_auto_levels_msg = msg - return False - if p_low <= 0 and p_high >= 255: - msg = "Auto levels: no change (already full range)" - self.update_status_message(f"{msg} (preview only)", timeout=9000) - self._last_auto_levels_msg = msg - return False - - # Apply scaled values - self.image_editor.set_edit_param("blacks", blacks) - self.image_editor.set_edit_param("whites", whites) - - # Update UI state - self.ui_state.blacks = blacks - self.ui_state.whites = whites - - # Trigger preview update - self.ui_state.currentImageSourceChanged.emit() - - if self.ui_state.isHistogramVisible: - self.update_histogram() - - # Build detail message - if p_high >= 255.0: - msg = ( - f"Auto levels: highlights clipped; shadows only (blacks {blacks:+.1f})" - ) - elif p_low <= 0.0: - msg = ( - f"Auto levels: shadows clipped; highlights only (whites {whites:+.1f})" - ) - else: - gain = 255.0 / dynamic_range - msg = ( - f"Auto levels: blacks {blacks:+.1f}, whites {whites:+.1f} " - f"(range {p_low:.0f}\u2013{p_high:.0f}, gain {gain:.2f})" - ) + blacks = recommendation["base_blacks"] + whites = recommendation["base_whites"] + msg = self._format_auto_levels_detail( + p_low=recommendation["p_low"], + p_high=recommendation["p_high"], + blacks=blacks, + whites=whites, + ) + changed = self._apply_levels_to_editor( + blacks=blacks, + whites=whites, + kick_preview=True, + ) - self._kick_preview_worker() + if not changed and recommendation["noop_reason"]: + msg = f"Auto levels: no change ({recommendation['noop_reason']})" self.update_status_message(f"{msg} (preview only)", timeout=9000) log.info( "Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s", - filepath, + active_path, self.auto_level_threshold, - strength, + recommendation["strength"], msg, ) t_al_end = time.perf_counter() @@ -7752,128 +8692,168 @@ def auto_levels(self): int((t_al_calc - t_al_load) * 1000), int((t_al_end - t_al_calc) * 1000), int((t_al_end - t_al_start) * 1000), - filepath, + active_path, ) # Store detail message for quick_auto_levels to pick up self._last_auto_levels_msg = msg + return changed + + def _seed_active_auto_adjust_state(self) -> Optional[ActiveAutoAdjustState]: + """Create transient auto-adjust state from the current loaded image.""" + recommendation = self._compute_auto_levels_recommendation() + state = self._build_active_auto_adjust_state(recommendation) + self._active_auto_adjust_state = state + return state + + def _apply_and_save_active_auto_adjust( + self, + *, + action_type: str, + force_save: bool = False, + ) -> bool: + """Apply the transient auto-adjust state to edits and save once. + + When ``force_save`` is True, always save even if the editor already + reflects the target levels. This is the path used by the debounced + '-' / '=' flow, where the preview step has already applied the edits + to the editor before the save fires. + """ + state = self._active_auto_adjust_state + if state is None: + return False + + blacks, whites = self._derive_auto_adjust_levels(state) + changed = self._apply_levels_to_editor( + blacks=blacks, + whites=whites, + kick_preview=False, + ) + detail = self._format_auto_levels_detail( + p_low=state.p_low, + p_high=state.p_high, + blacks=blacks, + whites=whites, + extra_highlight_steps=state.extra_highlight_steps, + extra_black_steps=state.extra_black_steps, + ) + self._last_auto_levels_msg = detail + + if not changed and not force_save: + self.update_status_message(detail, timeout=9000) + return False + + if not self._save_current_auto_adjust( + action_type=action_type, detail_msg=detail + ): + self._clear_active_auto_adjust_state( + "auto-adjust save failed", + clear_editor=True, + ) + return False return True @Slot() def quick_auto_levels(self): - """Applies auto levels and immediately saves (with undo).""" + """Apply auto levels to the live session without saving yet.""" if not self.image_files: self.update_status_message("No image to adjust") return - t_start = time.perf_counter() - - # Pre-load with preview_only for uint8 fast path (skips float32 conversion) - image_file = self.image_files[self.current_index] - filepath = str(image_file.path) - if ( - not self.image_editor.current_filepath - or str(self.image_editor.current_filepath) != filepath - ): - cached_preview = self.get_decoded_image(self.current_index) - self.image_editor.load_image( - filepath, cached_preview=cached_preview, preview_only=True - ) - - # Apply the preview first (loads image + sets params) - self._last_auto_levels_msg = "" - applied = self.auto_levels() - t_compute = time.perf_counter() + self._clear_active_auto_adjust_state( + "quick auto levels starts a fresh active auto-adjust state", + clear_editor=False, + ) + if self._ensure_active_image_loaded_for_auto_adjust() is None: + return - # If in auto mode and no changes were made (skipped), don't save - if self.auto_level_strength_auto and not applied: - # Status message already set by auto_levels ("No changes made...") + state = self._seed_active_auto_adjust_state() + if state is None: + self.update_status_message("Auto levels failed") return - try: - # Determine save_target_path for variant saves (Policy A) - save_target_path = self._get_save_target_path_for_current_view() + self._apply_auto_adjust_preview(state) - # Try uint8 fast path first, fall back to regular save - save_result = self.image_editor.save_image_uint8_levels( - save_target_path=save_target_path - ) - if save_result is None: - save_result = self.image_editor.save_image( - save_target_path=save_target_path - ) - except RuntimeError as e: - log.warning("quick_auto_levels: Save failed: %s", e) - self.update_status_message(f"Failed to save image: {e}") + @Slot() + def quick_auto_adjust(self): + """Apply AWB and auto-levels to the live session without saving yet.""" + if not self.image_files: + self.update_status_message("No image to adjust") return - except Exception as e: - log.exception("quick_auto_levels: Unexpected error during save: %s", e) - self.update_status_message("Failed to save image") + + self._clear_active_auto_adjust_state( + "combined auto-adjust starts a fresh active auto-adjust state", + clear_editor=False, + ) + if self._ensure_active_image_loaded_for_auto_adjust() is None: return - t_save = time.perf_counter() - if save_result: - saved_path, backup_path = save_result - metadata_path = self.image_files[self.current_index].path - metadata_before = self._mark_image_edited_in_sidecar( - self.sidecar, metadata_path - ) - timestamp = time.time() - self.undo_history.append( - ( - "auto_levels", - self._build_edit_undo_data( - saved_path, - backup_path, - metadata_path=metadata_path, - metadata_before=metadata_before, - sidecar=self.sidecar, - ), - timestamp, - ) - ) + # auto_white_balance() is preview-only here: it mutates the in-memory + # editor session and status text, but does not save or append undo. + awb_msg = self.auto_white_balance() + state = self._seed_active_auto_adjust_state() + if state is None: + self.update_status_message("Auto adjust failed") + return - # 2. Update list and model to pick up changes - self.refresh_image_list() + self._apply_auto_adjust_preview(state) + levels_msg = self._last_auto_levels_msg + self._last_auto_levels_msg = levels_msg - # Force reload to ensure disk consistency - self.image_editor.clear() + detail_parts = [msg for msg in (awb_msg, levels_msg) if msg] + if detail_parts: + self.update_status_message( + "; ".join(detail_parts), + timeout=9000, + ) - # Re-derive current_index (backup is excluded from visible list) - self._reindex_after_save(saved_path) - t_list = time.perf_counter() + def _ensure_or_seed_active_auto_adjust_state( + self, + ) -> Optional[ActiveAutoAdjustState]: + """Reuse the live transient state or create a fresh one from current pixels.""" + if self._has_valid_active_auto_adjust_state(): + return self._active_auto_adjust_state + if self._ensure_active_image_loaded_for_auto_adjust() is None: + return None + return self._seed_active_auto_adjust_state() - self._bump_display_generation() - self.image_cache.pop_path(saved_path) - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() + @Slot() + def reduce_auto_adjust_highlights(self): + """Darken the highlight side by one fixed step in the live session.""" + state = self._ensure_or_seed_active_auto_adjust_state() + if state is None: + return - if self.ui_state.isHistogramVisible: - self.update_histogram() + state.extra_highlight_steps += 1 + self._apply_auto_adjust_preview(state) - t_total = time.perf_counter() - total_ms = int((t_total - t_start) * 1000) - log.debug( - "[AUTO_LEVEL] quick: compute=%dms save=%dms list=%dms total=%dms", - int((t_compute - t_start) * 1000), - int((t_save - t_compute) * 1000), - int((t_list - t_save) * 1000), - total_ms, - ) - detail = self._last_auto_levels_msg - saved_msg = ( - f"{detail} \u2014 saved ({total_ms} ms)" - if detail - else f"Auto levels applied and saved ({total_ms} ms)" - ) - self.update_status_message(saved_msg, timeout=9000) - log.info( - "Quick auto levels saved for %s. New index: %d", - saved_path, - self.current_index, - ) - else: - self.update_status_message("Failed to save image") + @Slot() + def deepen_auto_adjust_blacks(self): + """Deepen the shadow side by one fixed step in the live session.""" + state = self._ensure_or_seed_active_auto_adjust_state() + if state is None: + return + + state.extra_black_steps += 1 + self._apply_auto_adjust_preview(state) + + def _apply_auto_adjust_preview(self, state: ActiveAutoAdjustState) -> None: + """Render the current auto-adjust state into the editor/UI immediately.""" + blacks, whites = self._derive_auto_adjust_levels(state) + self._apply_levels_to_editor( + blacks=blacks, + whites=whites, + kick_preview=True, + ) + detail = self._format_auto_levels_detail( + p_low=state.p_low, + p_high=state.p_high, + blacks=blacks, + whites=whites, + extra_highlight_steps=state.extra_highlight_steps, + extra_black_steps=state.extra_black_steps, + ) + self._last_auto_levels_msg = detail + self.update_status_message(detail, timeout=9000) def _apply_auto_levels_at_index(self, index: int) -> bool: """Apply auto levels and save for image at the given index. @@ -7905,7 +8885,7 @@ def _apply_auto_levels_at_index(self, index: int) -> bool: self._last_auto_levels_msg = "" applied = self.auto_levels() - if self.auto_level_strength_auto and not applied: + if not applied: return False try: @@ -7942,6 +8922,10 @@ def _apply_auto_levels_at_index(self, index: int) -> bool: timestamp, ) ) + self._clear_active_auto_adjust_state( + "batch auto levels saved a different image", + clear_editor=False, + ) self.image_editor.clear() self.image_cache.pop_path(saved_path) return True @@ -7962,6 +8946,11 @@ def batch_auto_levels(self): self.update_status_message("No images in batch.") return + self._clear_active_auto_adjust_state( + "batch auto levels replaces the current transient auto-adjust session", + clear_editor=True, + ) + self._batch_al_indices = batch_indices self._batch_al_pos = 0 self._batch_al_processed = 0 @@ -8034,127 +9023,18 @@ def _batch_auto_levels_done(self): @Slot() def quick_auto_white_balance(self): - """Quickly apply auto white balance, save the image, and track for undo.""" + """Quickly apply auto white balance to the live session without saving yet.""" if not self.image_files: self.update_status_message("No image to adjust") return - t_start = time.perf_counter() - - if self.view_override_path: - active_path = Path(self.view_override_path) - else: - active_path = self.get_active_edit_path(self.current_index) - filepath = str(active_path) - - # Ensure image is loaded in editor (skip if already loaded) - editor_path = self.image_editor.current_filepath - paths_match = False - if editor_path: - try: - paths_match = Path(editor_path).resolve() == active_path.resolve() - except (OSError, ValueError): - paths_match = str(editor_path) == filepath - - if not paths_match: - cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image( - filepath, cached_preview=cached_preview, preview_only=True - ): - self.update_status_message("Failed to load image") - return - t_load = time.perf_counter() - - # Calculate and apply auto white balance - # Returns detail string if applied, None if no change - detail_msg = self.auto_white_balance() - t_compute = time.perf_counter() - - # If no correction was needed, skip saving - if not detail_msg: - # Status message already set by auto_white_balance() - return - - # Save the edited image (this creates a backup automatically) - try: - save_target_path = self._get_save_target_path_for_current_view() - save_result = self.image_editor.save_image_uint8_white_balance( - save_target_path=save_target_path - ) - if save_result is None: - save_result = self.image_editor.save_image( - save_target_path=save_target_path - ) - except RuntimeError as e: - log.warning("quick_auto_white_balance: Save failed: %s", e) - self.update_status_message(f"Failed to save image: {e}") - return - except Exception as e: - log.exception( - "quick_auto_white_balance: Unexpected error during save: %s", e - ) - self.update_status_message("Failed to save image") + self._clear_active_auto_adjust_state( + "quick auto white balance starts a fresh live baseline", + clear_editor=False, + ) + if self._ensure_active_image_loaded_for_auto_adjust() is None: return - t_save = time.perf_counter() - - if save_result: - saved_path, backup_path = save_result - timestamp = time.time() - metadata_path = self.image_files[self.current_index].path - metadata_before = self._mark_image_edited_in_sidecar( - self.sidecar, metadata_path - ) - - # 2. Update list and model to pick up changes - self.refresh_image_list() - - # Re-derive current_index - self._reindex_after_save(saved_path) - - self.undo_history.append( - ( - "auto_white_balance", - self._build_edit_undo_data( - saved_path, - backup_path, - metadata_path=metadata_path, - metadata_before=metadata_before, - sidecar=self.sidecar, - ), - timestamp, - ) - ) - - # Force the image editor to clear its current state so it reloads fresh - self.image_editor.clear() - - t_list = time.perf_counter() - - # Invalidate cache for the edited image so it's reloaded from disk - self._bump_display_generation() - self.image_cache.pop_path(saved_path) - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() - - t_total = time.perf_counter() - total_ms = int((t_total - t_start) * 1000) - log.debug( - "[AUTO_COLOR] quick: load=%dms compute=%dms save=%dms list=%dms total=%dms", - int((t_load - t_start) * 1000), - int((t_compute - t_load) * 1000), - int((t_save - t_compute) * 1000), - int((t_list - t_save) * 1000), - total_ms, - ) - self.update_status_message(f"{detail_msg} \u2014 saved ({total_ms} ms)") - log.info("Quick auto white balance applied to %s", filepath) - else: - self.update_status_message("Failed to save image") + self.auto_white_balance() @Slot() def auto_white_balance(self) -> Optional[str]: diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index e4d6f60..7912b9e 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -39,6 +39,23 @@ log = logging.getLogger(__name__) +_REPLACE_RETRY_DELAY = 0.3 +_REPLACE_MAX_RETRIES = 3 + + +def _safe_replace(tmp_path: Path, target_path: Path) -> None: + """Atomically replace target with tmp, retrying on Windows file-lock errors.""" + for attempt in range(_REPLACE_MAX_RETRIES): + try: + os.replace(str(tmp_path), str(target_path)) + return + except OSError: + if attempt < _REPLACE_MAX_RETRIES - 1: + time.sleep(_REPLACE_RETRY_DELAY) + else: + raise + + # Aspect Ratios for cropping INSTAGRAM_RATIOS = { "Freeform": None, @@ -1394,14 +1411,38 @@ def auto_levels( Returns (blacks, whites, p_low, p_high). p_low/p_high are computed conservatively from RGB to avoid introducing new channel clipping. """ + blacks, whites, p_low, p_high = self.analyze_auto_levels( + threshold_percent, + reset_levels=True, + ) + + with self._lock: + self.current_edits["blacks"] = blacks + self.current_edits["whites"] = whites + self._edits_rev += 1 + return blacks, whites, float(p_low), float(p_high) + + def analyze_auto_levels( + self, + threshold_percent: float = 0.1, + *, + edits: Optional[Dict[str, Any]] = None, + reset_levels: bool = True, + ) -> Tuple[float, float, float, float]: + """Analyze auto-levels on the current edited baseline without mutating edits.""" _debug = log.isEnabledFor(logging.DEBUG) if _debug: t0 = time.perf_counter() + threshold_percent = max(0.0, min(10.0, threshold_percent)) - # Use preview for speed - img_arr = ( - self.float_preview if self.float_preview is not None else self.float_image - ) + + with self._lock: + img_arr = ( + self.float_preview.copy() + if self.float_preview is not None + else (self.float_image.copy() if self.float_image is not None else None) + ) + edits_snapshot = dict(self.current_edits) if edits is None else dict(edits) if img_arr is None: # Fallback for tests or cases where float data isn't initialized yet @@ -1413,12 +1454,19 @@ def auto_levels( else: return 0.0, 0.0, 0.0, 255.0 - # Convert to uint8 (0-255) for histogram analysis - # This preserves the logic of the original algorithm which was tuned for 0-255 bins + if reset_levels: + edits_snapshot["blacks"] = 0.0 + edits_snapshot["whites"] = 0.0 + + # Render the current edited baseline first so auto-levels sees any + # already-active adjustments such as WB, crop, rotation, or tone edits. if _debug: t_arr = time.perf_counter() - rgb = (np.clip(img_arr, 0.0, 1.0) * 255).astype(np.uint8) - # rgb shape: (H, W, 3) + edited_arr = self._apply_edits(img_arr, edits=edits_snapshot, for_export=False) + + # Convert to uint8 (0-255) for histogram analysis + # This preserves the logic of the original algorithm which was tuned for 0-255 bins + rgb = (np.clip(edited_arr, 0.0, 1.0) * 255).astype(np.uint8) if _debug: t_u8 = time.perf_counter() @@ -1450,10 +1498,6 @@ def auto_levels( p_low = min(p_lows) p_high = max(p_highs) - # NOTE: applying this stretch uniformly to RGB can clip individual channels - # more than luminance predicts. That's usually acceptable, but if we - # ever see weird color clipping, that might be why. - # Pin ends if pre-clipping exists (prevents making it worse) if max(clipped_high_pct) > eps_pct: p_high = 255.0 @@ -1472,16 +1516,11 @@ def auto_levels( blacks = -p_low / 40.0 whites = (255.0 - p_high) / 40.0 - with self._lock: - self.current_edits["blacks"] = blacks - self.current_edits["whites"] = whites - self._edits_rev += 1 - if _debug: t_end = time.perf_counter() h, w = rgb.shape[:2] log.debug( - "[AUTO_LEVEL] get_array=%dms to_uint8=%dms hist+clip=%dms total=%dms (%dx%d, %s)", + "[AUTO_LEVEL] get_array=%dms render=%dms hist+clip=%dms total=%dms (%dx%d, %s)", int((t_arr - t0) * 1000), int((t_u8 - t_arr) * 1000), int((t_end - t_u8) * 1000), @@ -1490,6 +1529,7 @@ def auto_levels( h, "preview" if self.float_preview is not None else "full", ) + return blacks, whites, float(p_low), float(p_high) @staticmethod @@ -2307,7 +2347,15 @@ def save_from_snapshot( is_tiff = original_path.suffix.lower() in [".tif", ".tiff"] if is_tiff: - self._write_tiff_16bit(original_path, final_float) + tmp_path = original_path.with_name( + f".{original_path.stem}_{uuid.uuid4().hex[:8]}{original_path.suffix}" + ) + try: + self._write_tiff_16bit(tmp_path, final_float) + _safe_replace(tmp_path, original_path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise else: arr_u8 = (np.clip(final_float, 0.0, 1.0) * 255).astype(np.uint8) img_u8 = Image.fromarray(arr_u8, mode="RGB") @@ -2316,10 +2364,18 @@ def save_from_snapshot( if main_exif: save_kwargs["exif"] = main_exif + tmp_path = original_path.with_name( + f".{original_path.stem}_{uuid.uuid4().hex[:8]}{original_path.suffix}" + ) try: - img_u8.save(original_path, **save_kwargs) - except Exception: - img_u8.save(original_path) + try: + img_u8.save(tmp_path, **save_kwargs) + except Exception: + img_u8.save(tmp_path) + _safe_replace(tmp_path, original_path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise if original_stat is not None: self._restore_file_times(original_path, original_stat) @@ -2349,10 +2405,18 @@ def save_from_snapshot( if exif_bytes: dev_kwargs["exif"] = exif_bytes + tmp_dev = developed_path.with_name( + f".{developed_path.stem}_{uuid.uuid4().hex[:8]}{developed_path.suffix}" + ) try: - img_u8.save(developed_path, **dev_kwargs) - except Exception: - img_u8.save(developed_path) + try: + img_u8.save(tmp_dev, **dev_kwargs) + except Exception: + img_u8.save(tmp_dev) + _safe_replace(tmp_dev, developed_path) + except BaseException: + tmp_dev.unlink(missing_ok=True) + raise if _debug: t_write = time.perf_counter() diff --git a/faststack/imaging/jpeg.py b/faststack/imaging/jpeg.py index 09392a8..c50ef6c 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -1,8 +1,10 @@ """High-performance JPEG decoding using PyTurboJPEG with a Pillow fallback.""" import logging +import time +import warnings from io import BytesIO -from typing import Optional, Tuple +from typing import Any, Optional, Tuple import numpy as np from PIL import Image @@ -13,21 +15,66 @@ JPEG_DECODER, TURBO_AVAILABLE = create_turbojpeg() +_PREMATURE_EOF_RETRY_DELAY = 0.15 -def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.ndarray]: + +def _decode_with_retry( + jpeg_bytes: bytes, + *, + source_path: Optional[str] = None, + decoder: Any = None, + **decode_kwargs: Any, +) -> Optional[np.ndarray]: + """Call decoder.decode() with a single retry on 'Premature end of JPEG file'. + + TurboJPEG emits this as a Python warning (not an exception) when the + file is truncated. We treat it as a soft/retryable condition — the + file may still be written by another process — and retry once after + a short delay. + """ + dec = decoder or JPEG_DECODER + for attempt in range(2): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = dec.decode(jpeg_bytes, **decode_kwargs) + + premature = any("Premature end of JPEG file" in str(w.message) for w in caught) + + if not premature: + return result + + if attempt == 0: + time.sleep(_PREMATURE_EOF_RETRY_DELAY) + continue + + label = source_path or "" + log.warning( + "TurboJPEG: 'Premature end of JPEG file' for %s " + "(retry also warned — file may be truncated)", + label, + ) + return result + + +def decode_jpeg_rgb( + jpeg_bytes: bytes, + fast_dct: bool = False, + source_path: Optional[str] = None, +) -> Optional[np.ndarray]: """Decodes JPEG bytes into an RGB numpy array.""" if TURBO_AVAILABLE and JPEG_DECODER: try: - # Decode with proper color space handling (no TJFLAG_FASTDCT) - # This ensures proper YCbCr->RGB conversion with correct gamma flags = 0 if fast_dct: - # TJFLAG_FASTDCT = 2048 flags |= 2048 - return JPEG_DECODER.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=flags) + return _decode_with_retry( + jpeg_bytes, + source_path=source_path, + pixel_format=TJPF_RGB, + flags=flags, + ) except Exception as e: log.exception("PyTurboJPEG failed to decode image: %s. Trying Pillow.", e) - # Fall through to Pillow fallback # Fallback to Pillow try: @@ -39,22 +86,22 @@ def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.nd def decode_jpeg_thumb_rgb( - jpeg_bytes: bytes, max_dim: int = 256 + jpeg_bytes: bytes, + max_dim: int = 256, + source_path: Optional[str] = None, ) -> Optional[np.ndarray]: """Decodes a JPEG into a thumbnail-sized RGB numpy array.""" if TURBO_AVAILABLE and JPEG_DECODER: try: - # Get image header to determine dimensions width, height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes) - - # Find the best scaling factor scaling_factor = _get_turbojpeg_scaling_factor(width, height, max_dim) - decoded = JPEG_DECODER.decode( + decoded = _decode_with_retry( jpeg_bytes, + source_path=source_path, scaling_factor=scaling_factor, pixel_format=TJPF_RGB, - flags=0, # Proper color space handling + flags=0, ) if decoded.shape[0] > max_dim or decoded.shape[1] > max_dim: img = Image.fromarray(decoded) @@ -99,23 +146,23 @@ def _get_turbojpeg_scaling_factor( def decode_jpeg_resized( - jpeg_bytes: bytes, width: int, height: int, fast_dct: bool = False + jpeg_bytes: bytes, + width: int, + height: int, + fast_dct: bool = False, + source_path: Optional[str] = None, ) -> Optional[np.ndarray]: """Decodes and resizes a JPEG to fit within the given dimensions.""" if width <= 0 or height <= 0: - return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct) + return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct, source_path=source_path) if TURBO_AVAILABLE and JPEG_DECODER: try: - # Get image header to determine dimensions img_width, img_height, _, _ = JPEG_DECODER.decode_header(jpeg_bytes) - # Determine which dimension is the limiting factor if img_width * height > img_height * width: - # Image is wider relative to target box; width is the constraint max_dim = width else: - # Image is taller relative to target box; height is the constraint max_dim = height scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) @@ -123,14 +170,14 @@ def decode_jpeg_resized( if scale_factor: flags = 0 if fast_dct: - # TJFLAG_FASTDCT = 2048 flags |= 2048 - decoded = JPEG_DECODER.decode( + decoded = _decode_with_retry( jpeg_bytes, + source_path=source_path, scaling_factor=scale_factor, pixel_format=TJPF_RGB, - flags=flags, # Proper color space handling + flags=flags, ) # Only use Pillow for final resize if needed diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index a0bbe2a..e38862f 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -555,10 +555,13 @@ def _decode_and_cache( display_width, display_height, fast_dct=fast_dct, + source_path=str(target_path), ) else: buffer = decode_jpeg_rgb( - mmapped, fast_dct=fast_dct + mmapped, + fast_dct=fast_dct, + source_path=str(target_path), ) if buffer is not None and should_resize: img = PILImage.fromarray(buffer) @@ -673,9 +676,14 @@ def _decode_and_cache( display_width, display_height, fast_dct=fast_dct, + source_path=str(target_path), ) else: - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + buffer = decode_jpeg_rgb( + mmapped, + fast_dct=fast_dct, + source_path=str(target_path), + ) if buffer is not None and should_resize: img = PILImage.fromarray(buffer) img.thumbnail( diff --git a/faststack/io/indexer.py b/faststack/io/indexer.py index 2deeedb..90c6ff1 100644 --- a/faststack/io/indexer.py +++ b/faststack/io/indexer.py @@ -289,14 +289,10 @@ def _find_raw_pair( if not potential_raws: return None - # Find the RAW file with the closest modification time within a 2-second window - best_match: Path | None = None - min_dt = 2.0 # seconds + if len(potential_raws) == 1: + return potential_raws[0][0] - for raw_path, raw_stat in potential_raws: - dt = abs(jpg_stat.st_mtime - raw_stat.st_mtime) - if dt <= min_dt: - min_dt = dt - best_match = raw_path - - return best_match + return min( + potential_raws, + key=lambda candidate: abs(jpg_stat.st_mtime - candidate[1].st_mtime), + )[0] diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 330ef32..ee95120 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -65,17 +65,21 @@ ApplicationWindow { } onClosing: function(close) { - if (root.allowCloseWithRecycleBins) { - close.accepted = true - return - } - if (root.uiStateRef && root.uiStateRef.hasRecycleBinItems) { + if (!root.allowCloseWithRecycleBins + && root.uiStateRef + && root.uiStateRef.hasRecycleBinItems) { close.accepted = false root.uiStateRef.refreshRecycleBinStats() recycleBinCleanupDialog.open() - } else { - close.accepted = true + return } + + if (root.controllerRef && !root.controllerRef.prepare_for_app_close()) { + close.accepted = false + return + } + + close.accepted = true } Component.onCompleted: { @@ -920,6 +924,16 @@ ApplicationWindow { actionsMenu.close() } } + MenuActionItem { + width: 220 + text: "Add Edited to Batch" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + onClicked: { + if (root.uiStateRef) root.uiStateRef.addEditedToBatch() + actionsMenu.close() + } + } MenuActionItem { width: 220 text: "Jump to Last Uploaded" @@ -1684,16 +1698,19 @@ ApplicationWindow { "  Ctrl+S: Toggle stacked flag

" + "File Management:
" + "  Delete/Backspace: Move current image to recycle bin
" + - "  Ctrl+Z: Undo last action

" + + "  Ctrl+Z: Undo last saved action

" + "Image Editing:
" + "  E: Toggle Image Editor
" + - "  Ctrl+S (in editor): Save edited image
" + - "  A: Quick auto white balance
" + - "  L: Quick auto levels
" + + "  Ctrl+S (in editor): Save current live edits
" + + "  A: Quick auto white balance (live)
" + + "  l: Quick auto levels (live)
" + + "  L: Quick auto white balance + auto levels (live)
" + + "  -: Darken current auto-adjust highlights/whites (live)
" + + "  =: Deepen current auto-adjust shadows/background (live)
" + "  K: Background Darkening Tool
" + "  O (or right-click): Toggle crop mode
" + "    1/2/3/4: Set aspect ratio (1:1, 4:3, 3:2, 16:9)
" + - "    Enter: Execute crop
" + + "    Enter: Apply crop to live session
" + "    Esc: Cancel crop

" + "Other Actions:
" + "  Enter: Launch Helicon Focus
" + diff --git a/faststack/tests/test_auto_adjust_regressions.py b/faststack/tests/test_auto_adjust_regressions.py new file mode 100644 index 0000000..215b891 --- /dev/null +++ b/faststack/tests/test_auto_adjust_regressions.py @@ -0,0 +1,333 @@ +import ast +import importlib.util +import sys +import textwrap +import types +from enum import IntFlag +from pathlib import Path +from types import SimpleNamespace +from typing import Optional +from unittest.mock import Mock + +REPO_ROOT = Path(__file__).resolve().parents[2] +APP_PATH = REPO_ROOT / "faststack" / "app.py" +KEYSTROKES_PATH = REPO_ROOT / "faststack" / "ui" / "keystrokes.py" +APP_SOURCE = APP_PATH.read_text(encoding="utf-8") +APP_AST = ast.parse(APP_SOURCE) + + +def _extract_app_method(method_name: str): + for node in APP_AST.body: + if isinstance(node, ast.ClassDef) and node.name == "AppController": + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.name == method_name: + namespace = { + "Optional": Optional, + "Path": Path, + } + exec( + textwrap.dedent(ast.get_source_segment(APP_SOURCE, item)), + namespace, + ) + return namespace[method_name] + raise AssertionError(f"Method not found: AppController.{method_name}") + + +def _extract_app_method_source(method_name: str) -> str: + for node in APP_AST.body: + if isinstance(node, ast.ClassDef) and node.name == "AppController": + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.name == method_name: + source = ast.get_source_segment(APP_SOURCE, item) + assert source is not None + return textwrap.dedent(source) + raise AssertionError(f"Method not found: AppController.{method_name}") + + +class _Qt(IntFlag): + NoModifier = 0 + ShiftModifier = 1 + ControlModifier = 2 + AltModifier = 4 + MetaModifier = 8 + Key_L = 100 + Key_Minus = 101 + Key_Equal = 102 + Key_Escape = 103 + Key_Right = 104 + Key_Left = 105 + Key_G = 106 + Key_BracketLeft = 107 + Key_BracketRight = 108 + Key_S = 109 + Key_BraceLeft = 110 + Key_BraceRight = 111 + Key_Backslash = 112 + Key_B = 113 + Key_X = 114 + Key_U = 115 + Key_F = 116 + Key_D = 117 + Key_I = 118 + Key_Enter = 119 + Key_Return = 120 + Key_P = 121 + Key_C = 122 + Key_A = 123 + Key_O = 124 + Key_H = 125 + Key_Delete = 126 + Key_Backspace = 127 + Key_Z = 128 + Key_E = 129 + Key_0 = 130 + Key_1 = 131 + Key_2 = 132 + Key_3 = 133 + Key_4 = 134 + + +def _load_keybinder_class(): + qtcore = types.ModuleType("PySide6.QtCore") + qtcore.Qt = _Qt + pyside6 = types.ModuleType("PySide6") + pyside6.QtCore = qtcore + sys.modules["PySide6"] = pyside6 + sys.modules["PySide6.QtCore"] = qtcore + + spec = importlib.util.spec_from_file_location( + "faststack_test_keystrokes", + KEYSTROKES_PATH, + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module.Keybinder + + +class _Event: + def __init__(self, key, text, modifiers): + self._key = key + self._text = text + self._modifiers = modifiers + + def key(self): + return self._key + + def text(self): + return self._text + + def modifiers(self): + return self._modifiers + + +class _Controller: + def __init__(self): + self.calls = [] + self.main_window = None + + def quick_auto_levels(self): + self.calls.append("quick_auto_levels") + + def quick_auto_adjust(self): + self.calls.append("quick_auto_adjust") + + def reduce_auto_adjust_highlights(self): + self.calls.append("reduce_auto_adjust_highlights") + + def deepen_auto_adjust_blacks(self): + self.calls.append("deepen_auto_adjust_blacks") + + +def test_clear_active_auto_adjust_state_clears_editor_even_if_editor_ui_is_open(): + clear_active_auto_adjust_state = _extract_app_method( + "_clear_active_auto_adjust_state" + ) + controller = SimpleNamespace( + _cancel_pending_auto_adjust_save=Mock(), + _active_auto_adjust_state=object(), + ui_state=SimpleNamespace(isEditorOpen=True), + image_editor=SimpleNamespace(clear=Mock()), + ) + + clear_active_auto_adjust_state(controller, clear_editor=True) + + controller._cancel_pending_auto_adjust_save.assert_called_once_with() + controller.image_editor.clear.assert_called_once_with() + assert controller._active_auto_adjust_state is None + + +def test_undo_flushes_pending_auto_adjust_save_before_reporting_nothing_to_undo(): + undo_delete = _extract_app_method("undo_delete") + controller = SimpleNamespace( + _auto_adjust_save_pending_action="auto_adjust", + undo_history=[], + update_status_message=Mock(), + _parse_edit_undo_data=lambda data: data, + _restore_backup_safe=lambda saved_path, backup_path: True, + _clear_active_auto_adjust_state=Mock(), + _restore_metadata_snapshot=Mock(), + _post_undo_refresh_and_select=Mock(), + sidecar=object(), + ) + + def flush_pending(): + controller.undo_history.append( + ( + "auto_adjust", + ("saved.jpg", "backup.jpg", "saved.jpg", None, None), + 123.0, + ) + ) + + controller._flush_pending_auto_adjust_save = Mock(side_effect=flush_pending) + + undo_delete(controller) + + controller._flush_pending_auto_adjust_save.assert_called_once_with() + messages = [ + call.args[0] for call in controller.update_status_message.call_args_list + ] + assert "Nothing to undo." not in messages + assert messages[-1] == "Undid auto adjust" + + +def test_quick_auto_levels_stays_preview_only(): + quick_auto_levels = _extract_app_method("quick_auto_levels") + controller = SimpleNamespace( + image_files=[object()], + update_status_message=Mock(), + _clear_active_auto_adjust_state=Mock(), + _ensure_active_image_loaded_for_auto_adjust=Mock( + return_value=Path("image.jpg") + ), + _seed_active_auto_adjust_state=Mock(return_value=object()), + _apply_auto_adjust_preview=Mock(), + ) + + quick_auto_levels(controller) + + controller._clear_active_auto_adjust_state.assert_called_once_with( + "quick auto levels starts a fresh active auto-adjust state", + clear_editor=False, + ) + controller._apply_auto_adjust_preview.assert_called_once() + + +def test_quick_auto_adjust_stays_preview_only(): + quick_auto_adjust = _extract_app_method("quick_auto_adjust") + controller = SimpleNamespace( + image_files=[object()], + _last_auto_levels_msg="", + update_status_message=Mock(), + _clear_active_auto_adjust_state=Mock(), + _ensure_active_image_loaded_for_auto_adjust=Mock( + return_value=Path("image.jpg") + ), + auto_white_balance=Mock(return_value="awb"), + _seed_active_auto_adjust_state=Mock(return_value=object()), + ) + + def apply_preview(_state): + controller._last_auto_levels_msg = "levels" + + controller._apply_auto_adjust_preview = Mock(side_effect=apply_preview) + + quick_auto_adjust(controller) + + controller._clear_active_auto_adjust_state.assert_called_once_with( + "combined auto-adjust starts a fresh active auto-adjust state", + clear_editor=False, + ) + controller.auto_white_balance.assert_called_once_with() + controller._apply_auto_adjust_preview.assert_called_once() + controller.update_status_message.assert_called_with("awb; levels", timeout=9000) + + +def test_quick_auto_white_balance_stays_preview_only(): + quick_auto_white_balance = _extract_app_method("quick_auto_white_balance") + controller = SimpleNamespace( + image_files=[object()], + update_status_message=Mock(), + _clear_active_auto_adjust_state=Mock(), + _ensure_active_image_loaded_for_auto_adjust=Mock( + return_value=Path("image.jpg") + ), + auto_white_balance=Mock(return_value="awb"), + ) + + quick_auto_white_balance(controller) + + controller._clear_active_auto_adjust_state.assert_called_once_with( + "quick auto white balance starts a fresh live baseline", + clear_editor=False, + ) + controller._ensure_active_image_loaded_for_auto_adjust.assert_called_once_with() + controller.auto_white_balance.assert_called_once_with() + + +def test_execute_crop_source_no_longer_saves_or_pushes_undo(): + source = _extract_app_method_source("execute_crop") + + assert ".save_image(" not in source + assert "undo_history.append" not in source + assert "_build_edit_undo_data" not in source + + +def test_navigation_flushes_live_session_before_switching_index(): + source = _extract_app_method_source("_set_current_index") + + assert "_flush_current_live_edit_session_for_navigation()" in source + assert source.index( + "_flush_current_live_edit_session_for_navigation()" + ) < source.index("self.current_index = index") + + +def test_drag_path_flushes_live_session_before_drag(): + source = _extract_app_method_source("start_drag_current_image") + + assert "_flush_current_live_edit_session_for_drag()" in source + + +def test_caps_lock_style_uppercase_l_without_shift_still_runs_quick_auto_levels(): + keybinder_cls = _load_keybinder_class() + controller = _Controller() + keybinder = keybinder_cls(controller) + + handled = keybinder.handle_key_press(_Event(_Qt.Key_L, "L", _Qt.NoModifier)) + + assert handled is True + assert controller.calls == ["quick_auto_levels"] + + +def test_shift_l_runs_combined_auto_adjust(): + keybinder_cls = _load_keybinder_class() + controller = _Controller() + keybinder = keybinder_cls(controller) + + handled = keybinder.handle_key_press(_Event(_Qt.Key_L, "L", _Qt.ShiftModifier)) + + assert handled is True + assert controller.calls == ["quick_auto_adjust"] + + +def test_shift_equals_character_still_triggers_shadow_adjust(): + keybinder_cls = _load_keybinder_class() + controller = _Controller() + keybinder = keybinder_cls(controller) + + handled = keybinder.handle_key_press(_Event(_Qt.Key_Equal, "=", _Qt.ShiftModifier)) + + assert handled is True + assert controller.calls == ["deepen_auto_adjust_blacks"] + + +def test_shift_minus_character_still_triggers_highlight_adjust(): + keybinder_cls = _load_keybinder_class() + controller = _Controller() + keybinder = keybinder_cls(controller) + + handled = keybinder.handle_key_press(_Event(_Qt.Key_Minus, "-", _Qt.ShiftModifier)) + + assert handled is True + assert controller.calls == ["reduce_auto_adjust_highlights"] diff --git a/faststack/tests/test_editor_integration.py b/faststack/tests/test_editor_integration.py index 4e1374e..938755a 100644 --- a/faststack/tests/test_editor_integration.py +++ b/faststack/tests/test_editor_integration.py @@ -47,6 +47,7 @@ def setUp(self): # Mock returns for methods that unpack results self.controller.image_editor.auto_levels.return_value = (0, 255, 0, 255) + self.controller.image_editor.analyze_auto_levels.return_value = (0, 255, 0, 255) self.controller.image_editor.save_image.return_value = (Path("test.jpg"), None) # Mock _save_executor to be synchronous to avoid race conditions @@ -128,7 +129,7 @@ def test_missing_methods(self): # 6. auto_levels try: self.controller.auto_levels() - self.controller.image_editor.auto_levels.assert_called_once() + self.controller.image_editor.analyze_auto_levels.assert_called_once() except AttributeError: self.fail("AppController is missing method 'auto_levels'") diff --git a/faststack/tests/test_pairing.py b/faststack/tests/test_pairing.py index 33cdf13..1666ea3 100644 --- a/faststack/tests/test_pairing.py +++ b/faststack/tests/test_pairing.py @@ -58,29 +58,95 @@ def test_raw_pairing_logic(): jpg_stat = MagicMock() jpg_stat.st_mtime = 1000.0 - # Case 1: Perfect match - raw1_path = Path("IMG_01.CR3") + # Case 1: Single same-stem candidate always pairs, even with a large mtime gap + raw1_path = Path("IMG_01.NEF") raw1_stat = MagicMock() - raw1_stat.st_mtime = 1000.1 + raw1_stat.st_mtime = 1010.0 potentials = [(raw1_path, raw1_stat)] assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw1_path - # Case 2: No match (time delta too large) - raw2_path = Path("IMG_01.CR3") + # Case 2: With multiple same-stem candidates, closest mtime wins + raw2_path = Path("IMG_01.DNG") raw2_stat = MagicMock() - raw2_stat.st_mtime = 1003.0 - potentials = [(raw2_path, raw2_stat)] - assert _find_raw_pair(jpg_path, jpg_stat, potentials) is None - - # Case 3: Closest match is chosen - raw3_path = Path("IMG_01_A.CR3") + raw2_stat.st_mtime = 1004.0 + raw3_path = Path("IMG_01.CR3") raw3_stat = MagicMock() - raw3_stat.st_mtime = 1000.5 - raw4_path = Path("IMG_01_B.CR3") + raw3_stat.st_mtime = 1001.0 + raw4_path = Path("IMG_01.ARW") raw4_stat = MagicMock() - raw4_stat.st_mtime = 1001.8 - potentials = [(raw3_path, raw3_stat), (raw4_path, raw4_stat)] + raw4_stat.st_mtime = 1008.0 + potentials = [ + (raw2_path, raw2_stat), + (raw3_path, raw3_stat), + (raw4_path, raw4_stat), + ] assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw3_path # Case 4: No potential RAWs assert _find_raw_pair(jpg_path, jpg_stat, []) is None + + +def test_find_images_pairs_same_stem_nef_beyond_two_seconds(tmp_path: Path): + """A single same-stem NEF should pair even when mtimes are far apart.""" + jpg_path = tmp_path / "DSC_0001.JPG" + raw_path = tmp_path / "DSC_0001.NEF" + + jpg_path.touch() + raw_path.touch() + os.utime(jpg_path, (1000, 1000)) + os.utime(raw_path, (1010, 1010)) + + images = find_images(tmp_path) + + assert len(images) == 1 + assert images[0].path.name == "DSC_0001.JPG" + assert images[0].raw_pair == raw_path + + +def test_find_images_multi_candidate_same_stem_uses_closest_mtime(tmp_path: Path): + """Multiple same-stem RAW candidates should be resolved by closest mtime.""" + jpg_path = tmp_path / "DSC_0002.JPG" + nef_path = tmp_path / "DSC_0002.NEF" + dng_path = tmp_path / "DSC_0002.DNG" + + jpg_path.touch() + nef_path.touch() + dng_path.touch() + os.utime(jpg_path, (1000, 1000)) + os.utime(nef_path, (1007, 1007)) + os.utime(dng_path, (1002, 1002)) + + images = find_images(tmp_path) + + assert len(images) == 2 + + jpg_image = next(im for im in images if im.path == jpg_path) + raw_only_image = next(im for im in images if im.path == nef_path) + + assert jpg_image.raw_pair == dng_path + assert raw_only_image.raw_pair == nef_path + + +def test_find_images_developed_artifact_behavior_is_preserved(tmp_path: Path): + """Developed JPGs stay unpaired while the base JPG keeps the RAW pair.""" + jpg_path = tmp_path / "A.jpg" + raw_path = tmp_path / "A.NEF" + developed_path = tmp_path / "A-developed.jpg" + + jpg_path.touch() + raw_path.touch() + developed_path.touch() + os.utime(jpg_path, (1000, 1000)) + os.utime(raw_path, (1010, 1010)) + os.utime(developed_path, (3000, 3000)) + + images = find_images(tmp_path) + + assert len(images) == 2 + + img_a = next(im for im in images if im.path == jpg_path) + img_dev = next(im for im in images if im.path == developed_path) + + assert img_a.raw_pair == raw_path + assert img_dev.raw_pair is None + assert [im.path.name for im in images] == ["A.jpg", "A-developed.jpg"] diff --git a/faststack/thumbnail_view/prefetcher.py b/faststack/thumbnail_view/prefetcher.py index f106caa..1aab321 100644 --- a/faststack/thumbnail_view/prefetcher.py +++ b/faststack/thumbnail_view/prefetcher.py @@ -16,6 +16,7 @@ import faststack.util.thumb_debug as thumb_debug from faststack.imaging.orientation import apply_orientation_to_np, get_exif_orientation +from faststack.imaging.jpeg import _decode_with_retry from faststack.imaging.turbo import TJPF_RGB, create_turbojpeg from faststack.io.utils import compute_path_hash from faststack.util.executors import create_priority_executor @@ -382,8 +383,12 @@ def _decode_image( # Decode with scaling scaling_factor = (1, scale_factor) - rgb = _tj.decode( - jpeg_data, pixel_format=TJPF_RGB, scaling_factor=scaling_factor + rgb = _decode_with_retry( + jpeg_data, + source_path=str(path), + decoder=_tj, + pixel_format=TJPF_RGB, + scaling_factor=scaling_factor, ) # Further resize with PIL if needed diff --git a/faststack/ui/keystrokes.py b/faststack/ui/keystrokes.py index 48488e4..5822ebb 100644 --- a/faststack/ui/keystrokes.py +++ b/faststack/ui/keystrokes.py @@ -46,7 +46,6 @@ def __init__(self, controller): Qt.Key_P: "edit_in_photoshop", Qt.Key_C: "clear_all_stacks", Qt.Key_A: "quick_auto_white_balance", - Qt.Key_L: "quick_auto_levels", Qt.Key_O: "toggle_crop_mode", Qt.Key_H: "toggle_histogram", Qt.Key_Delete: "delete_current_image", @@ -103,7 +102,35 @@ def handle_key_press(self, event): self._call(method_name) return True + blocked_auto_adjust_modifiers = modifiers & ( + Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier + ) + # Check for single key presses + if key == Qt.Key_L: + if not blocked_auto_adjust_modifiers: + self._call( + "quick_auto_adjust" + if modifiers & Qt.ShiftModifier + else "quick_auto_levels" + ) + return True + return False + + if not blocked_auto_adjust_modifiers: + if text == "-": + self._call("reduce_auto_adjust_highlights") + return True + if text == "=": + self._call("deepen_auto_adjust_blacks") + return True + if key == Qt.Key_Minus and modifiers == Qt.NoModifier: + self._call("reduce_auto_adjust_highlights") + return True + if key == Qt.Key_Equal and modifiers == Qt.NoModifier: + self._call("deepen_auto_adjust_blacks") + return True + method_name = self.key_map.get(key) if method_name: self._call(method_name) diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index d97712d..3749579 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -72,8 +72,31 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: # AND the generation matches (to avoid stale frames during rotation/param changes) # FIX: If zoomed in, force full-res image instead of low-res preview + # Also accept the editor-rendered preview when an auto-adjust + # session is live (rapid '-' / '=' before the debounced save), + # so the main loupe visibly tracks each keypress. + has_active_auto_adjust = ( + getattr(self.app_controller, "_active_auto_adjust_state", None) + is not None + ) + # Also accept the editor-rendered preview when the live edit + # session holds meaningful edits (e.g. a crop applied outside the + # editor), so the main loupe reflects those edits immediately. + has_live_edits = False + live_check = getattr( + self.app_controller, "_current_live_session_has_meaningful_edits", None + ) + if callable(live_check): + try: + has_live_edits = bool(live_check()) + except Exception: + has_live_edits = False use_editor_preview = ( - self.app_controller.ui_state.isEditorOpen + ( + self.app_controller.ui_state.isEditorOpen + or has_active_auto_adjust + or has_live_edits + ) and index == self.app_controller.current_index and not self.app_controller.ui_state.isZoomed and self.app_controller._last_rendered_preview is not None @@ -121,8 +144,9 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: # For standard browsing/prefetch, the buffer is stable enough. if ( self.app_controller.ui_state.isEditorOpen - and index == self.app_controller.current_index - ): + or has_active_auto_adjust + or has_live_edits + ) and index == self.app_controller.current_index: qimg = qimg.copy() else: # SAFETY: Keep a reference to the underlying buffer to prevent garbage collection @@ -769,6 +793,10 @@ def addFavoritesToBatch(self): def addUploadedToBatch(self): self.app_controller.add_uploaded_to_batch() + @Slot() + def addEditedToBatch(self): + self.app_controller.add_edited_to_batch() + @Slot() def jumpToLastUploaded(self): self.app_controller.jump_to_last_uploaded() diff --git a/pyproject.toml b/pyproject.toml index 60b4d31..cb86782 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.6.2" +version = "1.6.3" authors = [ { name = "Alan Rockefeller" }, ]