diff --git a/faststack/imaging/turbo.py b/faststack/imaging/turbo.py index 86d42b9..d64ea89 100644 --- a/faststack/imaging/turbo.py +++ b/faststack/imaging/turbo.py @@ -4,10 +4,13 @@ import logging import os +import sys +from functools import lru_cache from pathlib import Path from typing import Optional, Tuple log = logging.getLogger(__name__) +_fallback_warnings_emitted: set[str] = set() try: from turbojpeg import TJPF_RGB, TurboJPEG @@ -72,16 +75,53 @@ def _candidate_library_paths() -> list[Optional[str]]: return unique -def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]: - """Create a TurboJPEG decoder if possible.""" +def _install_hint() -> str: + """Return a concise, platform-specific libjpeg-turbo install hint.""" + if os.name == "nt": + return ( + "Windows: install the x64 libjpeg-turbo package so " + r"C:\libjpeg-turbo64\bin\turbojpeg.dll exists, or set " + "FASTSTACK_TURBOJPEG_LIB to the full turbojpeg.dll path." + ) + if sys.platform == "darwin": + return ( + "macOS: install libjpeg-turbo with `brew install jpeg-turbo`, " + "or set FASTSTACK_TURBOJPEG_LIB to the full libturbojpeg.dylib path." + ) + return ( + "Linux: install the TurboJPEG shared library, for example " + "`sudo apt install libturbojpeg` on Debian/Ubuntu, " + "`sudo dnf install turbojpeg` on Fedora, or " + "`sudo pacman -S libjpeg-turbo` on Arch. You can also set " + "FASTSTACK_TURBOJPEG_LIB to the full libturbojpeg.so path." + ) + + +def _warn_fallback_once(message: str, *args: object) -> None: + """Emit only one user-facing TurboJPEG fallback warning per process.""" + if message in _fallback_warnings_emitted: + return + _fallback_warnings_emitted.add(message) + log.warning(message, *args) + + +@lru_cache(maxsize=8) +def _create_turbojpeg_cached( + _decoder_identity: int, + candidates: tuple[Optional[str], ...], +) -> Tuple[Optional["TurboJPEG"], bool]: + """Probe TurboJPEG once per candidate set and cache the result.""" if TurboJPEG is None: - log.warning( - "PyTurboJPEG not found. Falling back to Pillow for JPEG decoding. Installing PyTurboJPEG will improve image navigation speed." + _warn_fallback_once( + "PyTurboJPEG is not installed. Falling back to Pillow for JPEG " + "decoding, which is slower for large folders. Install PyTurboJPEG " + "and libjpeg-turbo to enable faster image navigation. %s", + _install_hint(), ) return None, False failures: list[str] = [] - for candidate in _candidate_library_paths(): + for candidate in candidates: try: decoder = TurboJPEG() if candidate is None else TurboJPEG(candidate) except Exception as exc: @@ -97,9 +137,18 @@ def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]: for failure in failures: log.debug("TurboJPEG load attempt failed: %s", failure) - log.warning( + _warn_fallback_once( "TurboJPEG initialization failed (%d location(s) tried). " - "Falling back to Pillow for JPEG decoding.", + "PyTurboJPEG is installed, but the native libjpeg-turbo shared " + "library was not found or could not be loaded. Falling back to " + "Pillow for JPEG decoding, which is slower for large folders. %s", len(failures), + _install_hint(), ) return None, False + + +def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]: + """Create a TurboJPEG decoder if possible.""" + candidates = tuple(_candidate_library_paths()) + return _create_turbojpeg_cached(id(TurboJPEG), candidates) diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 1416b12..33acbb2 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -209,7 +209,6 @@ Item { return } mainImage.updateZoomState() - if (cropOverlay.visible) cropOverlay.updateCropRect() } // Fix B: Stable Logical Size @@ -319,7 +318,6 @@ Item { return } mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() } onYChanged: { if (imageRotator._lockedPanY > -1e8 && Math.abs(y - imageRotator._lockedPanY) > 0.01) { @@ -327,7 +325,6 @@ Item { return } mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() } } ] @@ -361,54 +358,47 @@ Item { // Crop overlay - anchored to mainImage to rotate with it Item { id: cropOverlay - property var cropBox: loupeView.uiStateRef ? loupeView.uiStateRef.currentCropBox : [0, 0, 1000, 1000] - property bool hasActiveCrop: cropBox && cropBox.length === 4 && !(cropBox[0]===0 && cropBox[1]===0 && cropBox[2]===1000 && cropBox[3]===1000) + property bool hasActiveCrop: { + var b = _liveCropBox() + return b && b.length === 4 && !(b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000) + } + property bool hasDrawableCrop: { + var b = _liveCropBox() + return b && b.length === 4 && (b[2] - b[0]) > 0 && (b[3] - b[1]) > 0 + && !(b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000) + } // Show visual content only when there is an actual user-drawn crop or rotate mode. - // The overlay Item itself stays alive (visible: isCropping) so updateCropRect() always fires. - property bool showCropContent: hasActiveCrop || mainMouseArea.isRotating - - visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping - anchors.fill: parent // Fills mainImage - z: 100 - - onCropBoxChanged: { if (parent.source) updateCropRect() } - Component.onCompleted: { if (parent.source) updateCropRect() } - + property bool showCropContent: hasActiveCrop || mainMouseArea.isRotating || mainMouseArea.isCropDragging + + property int _cropBoxRev: 0 Connections { target: loupeView.uiStateRef - function onCurrentCropBoxChanged() { if (mainImage.source) cropOverlay.updateCropRect() } - } - - Connections { - target: mainImage - function onWidthChanged() { cropOverlay.updateCropRect() } - function onHeightChanged() { cropOverlay.updateCropRect() } + function onCurrentCropBoxChanged() { + cropOverlay._cropBoxRev += 1 + } } - - function updateCropRect() { - if (!loupeView.uiStateRef || !loupeView.uiStateRef.currentCropBox || loupeView.uiStateRef.currentCropBox.length !== 4) return - var box = loupeView.uiStateRef.currentCropBox - - // Local coords in mainImage (Source Space) - var localLeft = (box[0] / 1000) * parent.width - var localTop = (box[1] / 1000) * parent.height - var localRight = (box[2] / 1000) * parent.width - var localBottom = (box[3] / 1000) * parent.height - - cropRect.x = localLeft - cropRect.y = localTop - cropRect.width = localRight - localLeft - cropRect.height = localBottom - localTop + + function _liveCropBox() { + var _ = cropOverlay._cropBoxRev + return loupeView.uiStateRef ? loupeView.uiStateRef.currentCropBox : null } - + + visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping + anchors.fill: parent // Fills mainImage + z: 100 + // Dimmer Rectangles — only render when a real crop is active/being drawn - Rectangle { visible: cropOverlay.showCropContent; x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 } - Rectangle { visible: cropOverlay.showCropContent; x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 } - Rectangle { visible: cropOverlay.showCropContent; x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 } - Rectangle { visible: cropOverlay.showCropContent; x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 } + Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 } + Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 } + Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 } + Rectangle { visible: cropOverlay.hasDrawableCrop; x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 } Rectangle { id: cropRect + x: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? (b[0] / 1000) * parent.width : 0 } + y: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? (b[1] / 1000) * parent.height : 0 } + width: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? ((b[2] - b[0]) / 1000) * parent.width : 0 } + height: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? ((b[3] - b[1]) / 1000) * parent.height : 0 } visible: cropOverlay.showCropContent color: "transparent" border.color: "white" @@ -439,8 +429,9 @@ Item { anchors.horizontalCenter: handleLine.horizontalCenter } } + } - + source: loupeView.displayedImageSource function _currentDpr() { diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py index 2192565..6d40be7 100644 --- a/faststack/tests/test_turbo.py +++ b/faststack/tests/test_turbo.py @@ -112,5 +112,5 @@ def test_missing_turbojpeg_package_emits_one_warning(monkeypatch, caplog): warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warning_records) == 1 - assert "PyTurboJPEG not found" in warning_records[0].message + assert "PyTurboJPEG is not installed" in warning_records[0].message assert "Pillow" in warning_records[0].message diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 5fa658c..d3c8464 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -319,7 +319,7 @@ def __init__(self, app_controller, clock_func=None): self._saturation = 0.0 self._white_balance_by = 0.0 self._white_balance_mg = 0.0 - self._current_crop_box = [0, 0, 1000, 1000] + self._current_crop_box = (0, 0, 1000, 1000) self._crop_rotation = 0.0 self._debug_mode = False self._aspect_ratio_names = [ @@ -1089,10 +1089,15 @@ def reset_editor_state(self): self.darkenMode = "assisted" self.darkenBrushRadius = 0.03 - @Property("QVariant", notify=histogram_data_changed) - def histogramData(self): - """Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values.""" - return self._histogram_data + @Property("QVariantMap", notify=histogram_data_changed) + def histogramData(self) -> dict: + """Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values. + + Note: declared as QVariantMap (not QVariant) so QML JavaScript receives a real + Object. Under PySide6 6.11+, returning a dict through Property("QVariant") produces + a QJSValue wrapper that JS cannot index by key. + """ + return self._histogram_data if self._histogram_data is not None else {} @histogramData.setter def histogramData(self, new_value): @@ -1100,14 +1105,18 @@ def histogramData(self, new_value): self._histogram_data = new_value self.histogram_data_changed.emit() - @Property("QVariant", notify=highlightStateChanged) - def highlightState(self): + @Property("QVariantMap", notify=highlightStateChanged) + def highlightState(self) -> dict: """Returns highlight analysis state for UI display. Returns dict with: - headroom_pct: Fraction of pixels with recoverable data above 1.0 (16-bit sources) - source_clipped_pct: Fraction of pixels clipped in the SOURCE image (JPEG flat-top @ 254+) - current_nearwhite_pct: Fraction of pixels currently near white in the processed state. + + Note: declared as QVariantMap (not QVariant) so QML JavaScript receives a real + Object. Under PySide6 6.11+, returning a dict through Property("QVariant") produces + a QJSValue wrapper that JS cannot index by key. """ editor = self.app_controller.image_editor state = {} @@ -1216,10 +1225,14 @@ def currentAspectRatioIndex(self, new_value: int): self._current_aspect_ratio_index = new_value self.current_aspect_ratio_index_changed.emit(new_value) - @Property("QVariant", notify=current_crop_box_changed) - def currentCropBox(self) -> tuple: - # QML will receive this as a list - return self._current_crop_box + @Property("QVariantList", notify=current_crop_box_changed) + def currentCropBox(self) -> list: + # Return a plain list so QML JavaScript receives a real Array. + # Under PySide6 6.11+, returning a tuple through Property("QVariant") + # produces a QJSValue wrapper that JS cannot index. + return ( + list(self._current_crop_box) if self._current_crop_box is not None else [] + ) @currentCropBox.setter def currentCropBox(self, new_value):