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: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
14 changes: 14 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,20 @@ def _apply_sticky_settings(self, config: WorkspaceConfig, only_global: bool = Fa
metadata=replace(config.metadata, protect_original_metadata=bool(sticky_protect)),
)

# 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

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
# profile is active; saved files keep their toggle.
Expand Down
6 changes: 6 additions & 0 deletions negpy/desktop/settings_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]) if v[0] else "—"),
),
)),
("Export", (
_row("Format", "export", "export_fmt"),
Expand Down
64 changes: 62 additions & 2 deletions negpy/desktop/view/sidebar/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -271,6 +283,26 @@ 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,
)
# 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:
conf = self.state.config.metadata
self._gear_library = GearProfiles.load_library()
Expand Down Expand Up @@ -456,6 +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 or DEFAULT_DESCRIPTION_FIELDS

if conf.exposure_override:
self._set_exif_text_quiet("exposure", conf.exposure_override)
Expand Down Expand Up @@ -498,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)
Expand All @@ -516,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.")
Expand Down
41 changes: 41 additions & 0 deletions negpy/desktop/view/widgets/description_fields_dialog.py
Original file line number Diff line number Diff line change
@@ -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())
59 changes: 59 additions & 0 deletions negpy/features/metadata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,54 @@
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)


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:
Expand Down Expand Up @@ -49,3 +97,14 @@ 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

# 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)
42 changes: 28 additions & 14 deletions negpy/features/metadata/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, resolve_description_fields

_NEGPY_SOFTWARE = "NegPy"
NEGPY_SOFTWARE = _NEGPY_SOFTWARE
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -296,8 +309,9 @@ def build_metadata_payload(
push_pull=push_pull,
)

desc = build_image_description(draft)
if not desc and config.film:
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)
Expand Down
Loading
Loading