From ca65fc0caf3c5250037e937185b678134ece157e Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 2 Jan 2026 10:47:03 -0800 Subject: [PATCH] Update image editor --- faststack/faststack/app.py | 396 +++++++++++------- faststack/faststack/config.py | 29 +- faststack/faststack/imaging/editor.py | 246 +++++++---- faststack/faststack/imaging/jpeg.py | 2 +- faststack/faststack/imaging/prefetch.py | 10 +- faststack/faststack/logging_setup.py | 13 +- faststack/faststack/qml/Components.qml | 22 +- faststack/faststack/qml/ImageEditorDialog.qml | 60 ++- faststack/faststack/ui/provider.py | 34 +- 9 files changed, 540 insertions(+), 272 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index a9972b6..dd57255 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -85,6 +85,8 @@ def make_hdrop(paths): class AppController(QObject): dataChanged = Signal() # New signal for general data changes is_zoomed_changed = Signal(bool) # Signal for zoom state changes + histogramReady = Signal(object) # Signal for off-thread histogram result + previewReady = Signal(object) # Signal for off-thread preview result class ProgressReporter(QObject): progress_updated = Signal(int) @@ -92,6 +94,24 @@ class ProgressReporter(QObject): def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: bool = False): super().__init__() + # Histogram Offloading Setup + self._hist_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + self._hist_inflight = False + self._hist_pending = None + self._hist_token = 0 + self._hist_lock = threading.Lock() + self.histogramReady.connect(self._apply_histogram_result) + self.previewReady.connect(self._apply_preview_result) + + # Preview Offloading Setup + self._preview_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + self._preview_inflight = False + self._preview_pending = False + self._preview_token = 0 + self._preview_lock = threading.Lock() + self._last_rendered_preview = None # Store latest valid render + self._shutting_down = False # Flag to gate async callbacks during shutdown + self.image_dir = image_dir self.image_files: List[ImageFile] = [] # Filtered list for display self._all_images: List[ImageFile] = [] # Cached full list from disk @@ -100,6 +120,9 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.main_window: Optional[QObject] = None self.engine = engine self.debug_cache = debug_cache # New debug_cache flag + + # Ensure clean shutdown of background threads + QCoreApplication.instance().aboutToQuit.connect(self._shutdown_executors) self.display_width = 0 self.display_height = 0 @@ -182,8 +205,14 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.histogram_timer = QTimer(self) self.histogram_timer.setSingleShot(True) self.histogram_timer.setInterval(50) # 50ms throttle (max 20fps) - self.histogram_timer.timeout.connect(self._perform_update_histogram) - self._pending_histogram_args = None + self.histogram_timer.timeout.connect(self._kick_histogram_worker) + + # Preview Refresh Timer (Coalescing) + self._preview_refresh_pending = False + self.preview_timer = QTimer(self) + self.preview_timer.setSingleShot(True) + self.preview_timer.setInterval(33) # ~30fps cap for smoother dragging + self.preview_timer.timeout.connect(self._do_preview_refresh) # Track if any dialog is open to disable keybindings self._dialog_open = False @@ -235,6 +264,7 @@ def clear_filter(self): def get_display_info(self): if self.is_zoomed: return 0, 0, self.display_generation + return self.display_width, self.display_height, self.display_generation def on_display_size_changed(self, width: int, height: int): @@ -431,11 +461,10 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: # Debug log if mismatch log.debug(f"Path mismatch in preview. Editor: {editor_path}, File: {file_path}") - # Return preview if Editor is open OR Cropping is active (for live rotation) + # Return background-rendered preview if Editor is open OR Cropping is active if match and self.image_editor.original_image: - preview_data = self.image_editor.get_preview_data() - if preview_data: - return preview_data + if self._last_rendered_preview: + return self._last_rendered_preview _, _, display_gen = self.get_display_info() image_path = self.image_files[index].path @@ -1835,8 +1864,6 @@ def delete_batch_images(self): # We need to find the smallest index that was part of the deletion. min_deleted_index = min(sorted_indices) - previous_index = self.current_index # This might be inside the deleted range - # Create recycle bin if it doesn't exist try: self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) @@ -2127,6 +2154,13 @@ def shutdown(self): self.sidecar.set_last_index(self.current_index) self.sidecar.save() + def _shutdown_executors(self): + """Explicitly shuts down thread pools on app exit to prevent hanging.""" + self._shutting_down = True + log.info("Shutting down background executors...") + self._hist_executor.shutdown(wait=False, cancel_futures=True) + self._preview_executor.shutdown(wait=False, cancel_futures=True) + def empty_recycle_bin(self): """Permanently deletes all files in the recycle bin.""" if not self.recycle_bin_dir.exists(): @@ -2448,6 +2482,10 @@ def load_image_for_editing(self): self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] self.ui_state.currentAspectRatioIndex = 0 self.ui_state.currentCropBox = (0, 0, 1000, 1000) # Reset crop box visually + + # Kick off initial background preview render + self._kick_preview_worker() + return True return False @@ -2456,21 +2494,38 @@ def get_preview_data(self) -> Optional[DecodedImage]: """Gets the preview data of the currently edited image as a DecodedImage.""" return self.image_editor.get_preview_data() + def _do_preview_refresh(self): + self._preview_refresh_pending = False + self._kick_preview_worker() + @Slot(str, "QVariant") def set_edit_parameter(self, key: str, value: Any): """Sets an edit parameter and updates the UIState for the slider visual.""" - if self.image_editor.set_edit_param(key, value): - # Update the corresponding UIState property to reflect the new value in QML + try: + # Update actual edit state (this bumps _edits_rev and invalidates preview cache) + changed = False + if self.ui_state.isEditorOpen: + changed = self.image_editor.set_edit_param(key, value) + + # Sync UI state with backend (e.g., rotation might be rounded) + final_value = value + if changed: + # Use thread-safe accessor to get the actual value applied + actual = self.image_editor.get_edit_value(key) + if actual is not None: + final_value = actual + + # Update UI state regardless (visual sliders need to match what user dragged, OR the clamped backend value) if hasattr(self.ui_state, key): - setattr(self.ui_state, key, value) + setattr(self.ui_state, key, final_value) - # Trigger a refresh of the image to show the edit - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - - # Update histogram if visible - if self.ui_state.isHistogramVisible: - self.update_histogram() + # Trigger a refresh of the image to show the edit (throttled), ONLY if something changed + if changed: + if not self._preview_refresh_pending: + self._preview_refresh_pending = True + self.preview_timer.start() + except Exception as e: + log.error("Error setting edit parameter %s=%s: %s", key, value, e) @Slot(int, int, int, int) def set_crop_box(self, left: int, top: int, right: int, bottom: int): @@ -2489,7 +2544,7 @@ def reset_edit_parameters(self): # Trigger a refresh to show the reset image self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() + self._kick_preview_worker() @Slot() def save_edited_image(self): @@ -2563,97 +2618,79 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = """ # Early guard: don't even schedule if nothing is showing the histogram if not (self.ui_state.isHistogramVisible or self.ui_state.isEditorOpen): - self._pending_histogram_args = None + self._hist_pending = None return - self._pending_histogram_args = (zoom, pan_x, pan_y, image_scale) - if not self.histogram_timer.isActive(): + self._hist_pending = (zoom, pan_x, pan_y, image_scale) + if not self.histogram_timer.isActive() and not self._hist_inflight: self.histogram_timer.start() - def _perform_update_histogram(self): - """Actual histogram computation logic (called by timer).""" - if not self._pending_histogram_args: + def _kick_histogram_worker(self): + if getattr(self, "_shutting_down", False): return - - zoom, pan_x, pan_y, image_scale = self._pending_histogram_args - self._pending_histogram_args = None - - # Return immediately if neither histogram window nor editor is visible - if not (self.ui_state.isHistogramVisible or self.ui_state.isEditorOpen): + if self._hist_inflight: return - - if not self.image_files or self.current_index >= len(self.image_files): + if self._hist_pending is None: return - + + args = self._hist_pending + self._hist_pending = None + + with self._hist_lock: + self._hist_token += 1 + token = self._hist_token + self._hist_inflight = True + + # Snap the currently known preview data to avoid racing with the editor + preview_data = self._last_rendered_preview + if not preview_data: + # Fallback for initial load if no edit preview yet (could use get_decoded_image?) + # But histogram is mostly for edits. If preview_data is None, we likely can't compute anyway. + # We can try to peek at the image editor if _last_rendered_preview is unset. + preview_data = self.image_editor.get_preview_data_cached(allow_compute=False) + + fut = self._hist_executor.submit(self._compute_histogram_worker, token, args, preview_data) + fut.add_done_callback(self._on_histogram_done) + + @staticmethod + def _compute_histogram_worker(token, args, decoded): + # IMPORTANT: do not touch QObjects here except thread-safe plain data + zoom, pan_x, pan_y, image_scale = args + + # Use explicitly passed decoded data + if not decoded: + return token, None + + import numpy as np try: - # Get the current image data - decoded = None - - # If editor is open and has a preview, use that instead - if self.ui_state.isEditorOpen and self.image_editor.original_image: - preview_data = self.image_editor.get_preview_data() - if preview_data: - decoded = preview_data - - # Fallback to cached image - if not decoded: - decoded = self.get_decoded_image(self.current_index) - - if not decoded: - return - - # Convert buffer to numpy array - arr = np.frombuffer(decoded.buffer, dtype=np.uint8) - arr = arr.reshape((decoded.height, decoded.width, 3)) + arr = np.frombuffer(decoded.buffer, dtype=np.uint8).reshape((decoded.height, decoded.width, 3)) # If zoomed in, calculate visible region and only use that portion - if zoom > 1.1 and self.ui_state.isZoomed: - # Calculate visible region in image coordinates - # When zoomed, the visible area is the original image size divided by zoom - # The pan_x/pan_y are in screen coordinates relative to transform origin (center) - # image_scale is the scale factor of displayed image vs original - - # Visible size in original image coordinates - visible_width = decoded.width / zoom - visible_height = decoded.height / zoom - - # Center of visible region in image coordinates - # pan_x/pan_y are screen pixel offsets, convert to image pixels - # Account for image_scale: if image is scaled down for display, pan needs scaling too - center_x = decoded.width / 2 - center_y = decoded.height / 2 - - # Convert pan from screen pixels to image pixels - # If image_scale < 1, the image is displayed smaller, so pan needs to be scaled up - pan_x_image = pan_x / image_scale if image_scale > 0 else 0 - pan_y_image = pan_y / image_scale if image_scale > 0 else 0 - - # The visible center in image coordinates (accounting for pan) - visible_center_x = center_x - (pan_x_image / zoom) - visible_center_y = center_y - (pan_y_image / zoom) - - # Calculate bounds - visible_x_start = max(0, int(visible_center_x - visible_width / 2)) - visible_y_start = max(0, int(visible_center_y - visible_height / 2)) - visible_x_end = min(decoded.width, int(visible_center_x + visible_width / 2)) - visible_y_end = min(decoded.height, int(visible_center_y + visible_height / 2)) - - # Ensure we have valid bounds - if visible_x_end > visible_x_start and visible_y_end > visible_y_start: - # Extract only the visible portion - arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] - log.debug(f"Histogram: Using zoomed region {visible_x_start},{visible_y_start} to {visible_x_end},{visible_y_end} (zoom={zoom:.2f}, pan=({pan_x:.1f},{pan_y:.1f}))") - - # --- New Histogram Logic --- + if zoom > 1.1: + visible_width = decoded.width / zoom + visible_height = decoded.height / zoom + center_x = decoded.width / 2 + center_y = decoded.height / 2 + pan_x_image = pan_x / image_scale if image_scale > 0 else 0 + pan_y_image = pan_y / image_scale if image_scale > 0 else 0 + visible_center_x = center_x - (pan_x_image / zoom) + visible_center_y = center_y - (pan_y_image / zoom) + + visible_x_start = max(0, int(visible_center_x - visible_width / 2)) + visible_y_start = max(0, int(visible_center_y - visible_height / 2)) + visible_x_end = min(decoded.width, int(visible_center_x + visible_width / 2)) + visible_y_end = min(decoded.height, int(visible_center_y + visible_height / 2)) + + if visible_x_end > visible_x_start and visible_y_end > visible_y_start: + arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] + bins = 256 value_range = (0, 256) - # Compute histograms for each channel r_hist = np.histogram(arr[:, :, 0], bins=bins, range=value_range)[0] g_hist = np.histogram(arr[:, :, 1], bins=bins, range=value_range)[0] b_hist = np.histogram(arr[:, :, 2], bins=bins, range=value_range)[0] - # Calculate clip and pre-clip counts *before* log scaling r_clip_count = int(r_hist[255]) g_clip_count = int(g_hist[255]) b_clip_count = int(b_hist[255]) @@ -2662,14 +2699,11 @@ def _perform_update_histogram(self): g_preclip_count = int(np.sum(g_hist[250:255])) b_preclip_count = int(np.sum(b_hist[250:255])) - # Apply log scaling for better visualization - keeping this per existing code style - # but converting to float as requested log_r_hist = [float(x) for x in np.log1p(r_hist)] log_g_hist = [float(x) for x in np.log1p(g_hist)] log_b_hist = [float(x) for x in np.log1p(b_hist)] - # Create the structured data for QML with keys 'r', 'g', 'b' - histogram_data = { + hist = { 'r': log_r_hist, 'g': log_g_hist, 'b': log_b_hist, @@ -2680,60 +2714,104 @@ def _perform_update_histogram(self): 'g_preclip': g_preclip_count, 'b_preclip': b_preclip_count, } - - self.ui_state.histogramData = histogram_data - log.debug("Histogram updated with log scale and clip counts") - - except ImportError: - log.error("NumPy not available for histogram computation") - self.update_status_message("Histogram requires NumPy") - except Exception as e: - log.exception("Failed to compute histogram: %s", e) - self.update_status_message(f"Histogram error: {e}") - - # Check if new requests arrived while we were computing - if self._pending_histogram_args is not None: - self.histogram_timer.start() - - @Slot() - def execute_crop(self): - """Execute crop.""" - if not self.ui_state.isCropping: + return token, hist + except Exception: + return token, None + + def _on_histogram_done(self, fut): + if getattr(self, "_shutting_down", False): return try: - crop_box_raw = self.ui_state.currentCropBox + token, hist = fut.result() + except Exception: + token, hist = None, None - if isinstance(crop_box_raw, list): - crop_box_raw = tuple(crop_box_raw) + # bounce back to UI thread via signal + self.histogramReady.emit((token, hist)) - # if it is a QVariant, try toVariant - if not isinstance(crop_box_raw, tuple) and hasattr(crop_box_raw, "toVariant"): - try: - crop_box_raw = tuple(crop_box_raw.toVariant()) - except Exception: - pass + @Slot(object) + def _apply_histogram_result(self, payload): + if getattr(self, "_shutting_down", False): + return + + token, hist = payload + self._hist_inflight = False - if not (isinstance(crop_box_raw, tuple) and len(crop_box_raw) == 4): - self.update_status_message("Invalid crop box") - return + if hist is not None: + with self._hist_lock: + if token == self._hist_token: + self.ui_state.histogramData = hist + + # If more updates arrived while we computed, run again soon + if self._hist_pending is not None: + self.histogram_timer.start() + + def _kick_preview_worker(self): + """Kicks off a background preview render task.""" + if getattr(self, "_shutting_down", False): + return + + with self._preview_lock: + if self._preview_inflight: + self._preview_pending = True + return - # Apply crop by setting the crop box (this also updates UI state via set_crop_box) - # Ensure values are ints - left, top, right, bottom = map(int, crop_box_raw) - self.set_crop_box(left, top, right, bottom) + self._preview_inflight = True + self._preview_pending = False + self._preview_token += 1 + token = self._preview_token + + # Submit task to dedicated preview executor + fut = self._preview_executor.submit(self._render_preview_worker, token, self.image_editor) + fut.add_done_callback(self._on_preview_done) + + @staticmethod + def _render_preview_worker(token, image_editor): + # Heavy work (PIL apply_edits) happens here off-thread + try: + # allow_compute=True ensures we actually do the work + decoded = image_editor.get_preview_data_cached(allow_compute=True) + return token, decoded + except Exception: + log.exception("Preview render failed") + return token, None + + def _on_preview_done(self, fut): + if getattr(self, "_shutting_down", False): + return + + try: + token, decoded = fut.result() + except Exception: + token, decoded = None, None + + # Emit from worker thread; Qt will queue to UI thread + self.previewReady.emit((token, decoded)) + + @Slot(object) + def _apply_preview_result(self, payload): + if getattr(self, "_shutting_down", False): + return + + token, decoded = payload + + with self._preview_lock: + self._preview_inflight = False - # Exit crop mode - self.ui_state.isCropping = False + if decoded is not None: + # Only accept if it matches the latest requested token + if token == self._preview_token: + self._last_rendered_preview = decoded + # Ensure QML/provider URL changes so we don't get a cached frame + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() - # Apply edits (which includes crop) to preview - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.update_status_message("Crop applied") + # If new requests arrived while we were rendering, start the next one immediately + if self._preview_pending: + QTimer.singleShot(0, self._kick_preview_worker) + - except Exception as e: - log.exception("Failed to execute crop: %s", e) - self.update_status_message(f"Crop failed: {e}") @Slot() def cancel_crop_mode(self): @@ -2946,19 +3024,29 @@ def execute_crop(self): crop_box_raw = self.ui_state.currentCropBox - # Ensure ImageEditor has the latest crop box (it should be synced via UIState, but good to be safe) - if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: - # Try to convert if it came as list - try: - crop_box_raw = tuple(crop_box_raw) if isinstance(crop_box_raw, list) else tuple(crop_box_raw.toVariant()) - except Exception: - pass - - if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: pass - - if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: - self.update_status_message("Invalid crop box") - return + # Normalize crop_box_raw to a tuple of 4 ints + try: + # Handle QJSValue/QVariant wrapper if present + if hasattr(crop_box_raw, "toVariant"): + crop_box_raw = crop_box_raw.toVariant() + + # Convert list to tuple if needed + if isinstance(crop_box_raw, list): + crop_box_raw = tuple(crop_box_raw) + + if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: + raise ValueError(f"Expected 4-item tuple, got {type(crop_box_raw)}: {crop_box_raw}") + + # Coerce elements to int and clamp to [0, 1000] + l, t, r, b = [max(0, min(1000, int(x))) for x in crop_box_raw] + + # Ensure correct order (left <= right, top <= bottom) + crop_box_raw = (min(l, r), min(t, b), max(l, r), max(t, b)) + + except (ValueError, TypeError, AttributeError) as e: + log.warning("Invalid crop box format: %s", e) + self.update_status_message("Invalid crop selection") + return if crop_box_raw == (0, 0, 1000, 1000): self.update_status_message("No crop area selected") diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index e4f6b3b..7aef2eb 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -15,9 +15,32 @@ "theme": "dark", "default_directory": "", "optimize_for": "speed", # "speed" or "quality" - "auto_level_threshold": "0.1", # Threshold for auto-level detection (0.0-1.0) - "auto_level_strength": "1.0", # Strength of auto-level correction (0.0-1.0) - "auto_level_strength_auto": "False", # Automatically adjust auto-level strength + + # --- Auto Levels Configuration --- + # + # Behavior: + # Auto Levels are triggered when the user explicitly clicks "Auto Levels" in the + # image editor or uses the "Quick Auto Levels" hotkey. + # + # Algorithm: + # 1. Compute black/white points by clipping `auto_level_threshold` fraction of pixels + # (0.0-1.0) at the dark and light ends of the histogram. + # 2. Construct a levels transform to map these points to 0 and 255. + # 3. Blend the transformed image with the original using `auto_level_strength`. + # 4. If `auto_level_strength_auto` is True, `auto_level_strength` acts as a maximum; + # the system will automatically reduce the applied strength if the computed + # transform would cause excessive clipping or color instability. + # + # Practical Tuning: + # - auto_level_threshold: A fraction (not percent). + # Higher values (e.g. 0.05 = 5%) increase contrast but risk hard clipping. + # Lower values (e.g. 0.001 = 0.1%) are gentler and preserve more dynamic range. + # - auto_level_strength: 1.0 applies the full mathematical correction. Lower values + # blend the result for a subtler effect. + + "auto_level_threshold": "0.1", + "auto_level_strength": "1.0", + "auto_level_strength_auto": "False", }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 23a545f..9e89253 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -12,6 +12,7 @@ from faststack.models import DecodedImage from PySide6.QtGui import QImage +import threading log = logging.getLogger(__name__) @@ -54,7 +55,7 @@ def create_backup_file(original_path: Path) -> Optional[Path]: shutil.copy2(original_path, backup_path) return backup_path except OSError as e: - log.error(f"Failed to create backup: {e}") + log.exception(f"Failed to create backup: {e}") return None # ---------------------------- @@ -172,11 +173,21 @@ def __init__(self): self.current_edits: Dict[str, Any] = self._initial_edits() self.current_filepath: Optional[Path] = None + # Caching support for smooth updates + self._lock = threading.RLock() + self._edits_rev = 0 + self._cached_rev = -1 + self._cached_preview = None + def clear(self): """Clear all editor state so the next edit starts from a clean slate.""" - self.original_image = None - self.current_filepath = None - self._preview_image = None + with self._lock: + self.original_image = None + self.current_filepath = None + self._preview_image = None + self._edits_rev += 1 + self._cached_preview = None + self._cached_rev = -1 # Optionally also reset edits if that matches your mental model: # self.current_edits = self._initial_edits() @@ -205,45 +216,64 @@ def _initial_edits(self) -> Dict[str, Any]: def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = None): """Load a new image for editing.""" if not filepath or not Path(filepath).exists(): - self.original_image = None - self.current_filepath = None - self._preview_image = None + with self._lock: + self.original_image = None + self.current_filepath = None + self._preview_image = None + self._edits_rev += 1 + self._cached_preview = None + self._cached_rev = -1 return False self.current_filepath = Path(filepath) - # Reset edits - self.current_edits = self._initial_edits() try: # We must load and close the original file handle immediately - self.original_image = Image.open(self.current_filepath).convert("RGB") + with Image.open(self.current_filepath) as im: + original = im.convert("RGB") # Use the cached, display-sized preview if available if cached_preview: - self._preview_image = Image.frombytes( + preview = Image.frombytes( "RGB", (cached_preview.width, cached_preview.height), bytes(cached_preview.buffer) ) else: # Fallback: create a thumbnail if no preview is provided - self._preview_image = self.original_image.copy() - self._preview_image.thumbnail((1920, 1080)) # Reasonable fallback size + preview = original.copy() + preview.thumbnail((1920, 1080)) # Reasonable fallback size + + with self._lock: + self.original_image = original + self._preview_image = preview + # Reset edits + self.current_edits = self._initial_edits() + self._edits_rev += 1 + self._cached_preview = None + self._cached_rev = -1 return True except Exception as e: - log.error(f"Error loading image for editing: {e}") - self.original_image = None - self._preview_image = None + log.exception(f"Error loading image for editing: {e}") + with self._lock: + self.original_image = None + self._preview_image = None + self._edits_rev += 1 + self._cached_preview = None + self._cached_rev = -1 return False - def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.Image: + def _apply_edits(self, img: Image.Image, edits: Optional[Dict[str, Any]] = None, *, for_export: bool = False) -> Image.Image: """Applies all current edits to the provided PIL Image.""" + if edits is None: + edits = self.current_edits + # 1. Rotation (90 degree steps) # (This remains first as it changes the coordinate system basis) - rotation = self.current_edits.get('rotation', 0) + rotation = edits.get('rotation', 0) if rotation == 90: img = img.transpose(Image.Transpose.ROTATE_270) elif rotation == 180: @@ -254,8 +284,8 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I # --------------------------------------------------------- # CHANGE: Apply Free Rotation (Straighten) BEFORE Cropping # --------------------------------------------------------- - straighten_angle = float(self.current_edits.get('straighten_angle', 0.0)) - has_crop_box = 'crop_box' in self.current_edits and self.current_edits['crop_box'] + straighten_angle = float(edits.get('straighten_angle', 0.0)) + has_crop_box = 'crop_box' in edits and edits['crop_box'] # Only apply rotation if it's significant AND we are exporting. # During preview (for_export=False), QML handles the visual rotation. @@ -279,7 +309,7 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I # CHANGE: Apply Cropping LAST # --------------------------------------------------------- if has_crop_box: - crop_box = self.current_edits['crop_box'] + crop_box = edits['crop_box'] if len(crop_box) == 4: # Normalize coordinates (0-1000) to pixel coordinates # Note: We calculate this based on the *current* img size, @@ -300,7 +330,7 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I img = img.crop((left, t, r, b)) # 3. Exposure (gamma-based) - exposure = self.current_edits['exposure'] + exposure = edits['exposure'] if abs(exposure) > 0.001: gamma = 1.0 / (1.0 + exposure) if exposure >= 0 else 1.0 - exposure arr = np.array(img, dtype=np.float32) / 255.0 @@ -308,8 +338,8 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I arr = (arr * 255).clip(0, 255).astype(np.uint8) img = Image.fromarray(arr) - blacks = self.current_edits['blacks'] - whites = self.current_edits['whites'] + blacks = edits['blacks'] + whites = edits['whites'] if abs(blacks) > 0.001 or abs(whites) > 0.001: arr = np.array(img, dtype=np.float32) black_point = -blacks * 40 @@ -321,8 +351,8 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) # 5. Highlights/Shadows - highlights = self.current_edits['highlights'] - shadows = self.current_edits['shadows'] + highlights = edits['highlights'] + shadows = edits['shadows'] if abs(highlights) > 0.001 or abs(shadows) > 0.001: arr = np.array(img, dtype=np.float32) @@ -341,17 +371,17 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) # 6. Brightness - bright_factor = 1.0 + self.current_edits['brightness'] + bright_factor = 1.0 + edits['brightness'] if abs(bright_factor - 1.0) > 0.001: img = ImageEnhance.Brightness(img).enhance(bright_factor) # 7. Contrast - contrast_factor = 1.0 + self.current_edits['contrast'] + contrast_factor = 1.0 + edits['contrast'] if abs(contrast_factor - 1.0) > 0.001: img = ImageEnhance.Contrast(img).enhance(contrast_factor) # 8. Clarity - clarity = self.current_edits['clarity'] + clarity = edits['clarity'] if abs(clarity) > 0.001: arr = np.array(img, dtype=np.float32) luminance = 0.299 * arr[:,:,0] + 0.587 * arr[:,:,1] + 0.114 * arr[:,:,2] @@ -365,12 +395,12 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) # 9. Saturation - saturation_factor = 1.0 + self.current_edits['saturation'] + saturation_factor = 1.0 + edits['saturation'] if abs(saturation_factor - 1.0) > 0.001: img = ImageEnhance.Color(img).enhance(saturation_factor) # 10. Vibrance - vibrance = self.current_edits['vibrance'] + vibrance = edits['vibrance'] if abs(vibrance) > 0.001: arr = np.array(img, dtype=np.float32) sat = (arr.max(axis=2) - arr.min(axis=2)) / 255.0 @@ -380,8 +410,8 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) # 11. White Balance - by_val = self.current_edits['white_balance_by'] * 0.5 - mg_val = self.current_edits['white_balance_mg'] * 0.5 + by_val = edits['white_balance_by'] * 0.5 + mg_val = edits['white_balance_mg'] * 0.5 if abs(by_val) > 0.001 or abs(mg_val) > 0.001: arr = np.array(img, dtype=np.float32) # Multiplicative White Balance (Gain-based) @@ -406,12 +436,12 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I img = Image.fromarray(arr.astype(np.uint8)) # 12. Sharpness - sharp_factor = 1.0 + self.current_edits['sharpness'] + sharp_factor = 1.0 + edits['sharpness'] if abs(sharp_factor - 1.0) > 0.001: img = ImageEnhance.Sharpness(img).enhance(sharp_factor) # 13. Vignette - vignette = self.current_edits['vignette'] + vignette = edits['vignette'] if vignette > 0.001: arr = np.array(img, dtype=np.float32) h, w = arr.shape[:2] @@ -427,7 +457,7 @@ def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.I # 14. Texture (Fine Detail Local Contrast) # Similar to Clarity but with a smaller radius to target texture/fine details - texture = self.current_edits.get('texture', 0.0) + texture = edits.get('texture', 0.0) if abs(texture) > 0.001: arr = np.array(img, dtype=np.float32) luminance = 0.299 * arr[:,:,0] + 0.587 * arr[:,:,1] + 0.114 * arr[:,:,2] @@ -473,69 +503,125 @@ def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float]: # Calculate parameters to map p_low->0 and p_high->255 # Logic matches _apply_edits: # black_point = -blacks * 40 - # white_point = 255 + whites * 40 + # white_point = 255 - whites * 40 # We want black_point to be p_low - # p_low = -blacks * 40 => blacks = -p_low / 40.0 + # p_low = -blacks * 40 => blacks = -float(p_low) / 40.0 blacks = -float(p_low) / 40.0 # We want white_point to be p_high - # p_high = 255 + whites * 40 => whites = (p_high - 255) / 40.0 - whites = (float(p_high) - 255.0) / 40.0 + # p_high = 255 - whites * 40 => whites = (255.0 - float(p_high)) / 40.0 + whites = (255.0 - float(p_high)) / 40.0 # Update state - self.current_edits['blacks'] = blacks - self.current_edits['whites'] = whites + with self._lock: + self.current_edits['blacks'] = blacks + self.current_edits['whites'] = whites + self._edits_rev += 1 return blacks, whites - def get_preview_data(self) -> Optional[DecodedImage]: - """Apply current edits and return the data as a DecodedImage.""" - if self._preview_image is None: - return None + def get_preview_data_cached(self, allow_compute: bool = True) -> Optional[DecodedImage]: + """Return cached preview if available, otherwise compute and cache. + + Args: + allow_compute: If False, returns None immediately if cache is stale (avoids blocking). + """ + with self._lock: + # Check cache validity + if self._cached_preview is not None and self._cached_rev == self._edits_rev: + return self._cached_preview + + if not allow_compute: + return None + + # Prepare for computation - snapshot data under lock + base = self._preview_image.copy() if self._preview_image is not None else None + edits = dict(self.current_edits) + rev = self._edits_rev - # Always start from a fresh copy of the small preview image - img = self._preview_image.copy() - img = self._apply_edits(img, for_export=False) + if base is None: + return None + # Heavy computation outside lock using snapshot + img = self._apply_edits(base, edits=edits, for_export=False) # The image is in RGB mode after _apply_edits buffer = img.tobytes() - return DecodedImage( + decoded = DecodedImage( buffer=memoryview(buffer), width=img.width, height=img.height, - bytes_per_line=img.width * 3, # 3 bytes per pixel for RGB + bytes_per_line=img.width * 3, format=QImage.Format.Format_RGB888 ) + with self._lock: + # Only cache if revision hasn't changed during computation + if self._edits_rev == rev: + self._cached_preview = decoded + self._cached_rev = rev + + return decoded + + def get_preview_data(self) -> Optional[DecodedImage]: + """Apply current edits and return the data as a DecodedImage.""" + return self.get_preview_data_cached() + + def get_edit_value(self, key: str, default: Any = None) -> Any: + """Thread-safe retrieval of an edit parameter.""" + with self._lock: + return self.current_edits.get(key, default) + def set_edit_param(self, key: str, value: Any) -> bool: """Update a single edit parameter.""" - if key == 'rotation': - # Guard against arbitrary angles in 'rotation'. It expects 90-degree steps. - # For arbitrary rotation (drag to rotate), use 'straighten_angle'. - try: - # Round to nearest 90 degrees - val_deg = float(value) - rounded_deg = round(val_deg / 90.0) * 90 - final_val = int(rounded_deg) % 360 - - if abs(val_deg - rounded_deg) > 1.0: - log.warning(f"'rotation' received {value}. Rounding to {final_val}. Use 'straighten_angle' for free rotation.") + with self._lock: + if key == 'rotation': + # Guard against arbitrary angles in 'rotation'. It expects 90-degree steps. + # For arbitrary rotation (drag to rotate), use 'straighten_angle'. + try: + # Round to nearest 90 degrees + val_deg = float(value) + rounded_deg = round(val_deg / 90.0) * 90 + final_val = int(rounded_deg) % 360 + + if abs(val_deg - rounded_deg) > 1.0: + log.warning(f"'rotation' received {value}. Rounding to {final_val}. Use 'straighten_angle' for free rotation.") + + self.current_edits[key] = final_val + self._edits_rev += 1 + return True + except (ValueError, TypeError) as e: + log.warning(f"Invalid value for rotation {value!r}: {e}") + return False + + + + if key in self.current_edits and key != 'crop_box': + # Check for floating point equality to prevent cache thrashing + new_val = value + current_val = self.current_edits.get(key) - self.current_edits[key] = final_val + # Try to compare as floats if possible + try: + vf = float(new_val) + cf = float(current_val) + if math.isclose(vf, cf, rel_tol=1e-5, abs_tol=1e-7): + return False + except (ValueError, TypeError): + # Fallback to direct equality + if current_val == new_val: + return False + + self.current_edits[key] = value + self._edits_rev += 1 return True - except (ValueError, TypeError): - log.error(f"Invalid value for rotation: {value}") - return False - - if key in self.current_edits and key != 'crop_box': - self.current_edits[key] = value - return True - return False + return False def set_crop_box(self, crop_box: Tuple[int, int, int, int]): """Set the normalized crop box (left, top, right, bottom) from 0-1000.""" - self.current_edits['crop_box'] = crop_box + with self._lock: + self.current_edits['crop_box'] = crop_box + self._edits_rev += 1 def save_image(self) -> Optional[Tuple[Path, Path]]: """Saves the edited image, backing up the original. @@ -633,7 +719,7 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: "format settings and EXIF metadata is likely lost." ) except Exception as e3: - log.error(f"Failed to save edited image even with fallback: {e3}") + log.exception(f"Failed to save edited image even with fallback: {e3}") # Reraise so the outer except logs and returns None raise @@ -642,7 +728,7 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: return original_path, backup_path except Exception as e: - log.error(f"Failed to save edited image or backup: {e}") + log.exception(f"Failed to save edited image or backup: {e}") return None def _restore_file_times(self, path: Path, original_stat: os.stat_result) -> None: @@ -654,13 +740,17 @@ def _restore_file_times(self, path: Path, original_stat: os.stat_result) -> None def rotate_image_cw(self): """Decreases the rotation edit parameter by 90° modulo 360 (clockwise).""" - current = self.current_edits.get('rotation', 0) - self.current_edits['rotation'] = (current - 90) % 360 + with self._lock: + current = self.current_edits.get('rotation', 0) + self.current_edits['rotation'] = (current - 90) % 360 + self._edits_rev += 1 def rotate_image_ccw(self): """Increases the rotation edit parameter by 90° modulo 360 (counter-clockwise).""" - current = self.current_edits.get('rotation', 0) - self.current_edits['rotation'] = (current + 90) % 360 + with self._lock: + current = self.current_edits.get('rotation', 0) + self.current_edits['rotation'] = (current + 90) % 360 + self._edits_rev += 1 # Dictionary of ratios for QML dropdown ASPECT_RATIOS = [{"name": name, "ratio": ratio} for name, ratio in INSTAGRAM_RATIOS.items()] diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index 9276f35..cf23033 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -115,7 +115,7 @@ def decode_jpeg_resized( jpeg_bytes: bytes, width: int, height: int, fast_dct: bool = False ) -> Optional[np.ndarray]: """Decodes and resizes a JPEG to fit within the given dimensions.""" - if width == 0 or height == 0: + if width <= 0 or height <= 0: return decode_jpeg_rgb(jpeg_bytes, fast_dct=fast_dct) if TURBO_AVAILABLE and jpeg_decoder: diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 2ff5e9d..e96e38e 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -419,10 +419,11 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, total=%.3fs, size=%dx%d", + log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", index, decoder, t_after_fallback_read - t_before_fallback_read, t_after_fallback_decode - t_after_fallback_read, - t_after_fallback_decode - t_start, w, h) + t_after_copy - t_after_fallback_decode, + t_after_copy - t_start, w, h) else: # Fall back to standard decode if ICC profile not available log.warning("ICC mode selected but no monitor profile available, using standard decode") @@ -452,9 +453,10 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, total=%.3fs, size=%dx%d", + log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_decode - t_start, w, h) + t_after_copy - t_after_decode, + t_after_copy - t_start, w, h) else: # Standard decode path (Option A or no color management) diff --git a/faststack/faststack/logging_setup.py b/faststack/faststack/logging_setup.py index 824e814..d42a658 100644 --- a/faststack/faststack/logging_setup.py +++ b/faststack/faststack/logging_setup.py @@ -22,19 +22,26 @@ def setup_logging(debug: bool = False): log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "app.log" - handler = logging.handlers.RotatingFileHandler( + # File handler + file_handler = logging.handlers.RotatingFileHandler( log_file, maxBytes=10*1024*1024, backupCount=5 ) formatter = logging.Formatter( "%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) - handler.setFormatter(formatter) + file_handler.setFormatter(formatter) + + # Console handler (for seeing logs in terminal) + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) root_logger = logging.getLogger() # Set log level based on debug flag root_logger.setLevel(logging.DEBUG if debug else logging.INFO) root_logger.handlers.clear() - root_logger.addHandler(handler) + root_logger.addHandler(file_handler) + root_logger.addHandler(console_handler) + # Configure logging for key modules if debug: logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG) diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index e0244e7..578242a 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -615,6 +615,8 @@ Item { if (uiState && uiState.isCropping) { // Check if clicking on existing crop box - Using Image Space Hit Testing var box = uiState.currentCropBox + if (box && box.length === 4) box = box.slice(0) + var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) @@ -657,22 +659,20 @@ Item { // Calculate start aspect ratio (in pixels) if (mainImage.width > 0) { - var cb = uiState.currentCropBox - if (cb && cb.length === 4) { - var boxW = (cb[2] - cb[0]) / 1000 * mainImage.width - var boxH = (cb[3] - cb[1]) / 1000 * mainImage.height - cropStartAspect = boxW / boxH + if (box && box.length === 4) { + var boxW = (box[2] - box[0]) / 1000 * mainImage.width + var boxH = (box[3] - box[1]) / 1000 * mainImage.height + if (boxH > 0) cropStartAspect = boxW / boxH } } // Seed cropBoxStart variables - var startBox = uiState.currentCropBox - if (startBox && startBox.length === 4) { - cropBoxStartLeft = startBox[0] - cropBoxStartTop = startBox[1] - cropBoxStartRight = startBox[2] - cropBoxStartBottom = startBox[3] + if (box && box.length === 4) { + cropBoxStartLeft = box[0] + cropBoxStartTop = box[1] + cropBoxStartRight = box[2] + cropBoxStartBottom = box[3] } isCropDragging = true diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index 87c0c7e..75b7fa0 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -89,15 +89,12 @@ Window { Component { id: sectionHeader Label { - text: headerText font.bold: true font.pixelSize: 15 font.letterSpacing: 1.0 color: imageEditorDialog.accentColorHover Layout.topMargin: 5 Layout.bottomMargin: 10 - - property string headerText: "" } } @@ -122,8 +119,8 @@ Window { // --- Light Group --- Loader { sourceComponent: sectionHeader - property string headerText: "☀ Light" Layout.topMargin: 0 // Remove top margin for the very first item + onLoaded: item.text = "☀ Light" } ListModel { id: lightModel @@ -140,7 +137,10 @@ Window { Loader { sourceComponent: sectionSeparator } // --- Detail Group --- - Loader { sourceComponent: sectionHeader; property string headerText: "🔍 Detail" } + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "🔍 Detail" + } ListModel { id: detailModel ListElement { name: "Clarity"; key: "clarity" } @@ -216,8 +216,8 @@ Window { // --- Color Group --- Loader { sourceComponent: sectionHeader - property string headerText: "🎨 Color" Layout.topMargin: 0 // Remove top margin for the very first item + onLoaded: item.text = "🎨 Color" } ListModel { id: colorModel @@ -254,7 +254,10 @@ Window { Loader { sourceComponent: sectionSeparator } // --- Effects Group --- - Loader { sourceComponent: sectionHeader; property string headerText: "✨ Effects" } + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "✨ Effects" + } ListModel { id: effectsModel ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } @@ -264,7 +267,10 @@ Window { Loader { sourceComponent: sectionSeparator } // --- Transform Group --- - Loader { sourceComponent: sectionHeader; property string headerText: "🔄 Transform" } + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "🔄 Transform" + } RowLayout { Layout.fillWidth: true spacing: 15 @@ -391,7 +397,28 @@ Window { return isReversed ? -val : val } - value: backendValue + // Auto-sync visual slider with backend changes when not dragging + Binding { + target: slider + property: "value" + value: slider.backendValue + when: !slider.pressed + } + + property real _pendingValue: 0 + property real _lastSentValue: 0 + Timer { + id: sendTimer + interval: 32 // ~30fps throttle + repeat: true + onTriggered: { + if (Math.abs(slider._pendingValue - slider._lastSentValue) > 0.001) { + var sendValue = slider.isReversed ? -slider._pendingValue : slider._pendingValue + controller.set_edit_parameter(model.key, sendValue / maxVal) + slider._lastSentValue = slider._pendingValue + } + } + } Connections { target: imageEditorDialog @@ -403,10 +430,8 @@ Window { } onMoved: { - var sendValue = isReversed ? -value : value - controller.set_edit_parameter(model.key, sendValue / maxVal) - // Trigger live histogram update (throttled by Python backend) - if (controller) controller.update_histogram() + _pendingValue = value + if (!sendTimer.running) sendTimer.start() } property double lastPressTime: 0 @@ -429,9 +454,16 @@ Window { lastPressValue = value imageEditorDialog.slidersPressedCount++ + _pendingValue = value + if (!sendTimer.running) sendTimer.start() } else { imageEditorDialog.slidersPressedCount-- - // Update histogram on release + + // Stop repeating sends, then send final value immediately + sendTimer.stop() + var sendValue = isReversed ? -value : value + controller.set_edit_parameter(model.key, sendValue / maxVal) + if (controller) controller.update_histogram() } } diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index f5ffd25..2a3d4ef 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -33,16 +33,41 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: try: image_index_str = id.split('/')[0] index = int(image_index_str) - image_data = self.app_controller.get_decoded_image(index) + + # If editor is open, use the background-rendered preview buffer + # BUT only if the requested index matches the currently edited index! + # Otherwise we serve the editor preview for thumbnails/prefetch. + if self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index: + image_data = self.app_controller._last_rendered_preview or self.app_controller.get_decoded_image(index) + else: + image_data = self.app_controller.get_decoded_image(index) if image_data: + # Handle format being None (from prefetcher) or missing + fmt = getattr(image_data, 'format', None) + if fmt is None: + fmt = QImage.Format.Format_RGB888 + qimg = QImage( image_data.buffer, image_data.width, image_data.height, image_data.bytes_per_line, - QImage.Format.Format_RGB888 + fmt ) + + + # Detach from Python buffer to prevent ownership issues and force proper texture upload + # OPTIMIZATION: Only do this expensive copy when serving the live editor preview, + # where we need to detach from the shared memory buffer that might change. + # For standard browsing/prefetch, the buffer is stable enough. + if self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index: + qimg = qimg.copy() + else: + # SAFETY: Keep a reference to the underlying buffer to prevent garbage collection + # while Qt holds the QImage. QImage created from bytes does NOT own the data. + qimg.original_buffer = image_data.buffer + # Set sRGB color space for proper color management (if available) # Skip this when using ICC mode - pixels are already in monitor space color_mode = config.get('color', 'mode', fallback="none").lower() @@ -56,8 +81,9 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: log.warning(f"Failed to set color space: {e}") elif color_mode == "icc": log.debug("ICC mode: skipping Qt color space (pixels already in monitor space)") - # keep buffer alive - qimg.original_buffer = image_data.buffer + + # Buffer is now safe to release (handled by copy), but original_buffer ref in Python object stays + # We don't need to manually attach original_buffer to qimg anymore since we copied. return qimg except (ValueError, IndexError) as e: