From 9d75ac256e0410fff0259e2d745bb015a555997b Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 1 Aug 2026 20:06:39 +0200 Subject: [PATCH 1/4] Add selectable EXIF ImageDescription fields for process and scanning metadata --- docs/USER_GUIDE.md | 2 +- negpy/desktop/session.py | 11 +++++ negpy/desktop/settings_catalog.py | 6 +++ negpy/desktop/view/sidebar/metadata.py | 30 +++++++++++- .../view/widgets/description_fields_dialog.py | 41 ++++++++++++++++ negpy/features/metadata/models.py | 49 ++++++++++++++++++- negpy/features/metadata/payload.py | 41 ++++++++++------ tests/metadata/test_gear_payload.py | 45 +++++++++++++++++ 8 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 negpy/desktop/view/widgets/description_fields_dialog.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 26247951..48e6cb5a 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -519,7 +519,7 @@ Archival metadata for the **original analog capture** (camera, lens, film, proce **Exposure**: optional original shutter/aperture/ISO. Click the lock to edit a free-text string (e.g. `1/125s f/2.8 ISO 400`). -**Metadata preview**: a live view of exactly what will be embedded, grouped by capture / scan / process / file. +**Metadata preview**: a live view of exactly what will be embedded, grouped by capture / scan / process / file. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. Defaults are camera, lens, film stock, and ISO — format, developer, push/pull, and scanning are off until you enable them. The selection is saved per frame; new frames inherit your last choice. Sync metadata / Sync settings copies it with the rest of the metadata. When you set capture gear, it's written to standard EXIF and the digitizing rig is preserved separately in `negpy:Scan*` XMP tags. Leave gear unset and your scanner/DSLR stays visible in EXIF instead. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 955101cf..661d68b0 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -557,6 +557,16 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa metadata=replace(config.metadata, protect_original_metadata=bool(sticky_protect)), ) + if not only_global: + sticky_desc = self.repo.get_global_setting("last_description_fields") + if sticky_desc is not None: + from negpy.features.metadata.models import normalize_description_fields + + config = replace( + config, + metadata=replace(config.metadata, description_fields=normalize_description_fields(sticky_desc)), + ) + # Flat-field profile and distortion k1 are rig-global: the active profile's # values always override the per-file ones. New files default to enabled when a # profile is active; saved files keep their toggle. @@ -738,6 +748,7 @@ def _persist_sticky_settings(self, config: WorkspaceConfig) -> None: "last_retouch_config": asdict(config.retouch), "last_dust_remove": config.retouch.dust_remove, "last_protect_original_metadata": config.metadata.protect_original_metadata, + "last_description_fields": list(config.metadata.description_fields), } ) diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index c14c9c11..5c335f7d 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -193,6 +193,12 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: _row("Scanning", "metadata", "scanning"), _row("Exposure Override", "metadata", "exposure_override"), _row("Protect Original Metadata", "metadata", "protect_original_metadata"), + _row( + "Description Fields", + "metadata", + "description_fields", + fmt=lambda v: ", ".join(str(x) for x in (v[0] or ())) or "—", + ), )), ("Export", ( _row("Format", "export", "export_fmt"), diff --git a/negpy/desktop/view/sidebar/metadata.py b/negpy/desktop/view/sidebar/metadata.py index e9f8c562..a5aed572 100644 --- a/negpy/desktop/view/sidebar/metadata.py +++ b/negpy/desktop/view/sidebar/metadata.py @@ -17,10 +17,12 @@ from negpy.desktop.view.styles.templates import field_label, hint_label from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.collapsible import CollapsibleSection +from negpy.desktop.view.widgets.description_fields_dialog import DescriptionFieldsDialog from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog from negpy.desktop.view.widgets.searchable_gear_combo import SearchableGearCombo from negpy.features.metadata.gear_logic import metadata_from_gear from negpy.features.metadata.gear_models import GearLibrary +from negpy.features.metadata.models import DEFAULT_DESCRIPTION_FIELDS from negpy.features.metadata.payload import build_metadata_payload from negpy.services.assets.gear import GearProfiles @@ -50,6 +52,7 @@ def _init_ui(self) -> None: self._dirty = False self._exif_locked = {"exposure": True} + self._description_fields: tuple[str, ...] = conf.description_fields or DEFAULT_DESCRIPTION_FIELDS self.protect_check = QCheckBox("Protect original metadata") self.protect_check.setChecked(conf.protect_original_metadata) @@ -161,8 +164,15 @@ def _init_ui(self) -> None: preview_layout.setContentsMargins(0, 0, 0, 0) preview_layout.setSpacing(4) + preview_top = QHBoxLayout() + preview_top.setContentsMargins(0, 0, 0, 0) + preview_top.setSpacing(THEME.space_sm) preview_hint = hint_label("Written to exported files on export.") - preview_layout.addWidget(preview_hint) + preview_top.addWidget(preview_hint, 1) + self.description_fields_btn = QPushButton("Description…") + self.description_fields_btn.setToolTip("Choose which fields join into EXIF ImageDescription.") + preview_top.addWidget(self.description_fields_btn) + preview_layout.addLayout(preview_top) self.preview_rows = QVBoxLayout() self.preview_rows.setSpacing(2) @@ -216,6 +226,7 @@ def _make_exif_field(self, key: str, layout: QVBoxLayout) -> QLineEdit: def _set_metadata_controls_enabled(self, enabled: bool) -> None: self._metadata_controls.setEnabled(enabled) + self.description_fields_btn.setEnabled(enabled) def _apply_lock_style(self, edit: QLineEdit, locked: bool) -> None: if locked: @@ -243,6 +254,7 @@ def _toggle_exif_lock(self, key: str, edit: QLineEdit, btn: QToolButton, checked def _connect_signals(self) -> None: self.protect_check.toggled.connect(self._on_protect_toggled) + self.description_fields_btn.clicked.connect(self._open_description_fields) self.preset_combo.selection_changed.connect(self._on_preset_changed) self.preset_clear_btn.clicked.connect(self._on_preset_clear) self.camera_combo.selection_changed.connect(self._on_gear_changed) @@ -271,6 +283,20 @@ def _on_protect_toggled(self, checked: bool) -> None: ) self._schedule_preview() + def _open_description_fields(self) -> None: + dlg = DescriptionFieldsDialog(self._description_fields, self) + if dlg.exec() != dlg.DialogCode.Accepted: + return + self._description_fields = dlg.selected_fields() + self.update_config_section( + "metadata", + persist=True, + render=False, + readback_metrics=False, + description_fields=self._description_fields, + ) + self._schedule_preview() + def _refresh_gear_combos(self, *, force: bool = False) -> None: conf = self.state.config.metadata self._gear_library = GearProfiles.load_library() @@ -431,6 +457,7 @@ def _persist_all_metadata_settings(self) -> None: scanning=self.scanning_edit.text().strip(), sync_to_batch=self.sync_check.isChecked(), exposure_override=exposure_override, + description_fields=self._description_fields, ) def sync_ui(self) -> None: @@ -456,6 +483,7 @@ def sync_ui(self) -> None: self.push_pull_combo.setCurrentIndex(idx) self.scanning_edit.setText(conf.scanning) self.sync_check.setChecked(conf.sync_to_batch) + self._description_fields = conf.description_fields if conf.exposure_override: self._set_exif_text_quiet("exposure", conf.exposure_override) diff --git a/negpy/desktop/view/widgets/description_fields_dialog.py b/negpy/desktop/view/widgets/description_fields_dialog.py new file mode 100644 index 00000000..d441030e --- /dev/null +++ b/negpy/desktop/view/widgets/description_fields_dialog.py @@ -0,0 +1,41 @@ +from PyQt6.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QVBoxLayout + +from negpy.desktop.view.styles.templates import hint_label +from negpy.features.metadata.models import ( + DESCRIPTION_FIELD_LABELS, + DESCRIPTION_FIELD_ORDER, + normalize_description_fields, +) + + +class DescriptionFieldsDialog(QDialog): + """Pick which metadata values join into EXIF ImageDescription.""" + + def __init__(self, selected: object, parent=None): + super().__init__(parent) + self.setWindowTitle("Description fields") + self.setMinimumWidth(320) + + root = QVBoxLayout(self) + root.addWidget( + hint_label( + "Checked fields are joined with • into the export ImageDescription. " + "Empty values are skipped." + ) + ) + + enabled = set(normalize_description_fields(selected)) + self._checks: dict[str, QCheckBox] = {} + for key in DESCRIPTION_FIELD_ORDER: + box = QCheckBox(DESCRIPTION_FIELD_LABELS[key]) + box.setChecked(key in enabled) + self._checks[key] = box + root.addWidget(box) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + root.addWidget(buttons) + + def selected_fields(self) -> tuple[str, ...]: + return normalize_description_fields(key for key, box in self._checks.items() if box.isChecked()) diff --git a/negpy/features/metadata/models.py b/negpy/features/metadata/models.py index a63f30e2..0f09e13d 100644 --- a/negpy/features/metadata/models.py +++ b/negpy/features/metadata/models.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional @@ -12,6 +12,45 @@ 3: "Push +3", } +# Ordered keys for EXIF ImageDescription; only non-empty values are joined. +DESCRIPTION_FIELD_ORDER: tuple[str, ...] = ( + "camera", + "lens", + "film", + "iso", + "format", + "developer", + "push_pull", + "scanning", +) +DESCRIPTION_FIELD_LABELS: dict[str, str] = { + "camera": "Camera", + "lens": "Lens", + "film": "Film stock", + "iso": "Film ISO", + "format": "Format", + "developer": "Developer", + "push_pull": "Push / Pull", + "scanning": "Scanning", +} +# Preserve pre-selector behaviour: gear only. +DEFAULT_DESCRIPTION_FIELDS: tuple[str, ...] = ("camera", "lens", "film", "iso") +_DESCRIPTION_FIELD_SET = frozenset(DESCRIPTION_FIELD_ORDER) + + +def normalize_description_fields(fields: object) -> tuple[str, ...]: + """Keep known keys in canonical order (JSON lists round-trip cleanly).""" + if fields is None: + return DEFAULT_DESCRIPTION_FIELDS + if isinstance(fields, str): + raw = {fields} + else: + try: + raw = set(fields) + except TypeError: + return DEFAULT_DESCRIPTION_FIELDS + return tuple(k for k in DESCRIPTION_FIELD_ORDER if k in raw and k in _DESCRIPTION_FIELD_SET) + @dataclass(frozen=True) class MetadataConfig: @@ -49,3 +88,11 @@ class MetadataConfig: protect_original_metadata: bool = False exposure_override: str = "" # free-text e.g. "1/125s f/2.8 ISO 400"; empty = use source EXIF + + # Which resolved fields join into EXIF ImageDescription. + description_fields: tuple[str, ...] = field(default_factory=lambda: DEFAULT_DESCRIPTION_FIELDS) + + def __post_init__(self) -> None: + normalized = normalize_description_fields(self.description_fields) + if normalized != self.description_fields: + object.__setattr__(self, "description_fields", normalized) diff --git a/negpy/features/metadata/payload.py b/negpy/features/metadata/payload.py index 251c8a4b..9d6a0a7e 100644 --- a/negpy/features/metadata/payload.py +++ b/negpy/features/metadata/payload.py @@ -8,7 +8,7 @@ from negpy.features.metadata.exif_read import ScanExif, extract_scan_from_exif from negpy.features.metadata.gear_models import GearLibrary -from negpy.features.metadata.models import MetadataConfig, PUSH_PULL_LABELS +from negpy.features.metadata.models import MetadataConfig, PUSH_PULL_LABELS, normalize_description_fields, DEFAULT_DESCRIPTION_FIELDS _NEGPY_SOFTWARE = "NegPy" NEGPY_SOFTWARE = _NEGPY_SOFTWARE @@ -197,22 +197,35 @@ def _apex_from_f_number(f_number: float) -> float: return 2.0 * math.log(f_number, 2.0) -def build_image_description(payload: MetadataPayload) -> str: - """Human-readable summary: camera • lens • film • ISO.""" +def build_image_description(payload: MetadataPayload, fields: object = None) -> str: + """Human-readable EXIF ImageDescription from the selected field set.""" + enabled = frozenset(normalize_description_fields(fields if fields is not None else DEFAULT_DESCRIPTION_FIELDS)) parts: list[str] = [] - camera = payload.camera_display() - if camera: - parts.append(camera) - lens = payload.lens_display() - if lens: - parts.append(lens) - if payload.film_stock: + if "camera" in enabled: + camera = payload.camera_display() + if camera: + parts.append(camera) + if "lens" in enabled: + lens = payload.lens_display() + if lens: + parts.append(lens) + if "film" in enabled and payload.film_stock: parts.append(payload.film_stock) - if payload.iso is not None: + if "iso" in enabled and payload.iso is not None: parts.append(f"ISO {payload.iso}") + if "format" in enabled and payload.film_format: + parts.append(payload.film_format) + if "developer" in enabled and payload.developer: + parts.append(payload.developer) + if "push_pull" in enabled and payload.push_pull and payload.push_pull != "Normal": + parts.append(payload.push_pull) + if "scanning" in enabled and payload.scan_method: + parts.append(payload.scan_method) if parts: return " • ".join(parts) - return payload.film_stock or "" + if "film" in enabled: + return payload.film_stock or "" + return "" def build_metadata_payload( @@ -296,8 +309,8 @@ def build_metadata_payload( push_pull=push_pull, ) - desc = build_image_description(draft) - if not desc and config.film: + desc = build_image_description(draft, config.description_fields) + if not desc and config.film and "film" in config.description_fields: desc = config.film.strip() exif_flags = compute_exif_write_flags(config, draft) diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py index aa37892e..6779eb44 100644 --- a/tests/metadata/test_gear_payload.py +++ b/tests/metadata/test_gear_payload.py @@ -383,9 +383,54 @@ def test_build_image_description(): lens_model="50mm f/1.4", film_stock="Portra 400", iso=400, + film_format="35mm", + developer="D-76 1+1", + push_pull="Push +1", + scan_method="DSLR copy-stand", ) assert build_image_description(payload) == "Canon AE-1 • 50mm f/1.4 • Portra 400 • ISO 400" + assert ( + build_image_description( + payload, + ("camera", "lens", "film", "iso", "format", "developer", "push_pull", "scanning"), + ) + == "Canon AE-1 • 50mm f/1.4 • Portra 400 • ISO 400 • 35mm • D-76 1+1 • Push +1 • DSLR copy-stand" + ) + + +def test_build_image_description_omits_normal_push_pull(): + from negpy.features.metadata.payload import MetadataPayload + + payload = MetadataPayload(developer="D-76", push_pull="Normal", film_format="35mm") + assert build_image_description(payload, ("format", "developer", "push_pull")) == "35mm • D-76" + + +def test_build_image_description_process_only(): + from negpy.features.metadata.payload import MetadataPayload + + payload = MetadataPayload(film_format="120", developer="HC-110", scan_method="flatbed") + assert build_image_description(payload, ("format", "developer", "scanning")) == "120 • HC-110 • flatbed" + + +def test_build_metadata_payload_respects_description_fields(): + config = MetadataConfig( + camera_make="Nikon", + camera_model="FM2", + film="Tri-X", + film_iso=400, + developer="D-76", + description_fields=("camera", "film", "developer"), + ) + payload = build_metadata_payload(config) + assert payload.image_description == "Nikon FM2 • Tri-X • D-76" + + +def test_normalize_description_fields_orders_and_filters(): + from negpy.features.metadata.models import normalize_description_fields + + assert normalize_description_fields(["iso", "camera", "nope"]) == ("camera", "iso") + assert MetadataConfig(description_fields=["scanning", "film"]).description_fields == ("film", "scanning") def test_build_metadata_payload_preview_pairs(): From f12b1635f1f7be28265ac89d882b35ee9aa4bbd4 Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 1 Aug 2026 20:29:31 +0200 Subject: [PATCH 2/4] fixed stickiness of selection across images --- docs/USER_GUIDE.md | 2 +- negpy/desktop/session.py | 31 ++++++++++++++++++++--------- negpy/desktop/settings_catalog.py | 2 +- negpy/features/metadata/models.py | 18 ++++++++++++++--- negpy/features/metadata/payload.py | 7 ++++--- tests/metadata/test_gear_payload.py | 1 + tests/test_desktop_session.py | 22 ++++++++++++++++++++ 7 files changed, 66 insertions(+), 17 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 48e6cb5a..3d60a6ef 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -519,7 +519,7 @@ Archival metadata for the **original analog capture** (camera, lens, film, proce **Exposure**: optional original shutter/aperture/ISO. Click the lock to edit a free-text string (e.g. `1/125s f/2.8 ISO 400`). -**Metadata preview**: a live view of exactly what will be embedded, grouped by capture / scan / process / file. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. Defaults are camera, lens, film stock, and ISO — format, developer, push/pull, and scanning are off until you enable them. The selection is saved per frame; new frames inherit your last choice. Sync metadata / Sync settings copies it with the rest of the metadata. +**Metadata preview**: a live view of exactly what will be embedded, grouped by capture / scan / process / file. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. Defaults are camera, lens, film stock, and ISO — format, developer, push/pull, and scanning are off until you enable them. The last choice is sticky across the roll unless a frame has its own selection (set via **Description…** on that image). Sync metadata / Sync settings can also copy it with the rest of the metadata. When you set capture gear, it's written to standard EXIF and the digitizing rig is preserved separately in `negpy:Scan*` XMP tags. Leave gear unset and your scanner/DSLR stays visible in EXIF instead. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index 661d68b0..de34ef7a 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -557,15 +557,19 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa metadata=replace(config.metadata, protect_original_metadata=bool(sticky_protect)), ) - if not only_global: - sticky_desc = self.repo.get_global_setting("last_description_fields") - if sticky_desc is not None: - from negpy.features.metadata.models import normalize_description_fields + # Description fields: unset (None) inherits the sticky roll choice; an explicit + # per-frame tuple from Description… is left alone. + if config.metadata.description_fields is None: + from negpy.features.metadata.models import resolve_description_fields - config = replace( - config, - metadata=replace(config.metadata, description_fields=normalize_description_fields(sticky_desc)), - ) + sticky_desc = self.repo.get_global_setting("last_description_fields") + config = replace( + config, + metadata=replace( + config.metadata, + description_fields=resolve_description_fields(None, sticky_desc), + ), + ) # Flat-field profile and distortion k1 are rig-global: the active profile's # values always override the per-file ones. New files default to enabled when a @@ -748,7 +752,16 @@ def _persist_sticky_settings(self, config: WorkspaceConfig) -> None: "last_retouch_config": asdict(config.retouch), "last_dust_remove": config.retouch.dust_remove, "last_protect_original_metadata": config.metadata.protect_original_metadata, - "last_description_fields": list(config.metadata.description_fields), + "last_description_fields": list( + config.metadata.description_fields + if config.metadata.description_fields is not None + else ( + "camera", + "lens", + "film", + "iso", + ) + ), } ) diff --git a/negpy/desktop/settings_catalog.py b/negpy/desktop/settings_catalog.py index 5c335f7d..b304212a 100644 --- a/negpy/desktop/settings_catalog.py +++ b/negpy/desktop/settings_catalog.py @@ -197,7 +197,7 @@ def _row(label, section, *fields, channels="", fmt=None) -> SettingRow: "Description Fields", "metadata", "description_fields", - fmt=lambda v: ", ".join(str(x) for x in (v[0] or ())) or "—", + fmt=lambda v: (", ".join(str(x) for x in v[0]) if v[0] else "—"), ), )), ("Export", ( diff --git a/negpy/features/metadata/models.py b/negpy/features/metadata/models.py index 0f09e13d..b17ca7c4 100644 --- a/negpy/features/metadata/models.py +++ b/negpy/features/metadata/models.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Optional @@ -52,6 +52,15 @@ def normalize_description_fields(fields: object) -> tuple[str, ...]: return tuple(k for k in DESCRIPTION_FIELD_ORDER if k in raw and k in _DESCRIPTION_FIELD_SET) +def resolve_description_fields(fields: object, sticky: object = None) -> tuple[str, ...]: + """Per-frame fields if set; otherwise sticky (or gear-only defaults).""" + if fields is not None: + return normalize_description_fields(fields) + if sticky is not None: + return normalize_description_fields(sticky) + return DEFAULT_DESCRIPTION_FIELDS + + @dataclass(frozen=True) class MetadataConfig: """ @@ -89,10 +98,13 @@ class MetadataConfig: exposure_override: str = "" # free-text e.g. "1/125s f/2.8 ISO 400"; empty = use source EXIF - # Which resolved fields join into EXIF ImageDescription. - description_fields: tuple[str, ...] = field(default_factory=lambda: DEFAULT_DESCRIPTION_FIELDS) + # EXIF ImageDescription field set. None = inherit sticky roll choice on open; + # an explicit tuple (from Description…) is per-frame and not overwritten by sticky. + description_fields: Optional[tuple[str, ...]] = None def __post_init__(self) -> None: + if self.description_fields is None: + return normalized = normalize_description_fields(self.description_fields) if normalized != self.description_fields: object.__setattr__(self, "description_fields", normalized) diff --git a/negpy/features/metadata/payload.py b/negpy/features/metadata/payload.py index 9d6a0a7e..9c116dfe 100644 --- a/negpy/features/metadata/payload.py +++ b/negpy/features/metadata/payload.py @@ -8,7 +8,7 @@ from negpy.features.metadata.exif_read import ScanExif, extract_scan_from_exif from negpy.features.metadata.gear_models import GearLibrary -from negpy.features.metadata.models import MetadataConfig, PUSH_PULL_LABELS, normalize_description_fields, DEFAULT_DESCRIPTION_FIELDS +from negpy.features.metadata.models import MetadataConfig, PUSH_PULL_LABELS, normalize_description_fields, DEFAULT_DESCRIPTION_FIELDS, resolve_description_fields _NEGPY_SOFTWARE = "NegPy" NEGPY_SOFTWARE = _NEGPY_SOFTWARE @@ -309,8 +309,9 @@ def build_metadata_payload( push_pull=push_pull, ) - desc = build_image_description(draft, config.description_fields) - if not desc and config.film and "film" in config.description_fields: + desc_fields = resolve_description_fields(config.description_fields) + desc = build_image_description(draft, desc_fields) + if not desc and config.film and "film" in desc_fields: desc = config.film.strip() exif_flags = compute_exif_write_flags(config, draft) diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py index 6779eb44..5e1ba649 100644 --- a/tests/metadata/test_gear_payload.py +++ b/tests/metadata/test_gear_payload.py @@ -431,6 +431,7 @@ def test_normalize_description_fields_orders_and_filters(): assert normalize_description_fields(["iso", "camera", "nope"]) == ("camera", "iso") assert MetadataConfig(description_fields=["scanning", "film"]).description_fields == ("film", "scanning") + assert MetadataConfig().description_fields is None def test_build_metadata_payload_preview_pairs(): diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 84b38615..35709953 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -225,6 +225,28 @@ def test_protect_original_metadata_applied_to_saved_files(self): config = self.session._apply_sticky_settings(base, only_global=True) self.assertTrue(config.metadata.protect_original_metadata) + def test_description_fields_sticky_applied_when_unset(self): + sticky = { + "last_export_config": {}, + "last_description_fields": ["camera", "film", "developer", "scanning"], + } + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) + base = WorkspaceConfig() # description_fields is None + config = self.session._apply_sticky_settings(base, only_global=True) + self.assertEqual(config.metadata.description_fields, ("camera", "film", "developer", "scanning")) + + def test_description_fields_per_frame_not_overwritten_by_sticky(self): + sticky = { + "last_export_config": {}, + "last_description_fields": ["camera", "film", "developer", "scanning"], + } + self.mock_repo.get_global_setting.side_effect = lambda key, default=None: sticky.get(key, default) + base = WorkspaceConfig( + metadata=replace(WorkspaceConfig().metadata, description_fields=("camera", "iso")), + ) + config = self.session._apply_sticky_settings(base, only_global=True) + self.assertEqual(config.metadata.description_fields, ("camera", "iso")) + def test_processing_toggles_carry_to_new_files(self): # Globally remembered toggles must be applied to a fresh (sidecar-less) file. sticky = { From 9e22d5c6890b357efca5493088aa56550af5ba4b Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 1 Aug 2026 20:37:22 +0200 Subject: [PATCH 3/4] Fixed stickiness bug --- docs/USER_GUIDE.md | 2 +- negpy/desktop/session.py | 10 ---------- negpy/desktop/view/sidebar/metadata.py | 7 ++++++- tests/test_desktop_session.py | 9 +++++++++ 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3d60a6ef..8efa454f 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -519,7 +519,7 @@ Archival metadata for the **original analog capture** (camera, lens, film, proce **Exposure**: optional original shutter/aperture/ISO. Click the lock to edit a free-text string (e.g. `1/125s f/2.8 ISO 400`). -**Metadata preview**: a live view of exactly what will be embedded, grouped by capture / scan / process / file. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. Defaults are camera, lens, film stock, and ISO — format, developer, push/pull, and scanning are off until you enable them. The last choice is sticky across the roll unless a frame has its own selection (set via **Description…** on that image). Sync metadata / Sync settings can also copy it with the rest of the metadata. +**Metadata preview**: a live view of exactly what will be embedded, grouped by capture / scan / process / file. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. Defaults are camera, lens, film stock, and ISO — format, developer, push/pull, and scanning are off until you enable them. Confirming **Description…** sets that frame's selection and becomes the sticky default for other frames that don't have their own; the last confirm on the roll wins. Sync metadata / Sync settings can also copy a frame's selection with the rest of the metadata. When you set capture gear, it's written to standard EXIF and the digitizing rig is preserved separately in `negpy:Scan*` XMP tags. Leave gear unset and your scanner/DSLR stays visible in EXIF instead. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index de34ef7a..da9cdca5 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -752,16 +752,6 @@ def _persist_sticky_settings(self, config: WorkspaceConfig) -> None: "last_retouch_config": asdict(config.retouch), "last_dust_remove": config.retouch.dust_remove, "last_protect_original_metadata": config.metadata.protect_original_metadata, - "last_description_fields": list( - config.metadata.description_fields - if config.metadata.description_fields is not None - else ( - "camera", - "lens", - "film", - "iso", - ) - ), } ) diff --git a/negpy/desktop/view/sidebar/metadata.py b/negpy/desktop/view/sidebar/metadata.py index a5aed572..9af3e51a 100644 --- a/negpy/desktop/view/sidebar/metadata.py +++ b/negpy/desktop/view/sidebar/metadata.py @@ -295,6 +295,12 @@ def _open_description_fields(self) -> None: readback_metrics=False, description_fields=self._description_fields, ) + # Sticky is only updated here — not on every metadata save — so the last + # Description… confirm wins for unset frames on the roll. + self.controller.session.repo.save_global_setting( + "last_description_fields", + list(self._description_fields), + ) self._schedule_preview() def _refresh_gear_combos(self, *, force: bool = False) -> None: @@ -457,7 +463,6 @@ def _persist_all_metadata_settings(self) -> None: scanning=self.scanning_edit.text().strip(), sync_to_batch=self.sync_check.isChecked(), exposure_override=exposure_override, - description_fields=self._description_fields, ) def sync_ui(self) -> None: diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 35709953..459190a8 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -247,6 +247,15 @@ def test_description_fields_per_frame_not_overwritten_by_sticky(self): config = self.session._apply_sticky_settings(base, only_global=True) self.assertEqual(config.metadata.description_fields, ("camera", "iso")) + def test_persist_sticky_settings_does_not_write_description_fields(self): + """Any metadata save must not clobber last Description… confirm.""" + config = WorkspaceConfig( + metadata=replace(WorkspaceConfig().metadata, description_fields=("camera", "developer")), + ) + self.session._persist_sticky_settings(config) + saved = self.mock_repo.save_global_settings.call_args.args[0] + self.assertNotIn("last_description_fields", saved) + def test_processing_toggles_carry_to_new_files(self): # Globally remembered toggles must be applied to a fresh (sidecar-less) file. sticky = { From b4f78cf802914ae0ef74f3871c3a2eccaf6a255c Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 1 Aug 2026 20:43:21 +0200 Subject: [PATCH 4/4] Fixed live update in meatdata preview of the new values selectable in ImageDescription --- negpy/desktop/view/sidebar/metadata.py | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/negpy/desktop/view/sidebar/metadata.py b/negpy/desktop/view/sidebar/metadata.py index 9af3e51a..13057684 100644 --- a/negpy/desktop/view/sidebar/metadata.py +++ b/negpy/desktop/view/sidebar/metadata.py @@ -488,7 +488,7 @@ def sync_ui(self) -> None: self.push_pull_combo.setCurrentIndex(idx) self.scanning_edit.setText(conf.scanning) self.sync_check.setChecked(conf.sync_to_batch) - self._description_fields = conf.description_fields + self._description_fields = conf.description_fields or DEFAULT_DESCRIPTION_FIELDS if conf.exposure_override: self._set_exif_text_quiet("exposure", conf.exposure_override) @@ -531,6 +531,33 @@ def _update_exif_display(self) -> None: else: self._set_exif_text_quiet("exposure", "") + def _preview_metadata_config(self): + """MetadataConfig from the live form so preview tracks edits before debounce persist.""" + conf = self.state.config.metadata + fmt = self.format_combo.currentText() + pp_idx = self.push_pull_combo.currentIndex() + exposure_override = "" + if not self._exif_locked.get("exposure", True): + exposure_override = self.exposure_edit.text().strip() + else: + exposure_override = conf.exposure_override + + return replace( + conf, + gear_preset_id=self.preset_combo.selected_id(), + camera_id=self.camera_combo.selected_id(), + lens_id=self.lens_combo.selected_id(), + film_stock_id=self.film_stock_combo.selected_id(), + format=fmt, + format_other=self.format_other_edit.text().strip() if fmt == "Other" else "", + developer=self.developer_edit.text().strip(), + push_pull=PUSH_PULL_VALUES[pp_idx] if 0 <= pp_idx < len(PUSH_PULL_VALUES) else 0, + scanning=self.scanning_edit.text().strip(), + sync_to_batch=self.sync_check.isChecked(), + exposure_override=exposure_override, + description_fields=self._description_fields, + ) + def _update_preview(self) -> None: while self.preview_rows.count(): item = self.preview_rows.takeAt(0) @@ -549,7 +576,7 @@ def _update_preview(self) -> None: if current_hash and current_hash in self.state.source_exif: source_exif = self.state.source_exif[current_hash] - payload = build_metadata_payload(conf, self._gear_library, source_exif) + payload = build_metadata_payload(self._preview_metadata_config(), self._gear_library, source_exif) sections = payload.to_preview_sections() self.preview_empty.setText("Select gear or enter process metadata to see a preview.")