diff --git a/README.md b/README.md index 9467746..a446ace 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,51 @@ 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 +Unable to locate turbojpeg library automatically. +You may specify the turbojpeg library path manually. +``` + +that means the Python package is installed but the native `turbojpeg.dll` is not available in a location that `PyTurboJPEG` can find. + +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..059b640 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -7,34 +7,18 @@ 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]: """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 + # Decode with proper color space handling (no TJFLAG_FASTDCT). flags = 0 if fast_dct: # TJFLAG_FASTDCT = 2048 @@ -42,9 +26,7 @@ def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.nd return JPEG_DECODER.decode(jpeg_bytes, 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: img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") return np.array(img) @@ -59,17 +41,14 @@ def decode_jpeg_thumb_rgb( """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( jpeg_bytes, 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) @@ -81,7 +60,6 @@ def decode_jpeg_thumb_rgb( "PyTurboJPEG failed to decode thumbnail: %s. Trying Pillow.", e ) - # Fallback to Pillow try: img = Image.open(BytesIO(jpeg_bytes)) img.thumbnail((max_dim, max_dim)) @@ -98,7 +76,6 @@ def _get_turbojpeg_scaling_factor( if not TURBO_AVAILABLE or not JPEG_DECODER: return None - # PyTurboJPEG provides a set of supported scaling factors supported_factors = sorted( JPEG_DECODER.scaling_factors, key=lambda x: x[0] / x[1], @@ -109,7 +86,6 @@ def _get_turbojpeg_scaling_factor( if (width * num / den) <= max_dim and (height * num / den) <= max_dim: return (num, den) - # If no suitable factor is found, return the smallest one return supported_factors[-1] if supported_factors else None @@ -122,15 +98,11 @@ def decode_jpeg_resized( 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) @@ -138,27 +110,23 @@ def decode_jpeg_resized( if scale_factor: flags = 0 if fast_dct: - # TJFLAG_FASTDCT = 2048 flags |= 2048 decoded = JPEG_DECODER.decode( jpeg_bytes, scaling_factor=scale_factor, pixel_format=TJPF_RGB, - flags=flags, # Proper color space handling + flags=flags, ) - # Only use Pillow for final resize if needed if decoded.shape[0] > height or decoded.shape[1] > width: img = Image.fromarray(decoded) - # Use BILINEAR for speed img.thumbnail((width, height), Image.Resampling.BILINEAR) return np.array(img) return decoded except Exception as e: log.exception("PyTurboJPEG failed: %s", e) - # Fallback to Pillow (existing code) try: img = Image.open(BytesIO(jpeg_bytes)) @@ -167,13 +135,10 @@ def decode_jpeg_resized( scale_factor_ratio = min(img.width / width, img.height / height) - # Use faster BILINEAR for large downscales, LANCZOS for smaller if scale_factor_ratio > 4: - resampling = Image.Resampling.BILINEAR # Much faster + resampling = Image.Resampling.BILINEAR else: - resampling = ( - Image.Resampling.LANCZOS - ) # Higher quality for smaller downscales + resampling = Image.Resampling.LANCZOS img.thumbnail((width, height), resampling) return np.array(img.convert("RGB")) diff --git a/faststack/imaging/turbo.py b/faststack/imaging/turbo.py new file mode 100644 index 0000000..6fc0499 --- /dev/null +++ b/faststack/imaging/turbo.py @@ -0,0 +1,101 @@ +"""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("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.warning("TurboJPEG load attempt failed: %s", failure) + log.warning( + "TurboJPEG initialization failed for all attempted locations. " + "Falling back to Pillow." + ) + return None, False diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index ab684a4..57859b0 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -4,14 +4,54 @@ import logging.handlers import os from pathlib import Path +from tempfile import NamedTemporaryFile, gettempdir + + +def _is_writable_directory(path: Path) -> bool: + """Return True when the directory exists and a temp file can be created there.""" + try: + if not path.exists() or not path.is_dir(): + return False + with NamedTemporaryFile(dir=path, prefix=".faststack-write-test-", delete=True): + pass + return True + except OSError: + return False + + +def _can_create_directory(path: Path) -> bool: + """Return True when the target directory can be created without creating it yet.""" + parent = path.parent + while not parent.exists() and parent != parent.parent: + parent = parent.parent + return _is_writable_directory(parent) def get_app_data_dir() -> Path: - """Returns the application data directory.""" + """Return a writable application data directory path.""" + candidates = [] + + override = os.getenv("FASTSTACK_APPDATA") + if override: + candidates.append(Path(override)) + app_data = os.getenv("APPDATA") if app_data: - return Path(app_data) / "faststack" - return Path.home() / ".faststack" + candidates.append(Path(app_data) / "faststack") + + local_app_data = os.getenv("LOCALAPPDATA") + if local_app_data: + candidates.append(Path(local_app_data) / "faststack") + + candidates.append(Path.home() / ".faststack") + candidates.append(Path(gettempdir()) / "faststack") + candidates.append(Path.cwd() / "var" / "appdata") + + for candidate in candidates: + if _is_writable_directory(candidate) or _can_create_directory(candidate): + return candidate + + return Path.cwd() / "var" / "appdata" def setup_logging(debug: bool = False): @@ -20,36 +60,34 @@ def setup_logging(debug: bool = False): Args: debug: If True, sets log level to DEBUG. Otherwise, sets to WARNING to reduce noise. """ - log_dir = get_app_data_dir() / "logs" - log_dir.mkdir(parents=True, exist_ok=True) - log_file = log_dir / "app.log" - - # 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" ) - 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.WARNING) root_logger.handlers.clear() - root_logger.addHandler(file_handler) root_logger.addHandler(console_handler) - # Configure logging for key modules + try: + log_dir = get_app_data_dir() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / "app.log" + file_handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=10 * 1024 * 1024, backupCount=5 + ) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + except OSError as exc: + root_logger.warning("File logging disabled: %s", exc) + if debug: logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG) logging.getLogger("faststack.imaging.prefetch").setLevel(logging.DEBUG) else: - # In non-debug mode, only log errors from these noisy modules logging.getLogger("faststack.imaging.cache").setLevel(logging.ERROR) logging.getLogger("faststack.imaging.prefetch").setLevel(logging.ERROR) logging.getLogger("PIL").setLevel(logging.INFO) diff --git a/faststack/tests/test_turbo.py b/faststack/tests/test_turbo.py new file mode 100644 index 0000000..12f0ed5 --- /dev/null +++ b/faststack/tests/test_turbo.py @@ -0,0 +1,151 @@ +import importlib +import logging +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + + +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_create_turbojpeg_logs_failed_candidates(monkeypatch, caplog): + 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 + assert "default loader" in caplog.text + assert "C:/one/turbojpeg.dll" in caplog.text + assert "C:/two/turbojpeg.dll" in caplog.text + assert "Falling back to Pillow" in caplog.text + + +def test_get_app_data_dir_falls_back_when_appdata_is_not_creatable(monkeypatch, tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + home_dir = tmp_path / "home" + home_dir.mkdir() + fallback_dir = home_dir / ".faststack" + blocked_candidate = tmp_path / "blocked" / "faststack" + + monkeypatch.setenv("APPDATA", str(tmp_path / "blocked")) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: home_dir) + monkeypatch.setattr( + logging_setup, + "_can_create_directory", + lambda path: False if path == blocked_candidate else True, + ) + + assert logging_setup.get_app_data_dir() == fallback_dir + + +def test_get_app_data_dir_falls_back_to_tempdir(monkeypatch, tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + home_dir = tmp_path / "home" + temp_dir = tmp_path / "tmp" + temp_dir.mkdir() + home_candidate = home_dir / ".faststack" + + monkeypatch.delenv("FASTSTACK_APPDATA", raising=False) + monkeypatch.delenv("APPDATA", raising=False) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: home_dir) + monkeypatch.setattr(logging_setup, "gettempdir", lambda: str(temp_dir)) + monkeypatch.setattr( + logging_setup, + "_can_create_directory", + lambda path: ( + False + if path == home_candidate + else logging_setup._is_writable_directory(path.parent) + ), + ) + + assert logging_setup.get_app_data_dir() == temp_dir / "faststack" + + +def test_is_writable_directory_does_not_create_missing_dir(tmp_path): + logging_setup = importlib.import_module("faststack.logging_setup") + + missing = tmp_path / "missing" + + assert logging_setup._is_writable_directory(missing) is False + assert missing.exists() is False + + +def test_setup_logging_keeps_console_handler_when_file_logging_fails(monkeypatch): + logging_setup = importlib.import_module("faststack.logging_setup") + + monkeypatch.setattr(logging_setup, "get_app_data_dir", lambda: Path("/bad-path")) + monkeypatch.setattr( + logging_setup.logging.handlers, + "RotatingFileHandler", + Mock(side_effect=OSError("disk full")), + ) + + root_logger = logging.getLogger() + original_handlers = list(root_logger.handlers) + try: + logging_setup.setup_logging(debug=True) + assert any( + isinstance(handler, logging.StreamHandler) for handler in root_logger.handlers + ) + finally: + root_logger.handlers = original_handlers 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: