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
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Change Log


## 0.33.1

- Fix: **exporting all RGB-scan triplets no longer fails** with "Input/output error" on most frames. Batch export was reusing stale saved paths for each frame's green/blue exposures instead of the ones the triplet was actually built from, so it tried to read files that weren't there; it now uses each frame's own exposures, the same as exporting one at a time.


## 0.33.0

- New: **Temperature slider** — a Kelvin lever above the CMY white balance: drag it and Magenta/Yellow move together along the warm–cool axis in the right ratio, like re-dialing a dichroic filter pack, while your green–magenta tint stays put. Move M/Y (or Pick WB) yourself and the slider reads back the print's temperature instead. Warm sits on the right, travel is mired-linear (equal drag = equal perceived shift), and `T`/`G` nudge it from the keyboard. The thermometer button locks the temperature for the roll: every frame you open gets re-aimed to it — keeping its own tint — and the lock survives restarts.
Expand Down
15 changes: 11 additions & 4 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from PyQt6.QtWidgets import QMessageBox

from negpy.desktop.converters import ImageConverter
from negpy.desktop.session import AppState, DesktopSessionManager, ToolMode
from negpy.desktop.session import AppState, DesktopSessionManager, ToolMode, resolve_asset_rgbscan
from negpy.desktop.workers.export import ExportTask, ExportWorker
from negpy.desktop.workers.render import (
AssetDiscoveryTask,
Expand Down Expand Up @@ -1338,6 +1338,13 @@ def _validate_preset_paths(self, presets: List[ExportPreset]) -> bool:
self.session.save_export_presets()
return True

def _batch_params_for(self, f: dict) -> WorkspaceConfig:
"""Resolve a visible frame's export params: its saved DB config (else the current
config), with its own RGB-scan green/blue re-injected from the asset dict — the
same authoritative source individual export gets via select_file."""
params = self.session.repo.load_file_settings(f["hash"]) or self.state.config
return resolve_asset_rgbscan(params, f)

def _tasks_for_file(
self,
file_info: dict,
Expand Down Expand Up @@ -1481,7 +1488,7 @@ def request_batch_export(self, override_settings: bool = False, files: list[dict

tasks = []
for f in files:
params = self.session.repo.load_file_settings(f["hash"]) or self.state.config
params = self._batch_params_for(f)

if override_settings:
params = replace(params, export=current_export)
Expand Down Expand Up @@ -1587,7 +1594,7 @@ def request_preset_batch_export(self) -> None:

tasks = []
for f in visible_files:
params = self.session.repo.load_file_settings(f["hash"]) or self.state.config
params = self._batch_params_for(f)

bounds_override = None
if f["hash"] == self.state.current_file_hash:
Expand Down Expand Up @@ -1632,7 +1639,7 @@ def request_contact_sheet(self) -> None:

tasks = []
for f in visible_files:
params = self.session.repo.load_file_settings(f["hash"]) or self.state.config
params = self._batch_params_for(f)
tasks.append(
ExportTask(
file_info=f,
Expand Down
12 changes: 12 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from PyQt6.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal

from negpy.domain.models import ExportPreset, WorkspaceConfig
from negpy.features.rgbscan.models import RgbScanConfig
from negpy.infrastructure.storage.repository import StorageRepository
from negpy.kernel.system.config import APP_CONFIG
from negpy.services.assets.sidecar import load_or_promote
Expand Down Expand Up @@ -335,6 +336,17 @@ def build_synced_config(
return out


def resolve_asset_rgbscan(params: WorkspaceConfig, asset: dict) -> WorkspaceConfig:
"""Overlay a frame's own RGB-scan triplet paths (from the asset dict) onto its export
params — the authoritative source select_file uses. A non-triplet frame gets rgbscan
reset so a batch frame never inherits the currently-open frame's leaked/stale triplet."""
green, blue = asset.get("green_path"), asset.get("blue_path")
if green and blue:
align = bool(asset.get("align", params.rgbscan.align))
return replace(params, rgbscan=RgbScanConfig(enabled=True, green_path=green, blue_path=blue, align=align))
return replace(params, rgbscan=RgbScanConfig())


class DesktopSessionManager(QObject):
"""
Manages application state, file list, and configuration persistence.
Expand Down
4 changes: 4 additions & 0 deletions negpy/services/rendering/image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ def _load_source_f32(
if is_triplet:
# Assemble one frame from the R/G/B exposures; the primary (red) file is
# already decoded above, so reuse it and decode only green/blue.
for label, path in (("green", rgbcfg.green_path), ("blue", rgbcfg.blue_path)):
if not os.path.exists(path):
raise FileNotFoundError(f"RGB-scan {label} exposure not found: {path}")

def _decode(path: str) -> np.ndarray:
if path == file_path:
return rgb
Expand Down
10 changes: 7 additions & 3 deletions tests/test_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,22 @@ def test_load_source_f32_cache_key_separates_fast_decode(monkeypatch) -> None:
assert calls == [True, False]


def test_load_source_f32_never_fast_decodes_rgbscan_triplets(monkeypatch) -> None:
def test_load_source_f32_never_fast_decodes_rgbscan_triplets(monkeypatch, tmp_path) -> None:
from dataclasses import replace

from negpy.features.rgbscan.models import RgbScanConfig

r, g, b = (tmp_path / n for n in ("r.raw", "g.raw", "b.raw"))
for f in (r, g, b):
f.write_bytes(b"x")

service = ImageProcessor()
calls: list = []
monkeypatch.setattr(service, "_decode_sensor_rgb", _fake_decode_recorder(calls))
cfg = replace(
WorkspaceConfig(),
rgbscan=RgbScanConfig(enabled=True, green_path="/nonexistent/g.raw", blue_path="/nonexistent/b.raw", align=False),
rgbscan=RgbScanConfig(enabled=True, green_path=str(g), blue_path=str(b), align=False),
)

service._load_source_f32("/nonexistent/r.raw", cfg, fast_decode=True)
service._load_source_f32(str(r), cfg, fast_decode=True)
assert calls and calls[0] is False
28 changes: 28 additions & 0 deletions tests/test_rgbscan.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from dataclasses import replace

import numpy as np
import pytest
Expand Down Expand Up @@ -203,3 +204,30 @@ def test_config_roundtrip_preserves_rgbscan():
cfg = type(cfg)(**{**cfg.__dict__, "rgbscan": RgbScanConfig(enabled=True, green_path="/g", blue_path="/b")})
restored = WorkspaceConfig.from_flat_dict(cfg.to_dict())
assert restored.rgbscan == cfg.rgbscan


def test_resolve_asset_rgbscan_injects_from_asset():
"""Batch export must use the asset dict's own green/blue, overriding a stale DB config."""
from negpy.desktop.session import resolve_asset_rgbscan

stale = replace(WorkspaceConfig(), rgbscan=RgbScanConfig(enabled=True, green_path="/old_g", blue_path="/old_b"))
asset = {"path": "/r", "green_path": "/g", "blue_path": "/b"}
out = resolve_asset_rgbscan(stale, asset)
assert out.rgbscan == RgbScanConfig(enabled=True, green_path="/g", blue_path="/b", align=True)


def test_resolve_asset_rgbscan_honors_align():
from negpy.desktop.session import resolve_asset_rgbscan

asset = {"green_path": "/g", "blue_path": "/b", "align": False}
out = resolve_asset_rgbscan(WorkspaceConfig(), asset)
assert out.rgbscan.align is False


def test_resolve_asset_rgbscan_resets_when_not_triplet():
"""A non-triplet frame must not inherit a leaked/enabled triplet config."""
from negpy.desktop.session import resolve_asset_rgbscan

leaked = replace(WorkspaceConfig(), rgbscan=RgbScanConfig(enabled=True, green_path="/g", blue_path="/b"))
out = resolve_asset_rgbscan(leaked, {"path": "/r"})
assert out.rgbscan == RgbScanConfig()
Loading