From 2e8356005747e56b4a36a95282c948b212dc8228 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 18:03:54 +0200 Subject: [PATCH 1/3] Add more Leica cameras/lenses, Kodak Vision3 + Double-X, Ilford/Kentmere film stocks Rounds out the bundled gear library: current and historical Leica M-mount lenses and bodies, Vision3 250D/500T in both remjet and AHU variants, and Ilford/Kentmere black & white stocks alongside the existing HP5. --- gear/cameras.json | 63 +++++++++++++++++++ gear/film_stocks.json | 139 +++++++++++++++++++++++++++++++++++++++++ gear/gear_presets.json | 9 ++- gear/lenses.json | 24 +++++++ 4 files changed, 234 insertions(+), 1 deletion(-) diff --git a/gear/cameras.json b/gear/cameras.json index b0115dc6..00746260 100644 --- a/gear/cameras.json +++ b/gear/cameras.json @@ -1,4 +1,67 @@ [ + { + "id": "cam-leica-ma", + "make": "Leica", + "model": "M-A", + "displayName": "Leica M-A", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m2", + "make": "Leica", + "model": "M2", + "displayName": "Leica M2", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m3", + "make": "Leica", + "model": "M3", + "displayName": "Leica M3", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m4", + "make": "Leica", + "model": "M4", + "displayName": "Leica M4", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m42", + "make": "Leica", + "model": "M4-2", + "displayName": "Leica M4-2", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m4p", + "make": "Leica", + "model": "M4-P", + "displayName": "Leica M4-P", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m5", + "make": "Leica", + "model": "M5", + "displayName": "Leica M5", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-m6", + "make": "Leica", + "model": "M6", + "displayName": "Leica M6", + "notes": "35mm rangefinder." + }, + { + "id": "cam-leica-mp", + "make": "Leica", + "model": "MP", + "displayName": "Leica MP", + "notes": "35mm rangefinder." + }, { "id": "cam-canon-ae1p", "make": "Canon", diff --git a/gear/film_stocks.json b/gear/film_stocks.json index 3d9d0296..fcac9834 100644 --- a/gear/film_stocks.json +++ b/gear/film_stocks.json @@ -45,6 +45,95 @@ "format": "35mm", "colorType": "B&W Negative" }, + { + "id": "film-fp4-125", + "displayName": "Ilford FP4 Plus", + "manufacturer": "Ilford", + "stockName": "FP4 Plus 125", + "iso": 125, + "format": "35mm", + "colorType": "B&W Negative" + }, + { + "id": "film-delta-100", + "displayName": "Ilford Delta 100", + "manufacturer": "Ilford", + "stockName": "Delta 100 Professional", + "iso": 100, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Core-shell tabular grain, fine grain and high sharpness." + }, + { + "id": "film-delta-400", + "displayName": "Ilford Delta 400", + "manufacturer": "Ilford", + "stockName": "Delta 400 Professional", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Core-shell tabular grain, fine grain and high sharpness." + }, + { + "id": "film-delta-3200", + "displayName": "Ilford Delta 3200", + "manufacturer": "Ilford", + "stockName": "Delta 3200 Professional", + "iso": 3200, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "True speed ~1000, designed to be push processed." + }, + { + "id": "film-panf-50", + "displayName": "Ilford Pan F Plus", + "manufacturer": "Ilford", + "stockName": "Pan F Plus 50", + "iso": 50, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Ultra-fine grain, slow speed." + }, + { + "id": "film-xp2-400", + "displayName": "Ilford XP2 Super", + "manufacturer": "Ilford", + "stockName": "XP2 Super 400", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Chromogenic B&W on a C-41 process, wide exposure latitude." + }, + { + "id": "film-sfx-200", + "displayName": "Ilford SFX 200", + "manufacturer": "Ilford", + "stockName": "SFX 200", + "iso": 200, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Extended red sensitivity to ~740nm, near-infrared effects with a red filter." + }, + { + "id": "film-kentmere-100", + "displayName": "Kentmere 100", + "manufacturer": "Kentmere", + "stockName": "Kentmere 100", + "iso": 100, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Budget B&W, made by Ilford." + }, + { + "id": "film-kentmere-400", + "displayName": "Kentmere 400", + "manufacturer": "Kentmere", + "stockName": "Kentmere 400", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Budget B&W, made by Ilford." + }, { "id": "film-ektachrome-e100", "displayName": "Kodak Ektachrome E100", @@ -63,5 +152,55 @@ "format": "35mm", "colorType": "ColorNegative", "notes": "Tungsten-balanced color negative with characteristic halation." + }, + { + "id": "film-vision3-250d-remjet", + "displayName": "Kodak Vision3 250D", + "manufacturer": "Kodak", + "stockName": "Vision3 250D", + "iso": 250, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, daylight balanced. With remjet layer." + }, + { + "id": "film-vision3-250d-ahu", + "displayName": "Kodak Vision3 250D AHU", + "manufacturer": "Kodak", + "stockName": "Vision3 250D AHU", + "iso": 250, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, daylight balanced." + }, + { + "id": "film-vision3-500t-remjet", + "displayName": "Kodak Vision3 500T", + "manufacturer": "Kodak", + "stockName": "Vision3 500T", + "iso": 500, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, tungsten balanced. With remjet layer" + }, + { + "id": "film-vision3-500t-ahu", + "displayName": "Kodak Vision3 500T AHU", + "manufacturer": "Kodak", + "stockName": "Vision3 500T AHU", + "iso": 500, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Motion picture stock, tungsten balanced." + }, + { + "id": "film-double-x-250", + "displayName": "Kodak Double-X", + "manufacturer": "Kodak", + "stockName": "Double-X 250", + "iso": 250, + "format": "35mm", + "colorType": "B&W Negative", + "notes": "Motion picture panchromatic negative." } ] diff --git a/gear/gear_presets.json b/gear/gear_presets.json index 0772a046..7d2db64a 100644 --- a/gear/gear_presets.json +++ b/gear/gear_presets.json @@ -6,11 +6,18 @@ "lensId": "lens-canon-fd-50-1.4", "filmStockId": "film-portra-400" }, + { + "id": "preset-lma-s50-vision500t-ahu", + "displayName": "M-A / 50 f/2.0 / 500T AHU", + "cameraId": "cam-leica-ma", + "lensId": "lens-canon-fd-50-1.4", + "filmStockId": "film-vision3-500t-ahu" + }, { "id": "preset-fm2-50-trix", "displayName": "FM2 / Nikkor 50 f/1.8 / Tri-X 400", "cameraId": "cam-nikon-fm2", - "lensId": "lens-nikkor-50-1.8", + "lensId": "lens-leica-summicron-50-2.0", "filmStockId": "film-tri-x-400" } ] diff --git a/gear/lenses.json b/gear/lenses.json index 280509a6..a49df29e 100644 --- a/gear/lenses.json +++ b/gear/lenses.json @@ -15,6 +15,30 @@ "focalLength": 28, "maxAperture": 2.8 }, + { + "id": "lens-leica-summicron-28-2.0", + "displayName": "Leica Summicron 28mm f/2.0", + "lensModel": "Summicron 28mm f/2 ASPH", + "make": "Leica", + "focalLength": 28, + "maxAperture": 2.0 + }, + { + "id": "lens-leica-summilux-50-1.4", + "displayName": "Leica Summilux 50mm f/1.4", + "lensModel": "Summilux 50mm f/1.4 ASPH", + "make": "Leica", + "focalLength": 50, + "maxAperture": 1.4 + }, + { + "id": "lens-leica-summicron-50-2.0", + "displayName": "Leica Summicron 50mm f/2.0", + "lensModel": "Summicron 50mm f/2.0", + "make": "Leica", + "focalLength": 50, + "maxAperture": 2.0 + }, { "id": "lens-nikkor-50-1.8", "displayName": "Nikkor 50mm f/1.8 AI-S", From a0b9e675ab4683a04240d66da25ca36375fbd658 Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 18:04:01 +0200 Subject: [PATCH 2/3] Stop seeding gear/crosstalk into the docs folder; read bundled data live Gear and crosstalk profiles used to be copied into the user's docs folder on first launch, so bundled updates in later releases never reached installs that were already seeded. Both now load bundled data straight from the app bundle and merge it with the user's own additions (deduped by id/name, bundled wins), so future gear/crosstalk additions show up for everyone automatically. Bundled gear entries are read-only in the Gear Library dialog; duplicating one makes an editable, persistable copy. --- negpy/desktop/main.py | 4 +- .../view/widgets/gear_library_dialog.py | 11 ++- negpy/features/metadata/gear_models.py | 4 + negpy/services/assets/crosstalk.py | 43 ++++------- negpy/services/assets/gear.py | 57 +++++++------- tests/metadata/test_gear_payload.py | 74 ++++++++++++++++++- tests/test_crosstalk_profiles.py | 63 +++++++++++----- 7 files changed, 177 insertions(+), 79 deletions(-) diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 061e99ed..9d7d4e6c 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -57,8 +57,8 @@ def _bootstrap_environment() -> None: ] for d in dirs: os.makedirs(d, exist_ok=True) - CrosstalkProfiles.seed_example() - GearProfiles.seed_example() + CrosstalkProfiles.ensure_user_dir() + GearProfiles.ensure_user_dir() def main() -> None: diff --git a/negpy/desktop/view/widgets/gear_library_dialog.py b/negpy/desktop/view/widgets/gear_library_dialog.py index ed2b53ec..6eef025e 100644 --- a/negpy/desktop/view/widgets/gear_library_dialog.py +++ b/negpy/desktop/view/widgets/gear_library_dialog.py @@ -290,10 +290,18 @@ def _refresh_preset_combos(self) -> None: def _on_item_changed(self, row: int) -> None: if row < 0 or row >= len(self._current_items()): self._selected_idx = -1 + self._set_form_editable(True) self._clear_form() return self._selected_idx = row - self._populate_form(self._current_items()[row]) + item = self._current_items()[row] + self._set_form_editable(not item.is_bundled) + self._populate_form(item) + + def _set_form_editable(self, enabled: bool) -> None: + for _label, widget in self._form_rows.values(): + widget.setEnabled(enabled) + self.del_btn.setEnabled(enabled) def _populate_form(self, item) -> None: self._updating = True @@ -421,6 +429,7 @@ def _duplicate_item(self) -> None: from negpy.features.metadata.gear_models import _new_id dup.id = _new_id() + dup.is_bundled = False items.append(dup) self._set_current_items(items) GearProfiles.save_library(self._library) diff --git a/negpy/features/metadata/gear_models.py b/negpy/features/metadata/gear_models.py index af4780c4..0ca880a1 100644 --- a/negpy/features/metadata/gear_models.py +++ b/negpy/features/metadata/gear_models.py @@ -69,6 +69,7 @@ class Camera: display_name: str = "" serial_number: str = "" notes: str = "" + is_bundled: bool = False @property def resolved_display_name(self) -> str: @@ -108,6 +109,7 @@ class Lens: max_aperture: Optional[float] = None serial_number: str = "" notes: str = "" + is_bundled: bool = False @property def resolved_display_name(self) -> str: @@ -158,6 +160,7 @@ class FilmStock: format: FilmFormat = FilmFormat.FORMAT_35MM color_type: FilmColorType = FilmColorType.COLOR_NEGATIVE notes: str = "" + is_bundled: bool = False @property def resolved_display_name(self) -> str: @@ -219,6 +222,7 @@ class GearPreset: lens_id: str = "" film_stock_id: str = "" notes: str = "" + is_bundled: bool = False def to_dict(self) -> dict[str, Any]: return { diff --git a/negpy/services/assets/crosstalk.py b/negpy/services/assets/crosstalk.py index c2ee995c..2f37d876 100644 --- a/negpy/services/assets/crosstalk.py +++ b/negpy/services/assets/crosstalk.py @@ -1,5 +1,4 @@ import os -import shutil import tomllib from typing import List, Optional @@ -21,16 +20,15 @@ class CrosstalkProfiles: DEFAULT_NAME = DEFAULT_NAME @staticmethod - def _scan() -> dict: - """Maps display-name -> flat 9-float matrix for valid custom .toml files.""" + def _scan_dir(directory: str) -> dict: + """Maps display-name -> flat 9-float matrix for valid .toml files in a directory.""" result: dict = {} - crosstalk_dir = APP_CONFIG.crosstalk_dir - if not os.path.isdir(crosstalk_dir): + if not os.path.isdir(directory): return result - for fname in os.listdir(crosstalk_dir): + for fname in os.listdir(directory): if not fname.endswith(".toml"): continue - path = os.path.join(crosstalk_dir, fname) + path = os.path.join(directory, fname) parsed = CrosstalkProfiles._parse_file(path) if parsed is None: continue @@ -40,6 +38,13 @@ def _scan() -> dict: result[name] = matrix return result + @staticmethod + def _scan() -> dict: + """Bundled ∪ user custom matrices, keyed by display name; bundled wins.""" + bundled = CrosstalkProfiles._scan_dir(get_resource_path("crosstalk")) + user = CrosstalkProfiles._scan_dir(APP_CONFIG.crosstalk_dir) + return {**user, **bundled} + @staticmethod def _parse_file(path: str) -> Optional[tuple]: """Parses a .toml file to (name, flat 9-float list), or None if invalid.""" @@ -79,24 +84,6 @@ def get_matrix(name: str) -> Optional[List[float]]: return CrosstalkProfiles._scan().get(name) @staticmethod - def seed_example() -> None: - """Copy bundled gallery matrices into the user folder, per file. - - Skips a file that already exists in the user folder, so user edits - are never overwritten. New matrices bundled in later releases still - get copied in on next startup. - """ - crosstalk_dir = APP_CONFIG.crosstalk_dir - bundled_dir = get_resource_path("crosstalk") - try: - if not os.path.isdir(crosstalk_dir) or not os.path.isdir(bundled_dir): - return - for fname in os.listdir(bundled_dir): - if not fname.endswith(".toml"): - continue - dest = os.path.join(crosstalk_dir, fname) - if os.path.exists(dest): - continue - shutil.copyfile(os.path.join(bundled_dir, fname), dest) - except OSError: - pass + def ensure_user_dir() -> None: + """Make sure the user's crosstalk directory exists; no seeding.""" + os.makedirs(APP_CONFIG.crosstalk_dir, exist_ok=True) diff --git a/negpy/services/assets/gear.py b/negpy/services/assets/gear.py index 2a8865db..53f7b538 100644 --- a/negpy/services/assets/gear.py +++ b/negpy/services/assets/gear.py @@ -4,7 +4,6 @@ import json import os -import shutil from typing import TypeVar from negpy.features.metadata.gear_models import ( @@ -59,14 +58,32 @@ def _write_list(path: str, items: list) -> None: f.write("\n") os.replace(tmp, path) + @staticmethod + def _read_bundled_list(fname: str, factory: type[T]) -> list[T]: + items = GearProfiles._read_list(os.path.join(get_resource_path("gear"), fname), factory) + for item in items: + item.is_bundled = True # type: ignore[attr-defined] + return items + + @staticmethod + def _merge(bundled: list[T], user: list[T]) -> list[T]: + """Bundled ∪ user, deduped by id, bundled wins.""" + bundled_ids = {item.id for item in bundled} # type: ignore[attr-defined] + return bundled + [item for item in user if item.id not in bundled_ids] # type: ignore[attr-defined] + + @staticmethod + def _load_merged(fname: str, factory: type[T]) -> list[T]: + bundled = GearProfiles._read_bundled_list(fname, factory) + user = GearProfiles._read_list(os.path.join(GearProfiles._gear_dir(), fname), factory) + return GearProfiles._merge(bundled, user) + @staticmethod def load_library() -> GearLibrary: - base = GearProfiles._gear_dir() return GearLibrary( - cameras=GearProfiles._read_list(os.path.join(base, _CAMERAS_FILE), Camera), - lenses=GearProfiles._read_list(os.path.join(base, _LENSES_FILE), Lens), - film_stocks=GearProfiles._read_list(os.path.join(base, _FILM_STOCKS_FILE), FilmStock), - gear_presets=GearProfiles._read_list(os.path.join(base, _GEAR_PRESETS_FILE), GearPreset), + cameras=GearProfiles._load_merged(_CAMERAS_FILE, Camera), + lenses=GearProfiles._load_merged(_LENSES_FILE, Lens), + film_stocks=GearProfiles._load_merged(_FILM_STOCKS_FILE, FilmStock), + gear_presets=GearProfiles._load_merged(_GEAR_PRESETS_FILE, GearPreset), ) @staticmethod @@ -87,26 +104,12 @@ def save_gear_presets(presets: list[GearPreset]) -> None: @staticmethod def save_library(library: GearLibrary) -> None: - GearProfiles.save_cameras(library.cameras) - GearProfiles.save_lenses(library.lenses) - GearProfiles.save_film_stocks(library.film_stocks) - GearProfiles.save_gear_presets(library.gear_presets) + GearProfiles.save_cameras([c for c in library.cameras if not c.is_bundled]) + GearProfiles.save_lenses([lens for lens in library.lenses if not lens.is_bundled]) + GearProfiles.save_film_stocks([f for f in library.film_stocks if not f.is_bundled]) + GearProfiles.save_gear_presets([p for p in library.gear_presets if not p.is_bundled]) @staticmethod - def seed_example() -> None: - """Copy bundled starter gear JSON into the user folder when missing.""" - gear_dir = APP_CONFIG.gear_dir - bundled_dir = get_resource_path("gear") - try: - os.makedirs(gear_dir, exist_ok=True) - if not os.path.isdir(bundled_dir): - return - for fname in (_CAMERAS_FILE, _LENSES_FILE, _FILM_STOCKS_FILE, _GEAR_PRESETS_FILE): - dest = os.path.join(gear_dir, fname) - if os.path.exists(dest): - continue - src = os.path.join(bundled_dir, fname) - if os.path.isfile(src): - shutil.copyfile(src, dest) - except OSError: - pass + def ensure_user_dir() -> None: + """Make sure the user's gear directory exists; no seeding.""" + GearProfiles._gear_dir() diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py index da7aeee3..8deda1ff 100644 --- a/tests/metadata/test_gear_payload.py +++ b/tests/metadata/test_gear_payload.py @@ -1,5 +1,6 @@ """Tests for gear library and metadata payload resolution.""" +import json import os @@ -26,14 +27,81 @@ @pytest.fixture def gear_dir(tmp_path, monkeypatch): monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(tmp_path)) + monkeypatch.setattr("negpy.services.assets.gear.get_resource_path", lambda _: str(tmp_path / "_no_bundled")) return tmp_path -def test_seed_example_copies_bundled_files(gear_dir): - GearProfiles.seed_example() +def test_ensure_user_dir_creates_directory(tmp_path, monkeypatch): + target = tmp_path / "nested" / "gear" + monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(target)) - assert os.path.isfile(os.path.join(gear_dir, "cameras.json")) + assert not target.exists() + + GearProfiles.ensure_user_dir() + + assert target.is_dir() + + +def test_load_library_merges_bundled_and_user_deduped(tmp_path, monkeypatch): + bundled_dir = tmp_path / "bundled" + bundled_dir.mkdir() + user_dir = tmp_path / "user" + user_dir.mkdir() + monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(user_dir)) + monkeypatch.setattr("negpy.services.assets.gear.get_resource_path", lambda _: str(bundled_dir)) + + with open(bundled_dir / "cameras.json", "w", encoding="utf-8") as f: + json.dump([{"id": "cam-shared", "make": "Leica", "model": "M6"}], f) + with open(user_dir / "cameras.json", "w", encoding="utf-8") as f: + json.dump( + [ + {"id": "cam-shared", "make": "Leica", "model": "M6-EDITED"}, + {"id": "user-1", "make": "Pentax", "model": "K1000"}, + ], + f, + ) + + library = GearProfiles.load_library() + + assert [c.id for c in library.cameras] == ["cam-shared", "user-1"] + assert library.cameras[0].model == "M6" + assert library.cameras[0].is_bundled is True + assert library.cameras[1].is_bundled is False + + +def test_save_library_excludes_bundled_items(gear_dir): + library = GearLibrary( + cameras=[ + Camera(id="cam-bundled", make="Leica", is_bundled=True), + Camera(id="user-1", make="Pentax"), + ] + ) + + GearProfiles.save_library(library) + + on_disk = GearProfiles._read_list(os.path.join(gear_dir, "cameras.json"), Camera) + assert [c.id for c in on_disk] == ["user-1"] + + +def test_duplicate_bundled_item_is_editable_and_persistable(gear_dir): + from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog + + library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) + dlg = GearLibraryDialog(library) + + assert dlg.display_name_edit.isEnabled() is False + assert dlg.del_btn.isEnabled() is False + + dlg._duplicate_item() + + dup = dlg.library().cameras[-1] + assert dup.is_bundled is False + assert dup.id != "cam-bundled" + assert dlg.display_name_edit.isEnabled() is True + + on_disk = GearProfiles._read_list(os.path.join(gear_dir, "cameras.json"), Camera) + assert [c.id for c in on_disk] == [dup.id] def test_load_and_save_library(gear_dir): diff --git a/tests/test_crosstalk_profiles.py b/tests/test_crosstalk_profiles.py index 9e75fc4b..7651841b 100644 --- a/tests/test_crosstalk_profiles.py +++ b/tests/test_crosstalk_profiles.py @@ -1,5 +1,7 @@ import os +import pytest + from negpy.kernel.system.config import APP_CONFIG from negpy.services.assets.crosstalk import CrosstalkProfiles @@ -9,6 +11,11 @@ def _write(path, content): f.write(content) +@pytest.fixture(autouse=True) +def _isolate_bundled(tmp_path, monkeypatch): + monkeypatch.setattr("negpy.services.assets.crosstalk.get_resource_path", lambda _: str(tmp_path / "_no_bundled")) + + def test_list_and_get_custom(tmp_path, monkeypatch): monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(tmp_path)) @@ -44,35 +51,55 @@ def test_malformed_skipped(tmp_path, monkeypatch): assert CrosstalkProfiles.list_profiles() == ["Default"] -def test_seed_example(tmp_path, monkeypatch): +def test_ensure_user_dir_creates_directory(tmp_path, monkeypatch): + target = tmp_path / "nested" / "crosstalk" + monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(target)) + + assert not target.exists() + + CrosstalkProfiles.ensure_user_dir() + + assert target.is_dir() + + +def test_list_profiles_merges_bundled_and_user(tmp_path, monkeypatch): user_dir = tmp_path / "user" bundled_dir = tmp_path / "bundled" user_dir.mkdir() bundled_dir.mkdir() _write( - os.path.join(bundled_dir, "example.toml"), - 'name = "Example"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + os.path.join(bundled_dir, "portra_400.toml"), + 'name = "Portra 400"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + ) + _write( + os.path.join(user_dir, "my_film.toml"), + 'name = "My Film"\nmatrix = [[0.9, 0.1, 0.0], [0.0, 0.9, 0.1], [0.0, 0.0, 0.9]]\n', ) - _write(os.path.join(bundled_dir, "README.md"), "not a matrix\n") monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(user_dir)) monkeypatch.setattr("negpy.services.assets.crosstalk.get_resource_path", lambda _: str(bundled_dir)) - CrosstalkProfiles.seed_example() - tomls = [f for f in os.listdir(user_dir) if f.endswith(".toml")] - assert tomls == ["example.toml"] # README.md not copied - assert CrosstalkProfiles.list_profiles() == ["Default", "Example"] + assert CrosstalkProfiles.list_profiles() == ["Default", "My Film", "Portra 400"] + assert CrosstalkProfiles.get_matrix("Portra 400") == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] + assert CrosstalkProfiles.get_matrix("My Film") == [0.9, 0.1, 0.0, 0.0, 0.9, 0.1, 0.0, 0.0, 0.9] - # Re-running is a no-op for a file the user already has. - _write(os.path.join(user_dir, "example.toml"), 'name = "Edited"\nmatrix = [[1,0,0],[0,1,0],[0,0,1]]\n') - CrosstalkProfiles.seed_example() - assert CrosstalkProfiles.list_profiles() == ["Default", "Edited"] - # A matrix added to the bundled dir in a later release still gets copied in. +def test_bundled_wins_on_name_collision_dedup(tmp_path, monkeypatch): + user_dir = tmp_path / "user" + bundled_dir = tmp_path / "bundled" + user_dir.mkdir() + bundled_dir.mkdir() + _write( + os.path.join(bundled_dir, "portra_400.toml"), + 'name = "Portra 400"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + ) _write( - os.path.join(bundled_dir, "second.toml"), - 'name = "Second"\nmatrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]\n', + os.path.join(user_dir, "old_seeded_copy.toml"), + 'name = "Portra 400"\nmatrix = [[9.0, 9.0, 9.0], [9.0, 9.0, 9.0], [9.0, 9.0, 9.0]]\n', ) - CrosstalkProfiles.seed_example() - assert sorted(f for f in os.listdir(user_dir) if f.endswith(".toml")) == ["example.toml", "second.toml"] - assert CrosstalkProfiles.list_profiles() == ["Default", "Edited", "Second"] + + monkeypatch.setattr(APP_CONFIG, "crosstalk_dir", str(user_dir)) + monkeypatch.setattr("negpy.services.assets.crosstalk.get_resource_path", lambda _: str(bundled_dir)) + + assert CrosstalkProfiles.list_profiles() == ["Default", "Portra 400"] + assert CrosstalkProfiles.get_matrix("Portra 400") == [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] From 9c15fafc20a108578a718c8847a37c13cf85fa2c Mon Sep 17 00:00:00 2001 From: Marcin Zawalski Date: Tue, 7 Jul 2026 18:15:16 +0200 Subject: [PATCH 3/3] update presets --- gear/gear_presets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gear/gear_presets.json b/gear/gear_presets.json index 7d2db64a..1860582c 100644 --- a/gear/gear_presets.json +++ b/gear/gear_presets.json @@ -10,7 +10,7 @@ "id": "preset-lma-s50-vision500t-ahu", "displayName": "M-A / 50 f/2.0 / 500T AHU", "cameraId": "cam-leica-ma", - "lensId": "lens-canon-fd-50-1.4", + "lensId": "lens-leica-summicron-50-2.0", "filmStockId": "film-vision3-500t-ahu" }, {