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
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Fix: **A long status message now wraps instead of running off the canvas.** The toast was a single unwrapped line, which was fine for "merging exposures" but cut off anything that had to explain itself — such as why a file will not open. It now wraps within a share of the canvas width, using the room it has before folding; short toasts are unchanged.
- Fix: **A Nikon High Efficiency NEF now says why it cannot be opened.** The Z 8 and Z 9 can record raw in HE or HE*, which is intoPIX TicoRAW under a licensed codec. Such a file keeps the `.NEF` extension and the same TIFF compression tag as an ordinary lossless NEF, so it parses cleanly and reports full sensor dimensions, and only fails when the pixels are unpacked — as "Unsupported file format or not RAW file", which reads as a corrupt file to someone whose other NEFs all work. NegPy now recognises the payload and says what it is and what to do: re-shoot in Lossless Compressed, or convert to DNG. Decoding these is not possible without the codec. The check runs only after a decode has already failed, so files that work pay nothing for it.
- New: **Multi-core CPU rendering is now a setting** (canvas toolbar → **»**). It spreads the CPU rendering kernels across your cores and applies immediately, since every kernel is compiled both ways and picked per call. The kernels run 5-8x faster; a whole HDR merge comes down by about 10%, because most of a merge is decoding the RAW files and that is untouched. **Nothing changes for anyone who does not touch it**: Windows and Linux stay on, macOS stays off. macOS remains off pending wider evidence — Numba's threading layer terminates the process on concurrent entry, and although every parallel call is serialised behind one lock (verified on macOS: the unguarded path aborts within a second, the guarded one survived 68,720 concurrent invocations across 8 threads), that is one machine rather than the range of them. Because such an abort leaves no exception and no log entry, NegPy now records whether each session exited cleanly, and offers to turn the setting back off after a session that did not.
- Fix: **The session panel's toolbar measures its own contents correctly.** It sized every item by `sizeHint()`, which is only a preference — `setFixedWidth` does not change it, and a bare `QFrame` has none at all and reports -1. So the 1 px dividers were each given a 3 px slot that Qt then clamped the ink back into, leaving 2 px of dead space beside them and pushing buttons into the **»** menu sooner than the width required. The bar also reserved room for the **»** button before knowing one was needed, which on a bar ending in a divider evicted a real button and so conjured the very chevron it was reserving for. Both now measure what will actually be laid out. This is what `tests/test_overflow_bar.py::test_overflow_button_stays_hidden_when_only_a_separator_would_spill` has been failing on since #701 — on macOS, where a QToolButton's natural hint is wider than the width it is fixed to; CI runs Linux, where it is not.
- Fix: **Dialog buttons on macOS no longer underline a letter that does nothing.** Qt puts the mnemonic in its own standard-button text, so a Yes/No box asks for `&Yes`, and the Fusion style NegPy uses for the dark theme draws the underline. macOS has no mnemonic convention, though, so Qt binds no key there — the native style answers that hint the other way and draws nothing. The underline is now suppressed on macOS only; Alt+letter still works, and is still shown, on Windows and Linux.
Expand Down
2 changes: 2 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ NegPy reads the tree straight from disk and never creates, renames, moves or del

### Importing & managing files

**A note on Nikon High Efficiency raw.** The Z 8 and Z 9 can record NEFs in **High Efficiency (HE)** or **HE\***, which use a licensed codec NegPy cannot decode. Such a file is still called `.NEF` and still carries the same TIFF compression tag as an ordinary lossless NEF, so nothing about it looks unusual until it fails to open — NegPy names the reason rather than reporting a generic unsupported-file error. Re-shoot in **Lossless Compressed** NEF, or convert with Adobe DNG Converter. Lossless NEFs from the same cameras open normally.

Toolbar buttons, left to right:

* **Add files** / **Add folder**: load individual images or every image in a folder. Pick a folder that only holds *other* folders and NegPy reveals it in the Library section instead of reporting that it found nothing. Dropping a folder on the window does the same.
Expand Down
21 changes: 21 additions & 0 deletions negpy/desktop/view/canvas/hud.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
from negpy.desktop.view.styles.theme import THEME

_DEFAULT_TOAST_MS = 2500
#: Toast wrapping bounds. The ratio keeps a long message clear of the corner pills; the
#: floor stops it collapsing to a sliver on a narrow canvas.
_TOAST_WIDTH_RATIO = 0.55
_TOAST_MIN_WIDTH = 320
#: Horizontal padding in _TOAST_QSS, so a one-line measurement matches the rendered pill.
_TOAST_PADDING = 40

_PILL_QSS = (
f"color: {THEME.text_secondary}; font-size: {THEME.font_size_xs}px; font-weight: 500; "
Expand Down Expand Up @@ -46,6 +52,10 @@ def __init__(self, parent=None):
lbl.setStyleSheet(_PILL_QSS)
lbl.hide()
self.toast.setStyleSheet(_TOAST_QSS)
# Wraps rather than running off the canvas: a message that has to explain itself —
# why a file will not open, and what to do about it — does not fit one line.
self.toast.setWordWrap(True)
self.toast.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.toast.hide()

top = Qt.AlignmentFlag.AlignTop
Expand Down Expand Up @@ -74,6 +84,9 @@ def __init__(self, parent=None):
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self.progress.setGeometry(0, 0, self.width(), 3)
# A cap, not a width: wordWrap only wraps at the widget's width, and without a
# bound the label simply grows to the canvas. Short toasts still size to content.
self.toast.setMaximumWidth(max(_TOAST_MIN_WIDTH, int(self.width() * _TOAST_WIDTH_RATIO)))

@staticmethod
def _set_pill(lbl: QLabel, text: str) -> None:
Expand All @@ -93,6 +106,14 @@ def showMessage(self, text: str, timeout: int = 0) -> None:
self._toast_timer.stop()
self.toast.hide()
return
# Cap here as well as on resize: a toast can be posted before the HUD has ever been
# resized. And set the minimum too — a wrapping QLabel's sizeHint aims for a squarish
# block, so a long message folds into five narrow lines rather than using the width
# it is allowed. Short toasts keep their natural width.
cap = max(_TOAST_MIN_WIDTH, int(self.width() * _TOAST_WIDTH_RATIO))
one_line = self.toast.fontMetrics().horizontalAdvance(text) + _TOAST_PADDING
self.toast.setMaximumWidth(cap)
self.toast.setMinimumWidth(min(cap, one_line))
self.toast.setText(text.lower())
self.toast.show()
self._toast_timer.start(timeout if timeout > 0 else _DEFAULT_TOAST_MS)
Expand Down
10 changes: 8 additions & 2 deletions negpy/desktop/workers/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from negpy.features.hdr.models import HdrConfig, hdr_active
from negpy.features.geometry.batch_autocrop import detect_crop_candidate, resolve_roll_crops
from negpy.features.process.sensor import apply_sensor_correction, effective_sensor_matrix
from negpy.infrastructure.loaders.helpers import unsupported_raw_reason
from negpy.features.rgbscan.models import RgbScanConfig, is_rgb_triplet
from negpy.features.stitch.models import StitchConfig, stitch_active
from negpy.features.geometry.processor import GeometryProcessor
Expand Down Expand Up @@ -736,8 +737,13 @@ def process(self, task: PreviewLoadTask) -> None:
)
except Exception as e:
logger.exception(f"Asset load failed: {task.file_path}")
self.error.emit(str(e))
self.load_failed.emit(task.file_path, str(e))
# libraw reports "Unsupported file format or not RAW file" for a file whose
# tags it parsed perfectly and whose payload it cannot decode, which reads as
# "your NEF is broken". Ask why only once the decode has actually failed, so
# the check costs nothing on the files that work.
message = unsupported_raw_reason(task.file_path) or str(e)
self.error.emit(message)
self.load_failed.emit(task.file_path, message)

def _detect_mode(self, task: PreviewLoadTask, raw) -> str:
"""Classify film process mode; re-decode no-WB since the C41 mask is hidden by camera WB."""
Expand Down
44 changes: 44 additions & 0 deletions negpy/infrastructure/loaders/helpers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import io
from types import SimpleNamespace
from typing import Any, Optional, Tuple
Expand Down Expand Up @@ -228,6 +229,49 @@ def camera_wb_multipliers(raw: Any) -> Optional[list]:
return wb


#: Nikon's High Efficiency (HE / HE*) raw on the Z 8 / Z 9 is intoPIX TicoRAW carrying a
#: plain-text vendor marker at the head of the strip. The TIFF tag still reads 34713
#: ("Nikon NEF Compressed"), the same value a lossless NEF uses, so tags cannot tell them
#: apart -- only the payload can.
_TICORAW_MARKER = b"INTOPIX"
_NEF_COMPRESSED = 34713


def unsupported_raw_reason(file_path: str) -> Optional[str]:
"""Why libraw cannot decode this raw, in words a photographer can act on.

None when nothing recognised is wrong -- the caller then reports libraw's own error,
which is right for a genuinely corrupt or unknown file. This exists because the useful
cases are indistinguishable from corruption by their tags: a High Efficiency NEF parses
perfectly, reports full sensor dimensions, and only fails when the payload is unpacked.
"""
if os.path.splitext(file_path)[1].lower() != ".nef":
return None
try:
import tifffile

with tifffile.TiffFile(file_path) as tif:
for sub in tif.pages[0].pages or []:
tags = getattr(sub, "tags", None)
compression = tags.get("Compression") if tags else None
if compression is None or int(compression.value) != _NEF_COMPRESSED:
continue
offsets = tags.get("StripOffsets")
if offsets is None:
continue
offset = offsets.value[0] if isinstance(offsets.value, (tuple, list)) else int(offsets.value)
with open(file_path, "rb") as f:
f.seek(int(offset))
if _TICORAW_MARKER in f.read(64):
return (
"Nikon High Efficiency (HE) raw — NegPy cannot decode this format. "
"Re-shoot as Lossless Compressed, or convert to DNG."
)
except Exception:
return None
return None


def get_supported_raw_wildcards() -> str:
"""
Returns raw formats as string for file dialogs.
Expand Down
66 changes: 66 additions & 0 deletions tests/test_hud_toast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""A toast that has to explain something must stay on the canvas.

Most toasts are a few words ("merging exposures"), but a failure has to say what went wrong
and what to do about it. Unwrapped, such a message ran off both edges of the canvas.
"""

from PyQt6.QtWidgets import QWidget

from negpy.desktop.view.canvas.hud import _TOAST_WIDTH_RATIO, CanvasHud

LONG = "Nikon High Efficiency (HE) raw — NegPy cannot decode this format. Re-shoot as Lossless Compressed, or convert to DNG."
SHORT = "merging exposures"


def _hud(qapp, width=1400, height=900):
"""The parent is returned too: dropping it lets Qt delete the HUD as its child."""
parent = QWidget()
parent.resize(width, height)
hud = CanvasHud(parent)
hud.resize(width, height)
hud._keepalive = parent
return hud


def test_a_long_message_stays_within_the_canvas(qapp):
hud = _hud(qapp)
hud.showMessage(LONG, 5000)
hud.toast.adjustSize()
assert hud.toast.width() <= hud.width(), "the toast ran off the canvas"
assert hud.toast.width() <= int(hud.width() * _TOAST_WIDTH_RATIO) + 1, "it should leave room around itself"


def test_a_long_message_wraps_rather_than_truncating(qapp):
"""Wrapping, not eliding — the remedy is in the second half of the sentence."""
hud = _hud(qapp)
hud.showMessage(LONG, 5000)
hud.toast.adjustSize()
assert hud.toast.height() > hud.toast.fontMetrics().lineSpacing(), "expected more than one line"
assert hud.toast.text().replace("\n", " ").strip() == LONG.lower(), "no text may be dropped"


def test_a_short_message_keeps_its_natural_width(qapp):
"""The floor must not inflate an ordinary toast into a banner."""
hud = _hud(qapp)
hud.showMessage(SHORT, 3000)
hud.toast.adjustSize()
assert hud.toast.width() < int(hud.width() * _TOAST_WIDTH_RATIO), "a short toast should not fill the cap"
assert hud.toast.height() <= hud.toast.fontMetrics().lineSpacing() * 2


def test_it_fits_before_the_hud_has_ever_been_resized(qapp):
"""A load failure can post a toast during startup, before any resize event."""
parent = QWidget()
parent.resize(1200, 800)
hud = CanvasHud(parent)
hud._keepalive = parent
hud.showMessage(LONG, 5000)
hud.toast.adjustSize()
assert hud.toast.width() <= max(1200, hud.width())


def test_a_narrow_canvas_still_gets_a_usable_width(qapp):
hud = _hud(qapp, width=420)
hud.showMessage(LONG, 5000)
hud.toast.adjustSize()
assert hud.toast.width() >= 320, "the floor keeps it from collapsing to a sliver"
84 changes: 84 additions & 0 deletions tests/test_unsupported_raw_reason.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Explain a raw NegPy cannot decode, when the reason is knowable.

Nikon's High Efficiency raw (Z 8 / Z 9) is intoPIX TicoRAW, but its TIFF Compression tag
reads 34713 — the same value a lossless NEF uses. So the tags say "ordinary NEF", libraw
parses the file and reports full sensor dimensions, and the failure only arrives when the
payload is unpacked, as "Unsupported file format or not RAW file". That reads as "your file
is corrupt" to someone whose other NEFs all work.
"""

import struct

import pytest

from negpy.infrastructure.loaders.helpers import unsupported_raw_reason


def _nef(tmp_path, name, strip_payload: bytes, compression: int = 34713):
"""A minimal little-endian TIFF with one SubIFD carrying `strip_payload`.

Only the parts the detector reads: IFD0 with a SubIFDs tag, and a SubIFD with
Compression and StripOffsets.
"""
path = tmp_path / name
strip_offset = 512
sub_offset = 200
ifd0_offset = 8

def ifd(entries, next_ifd=0):
out = struct.pack("<H", len(entries))
for tag, typ, count, value in entries:
out += struct.pack("<HHI", tag, typ, count) + struct.pack("<I", value)
return out + struct.pack("<I", next_ifd)

buf = bytearray(b"\x00" * (strip_offset + len(strip_payload)))
buf[0:8] = struct.pack("<2sHI", b"II", 42, ifd0_offset)
buf[ifd0_offset : ifd0_offset + 100] = ifd([(330, 4, 1, sub_offset)]).ljust(100, b"\x00") # SubIFDs
sub = ifd(
[
(256, 3, 1, 8280), # ImageWidth
(257, 3, 1, 5520), # ImageLength
(258, 3, 1, 14), # BitsPerSample
(259, 3, 1, compression), # Compression
(273, 4, 1, strip_offset), # StripOffsets
(279, 4, 1, len(strip_payload)), # StripByteCounts
]
)
buf[sub_offset : sub_offset + len(sub)] = sub
buf[strip_offset : strip_offset + len(strip_payload)] = strip_payload
path.write_bytes(bytes(buf))
return str(path)


TICORAW = b"\xff\x10\xff\x50\x00\x22CONTACT_INTOPIX_\xef\xc0" + b"\x00" * 64
LOSSLESS = b"\x00\x4a\x00\x01\x00\x00" + bytes(range(64))


def test_high_efficiency_is_named(tmp_path):
reason = unsupported_raw_reason(_nef(tmp_path, "he.nef", TICORAW))
assert reason is not None
assert "High Efficiency" in reason
assert "Lossless" in reason and "DNG" in reason, "say what to do about it, not just what is wrong"


def test_an_ordinary_nef_has_no_complaint(tmp_path):
"""Same Compression tag, different payload — only the bytes can tell them apart."""
assert unsupported_raw_reason(_nef(tmp_path, "lossless.nef", LOSSLESS)) is None


@pytest.mark.parametrize("name", ["shot.cr2", "shot.arw", "shot.dng", "scan.tif"])
def test_only_nef_is_inspected(tmp_path, name):
"""The marker is Nikon-specific; other formats fail for their own reasons and keep
libraw's own message."""
assert unsupported_raw_reason(_nef(tmp_path, name, TICORAW)) is None


def test_a_corrupt_file_yields_no_false_explanation(tmp_path):
"""Nothing recognised means libraw's own error is the honest answer."""
p = tmp_path / "broken.nef"
p.write_bytes(b"not a tiff at all")
assert unsupported_raw_reason(str(p)) is None


def test_a_missing_file_does_not_raise(tmp_path):
assert unsupported_raw_reason(str(tmp_path / "absent.nef")) is None
Loading