Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 3 additions & 18 deletions faststack/imaging/jpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
103 changes: 103 additions & 0 deletions faststack/imaging/turbo.py
Original file line number Diff line number Diff line change
@@ -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)"),
]
Comment thread
AlanRockefeller marked this conversation as resolved.
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
122 changes: 122 additions & 0 deletions faststack/tests/test_turbo.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 5 additions & 10 deletions faststack/thumbnail_view/prefetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading