diff --git a/ChangeLog.md b/ChangeLog.md index f6b93fd..3ed2274 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -10,6 +10,9 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Moved menu activation from hovering over the image to hovering over the title bar. - Expand the default prefetch window from a radius of 4 to 12 images. - Introduce directional awareness to task cancellation logic, making the prefetcher a lot faster. +- Improved TurboJPEG setup on Windows by using shared library detection logic in JPEG decoding and thumbnail prefetching. Thanks to Andy Arijs for the PR! +- Added Windows documentation for installing turbojpeg.dll, using FASTSTACK_TURBOJPEG_LIB, and understanding fallback behavior. Thanks to Andy Arijs! +- FastStack now more clearly explains when it falls back to Pillow for JPEG decoding and thumbnails. Thanks to Andy Arijs! ## 1.6.0 (2026-03-06) diff --git a/README.md b/README.md index 9467746..fc19534 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,50 @@ pip install . faststack ``` +### Windows Performance Note +On Windows, `PyTurboJPEG` also needs the native `libjpeg-turbo` library (`turbojpeg.dll`). + +- If `turbojpeg.dll` is installed, FastStack uses it automatically for faster JPEG decode and thumbnail generation. +- If it is missing, FastStack still runs, but falls back to Pillow and may feel slower on large folders. + +Recommended install location: + +- `C:\libjpeg-turbo64\bin\turbojpeg.dll` + +FastStack also checks these optional environment variables if you installed it elsewhere: + +- `FASTSTACK_TURBOJPEG_LIB` +- `TURBOJPEG_LIB` + +Example: + +```cmd +set FASTSTACK_TURBOJPEG_LIB=C:\path\to\turbojpeg.dll +venv\Scripts\python.exe -m faststack.app "C:\path\to\photos" +``` + +### Troubleshooting on Windows +If startup logs mention: + +```text +TurboJPEG initialization failed (N location(s) tried). Falling back to Pillow for JPEG decoding. +``` + +that means the Python package is installed but FastStack could not initialize TurboJPEG from any discovered location and is using Pillow instead. + +Fastest fixes: + +1. Install `libjpeg-turbo` for Windows x64 so that this file exists: + `C:\libjpeg-turbo64\bin\turbojpeg.dll` +2. Or point FastStack to the dll explicitly: + +```cmd +set FASTSTACK_TURBOJPEG_LIB=C:\path\to\turbojpeg.dll +venv\Scripts\python.exe -m faststack.app "C:\path\to\photos" +``` + +If you do nothing, FastStack will still run, but JPEG decoding and thumbnail generation will use Pillow instead of `libjpeg-turbo`, which is slower. + ## Keyboard Shortcuts - `J` / `Right Arrow`: Next Image diff --git a/faststack/imaging/jpeg.py b/faststack/imaging/jpeg.py index 55b8bbb..09392a8 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -7,26 +7,11 @@ import numpy as np from PIL import Image -log = logging.getLogger(__name__) +from faststack.imaging.turbo import TJPF_RGB, create_turbojpeg -# Attempt to import PyTurboJPEG +log = logging.getLogger(__name__) -try: - from turbojpeg import TurboJPEG, TJPF_RGB -except ImportError: - JPEG_DECODER = None - TURBO_AVAILABLE = False - log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") -else: - try: - JPEG_DECODER = TurboJPEG() - except Exception: - JPEG_DECODER = None - TURBO_AVAILABLE = False - log.exception("PyTurboJPEG initialization failed. Falling back to Pillow.") - else: - TURBO_AVAILABLE = True - log.info("PyTurboJPEG is available. Using it for JPEG decoding.") +JPEG_DECODER, TURBO_AVAILABLE = create_turbojpeg() def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.ndarray]: diff --git a/faststack/imaging/turbo.py b/faststack/imaging/turbo.py new file mode 100644 index 0000000..a9742f3 --- /dev/null +++ b/faststack/imaging/turbo.py @@ -0,0 +1,103 @@ +"""TurboJPEG discovery helpers with Windows DLL fallbacks.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Optional, Tuple + +log = logging.getLogger(__name__) + +try: + from turbojpeg import TurboJPEG, TJPF_RGB +except ImportError: # pragma: no cover - exercised via create_turbojpeg + TurboJPEG = None + TJPF_RGB = None + + +def _candidate_library_paths() -> list[Optional[str]]: + """Return candidate libjpeg-turbo library paths to try in priority order.""" + candidates: list[Optional[str]] = [] + + explicit = os.getenv("FASTSTACK_TURBOJPEG_LIB") or os.getenv("TURBOJPEG_LIB") + if explicit: + candidates.append(explicit) + candidates.append(None) + + if os.name == "nt": + common_roots = [ + os.getenv("FASTSTACK_TURBOJPEG_ROOT"), + os.getenv("SystemDrive", "C:") + os.sep, + os.getenv("ProgramFiles"), + os.getenv("ProgramFiles(x86)"), + ] + suffixes = [ + ("libjpeg-turbo", "bin", "turbojpeg.dll"), + ("libjpeg-turbo64", "bin", "turbojpeg.dll"), + ("libjpeg-turbo-gcc64", "bin", "turbojpeg.dll"), + ("TurboJPEG", "bin", "turbojpeg.dll"), + ("bin", "turbojpeg.dll"), + ] + for root in common_roots: + if not root: + continue + for suffix in suffixes: + candidates.append(str(Path(root).joinpath(*suffix))) + + local_app_data = os.getenv("LOCALAPPDATA") + if local_app_data: + candidates.append( + str( + Path(local_app_data) + / "Programs" + / "libjpeg-turbo" + / "bin" + / "turbojpeg.dll" + ) + ) + + for path_dir in os.getenv("PATH", "").split(os.pathsep): + if path_dir: + candidates.append(str(Path(path_dir) / "turbojpeg.dll")) + + unique: list[Optional[str]] = [] + seen: set[str] = set() + for candidate in candidates: + key = "__default__" if candidate is None else os.path.normcase(candidate) + if key in seen: + continue + seen.add(key) + unique.append(candidate) + return unique + + +def create_turbojpeg() -> Tuple[Optional["TurboJPEG"], bool]: + """Create a TurboJPEG decoder if possible.""" + if TurboJPEG is None: + log.warning("PyTurboJPEG not found. Falling back to Pillow for JPEG decoding.") + return None, False + + failures: list[str] = [] + for candidate in _candidate_library_paths(): + try: + decoder = TurboJPEG() if candidate is None else TurboJPEG(candidate) + except Exception as exc: + source = "default loader" if candidate is None else candidate + failures.append(f"{source}: {exc}") + continue + + if candidate is None: + log.info("PyTurboJPEG is available. Using it for JPEG decoding.") + else: + log.info("Loaded TurboJPEG library from %s", candidate) + return decoder, True + + for failure in failures: + log.debug("TurboJPEG load attempt failed: %s", failure) + log.warning( + "TurboJPEG initialization failed (%d location(s) tried). " + "Falling back to Pillow for JPEG decoding.", + len(failures), + ) + return None, False diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py new file mode 100644 index 0000000..6e90094 --- /dev/null +++ b/faststack/tests/test_turbo.py @@ -0,0 +1,122 @@ +import importlib +import logging +from types import SimpleNamespace + + +def test_create_turbojpeg_prefers_explicit_env_path(monkeypatch): + turbo = importlib.import_module("faststack.imaging.turbo") + + calls = [] + + def fake_decoder(path=None): + calls.append(path) + if path == "C:/turbo/bin/turbojpeg.dll": + return SimpleNamespace(source=path) + raise RuntimeError(f"boom:{path}") + + monkeypatch.setattr(turbo, "TurboJPEG", fake_decoder) + monkeypatch.setenv("FASTSTACK_TURBOJPEG_LIB", "C:/turbo/bin/turbojpeg.dll") + + decoder, available = turbo.create_turbojpeg() + + assert available is True + assert decoder.source == "C:/turbo/bin/turbojpeg.dll" + assert calls == ["C:/turbo/bin/turbojpeg.dll"] + + +def test_create_turbojpeg_retries_default_loader_after_bad_env_override(monkeypatch): + turbo = importlib.import_module("faststack.imaging.turbo") + + calls = [] + + def fake_decoder(path=None): + calls.append(path) + if path == "/bad/turbojpeg.so": + raise RuntimeError("bad override") + if path is None: + return SimpleNamespace(source="default") + raise RuntimeError(f"unexpected path:{path}") + + monkeypatch.setattr(turbo, "TurboJPEG", fake_decoder) + monkeypatch.setattr(turbo.os, "name", "posix") + monkeypatch.setenv("FASTSTACK_TURBOJPEG_LIB", "/bad/turbojpeg.so") + monkeypatch.delenv("TURBOJPEG_LIB", raising=False) + + decoder, available = turbo.create_turbojpeg() + + assert available is True + assert decoder.source == "default" + assert calls == ["/bad/turbojpeg.so", None] + + +def test_all_candidates_fail_emits_one_warning(monkeypatch, caplog): + """When all locations fail, exactly one warning is emitted (not one per candidate).""" + turbo = importlib.import_module("faststack.imaging.turbo") + + def fake_decoder(path=None): + raise RuntimeError(f"boom:{path}") + + monkeypatch.setattr(turbo, "TurboJPEG", fake_decoder) + monkeypatch.setattr( + turbo, + "_candidate_library_paths", + lambda: [None, "C:/one/turbojpeg.dll", "C:/two/turbojpeg.dll"], + ) + + with caplog.at_level(logging.WARNING): + decoder, available = turbo.create_turbojpeg() + + assert decoder is None + assert available is False + + warning_records = [ + r for r in caplog.records if r.levelno == logging.WARNING + ] + assert len(warning_records) == 1 + assert "Falling back to Pillow" in warning_records[0].message + assert "3 location(s) tried" in warning_records[0].message + + +def test_all_candidates_fail_details_at_debug(monkeypatch, caplog): + """Per-candidate failure details are available at DEBUG level.""" + turbo = importlib.import_module("faststack.imaging.turbo") + + def fake_decoder(path=None): + raise RuntimeError(f"boom:{path}") + + monkeypatch.setattr(turbo, "TurboJPEG", fake_decoder) + monkeypatch.setattr( + turbo, + "_candidate_library_paths", + lambda: [None, "C:/one/turbojpeg.dll"], + ) + + with caplog.at_level(logging.DEBUG): + turbo.create_turbojpeg() + + debug_records = [ + r for r in caplog.records if r.levelno == logging.DEBUG + ] + debug_text = " ".join(r.message for r in debug_records) + assert "default loader" in debug_text + assert "C:/one/turbojpeg.dll" in debug_text + + +def test_missing_turbojpeg_package_emits_one_warning(monkeypatch, caplog): + """When the turbojpeg package is not installed, exactly one warning is emitted.""" + turbo = importlib.import_module("faststack.imaging.turbo") + + monkeypatch.setattr(turbo, "TurboJPEG", None) + + with caplog.at_level(logging.WARNING): + decoder, available = turbo.create_turbojpeg() + + assert decoder is None + assert available is False + + 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 "Pillow" in warning_records[0].message diff --git a/faststack/thumbnail_view/prefetcher.py b/faststack/thumbnail_view/prefetcher.py index 7874d21..afae214 100644 --- a/faststack/thumbnail_view/prefetcher.py +++ b/faststack/thumbnail_view/prefetcher.py @@ -18,6 +18,7 @@ from PIL import Image from contextlib import nullcontext +from faststack.imaging.turbo import TJPF_RGB, create_turbojpeg from faststack.util.executors import create_priority_executor from faststack.imaging.orientation import get_exif_orientation, apply_orientation_to_np from faststack.io.utils import compute_path_hash @@ -38,16 +39,10 @@ class _ReadyEmitter(QObject): _HAS_QT = False QCoreApplication = None -# Try to import turbojpeg for faster JPEG decoding -try: - from turbojpeg import TurboJPEG, TJPF_RGB - - _tj = TurboJPEG() - HAS_TURBOJPEG = True -except ImportError: - _tj = None - HAS_TURBOJPEG = False - log.debug("TurboJPEG not available, using PIL for thumbnail decoding") +# Try to initialize turbojpeg with shared discovery logic. +_tj, HAS_TURBOJPEG = create_turbojpeg() +if not HAS_TURBOJPEG: + log.debug("TurboJPEG unavailable, using PIL for thumbnail decoding") class ThumbnailPrefetcher: