diff --git a/build.py b/build.py index 2c1781d8..ef4019aa 100644 --- a/build.py +++ b/build.py @@ -71,6 +71,7 @@ "--add-data=icc:icc", "--add-data=media:media", "--add-data=crosstalk:crosstalk", + "--add-data=gear:gear", "--add-data=VERSION:.", ] diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index bcdff9e0..b4d4c01e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -210,27 +210,41 @@ ICC profile for soft-proofing in the preview and (optionally) embedding in the e --- ## 11. Metadata Panel -Custom film/process metadata plus per-image overrides of EXIF inherited from the source file. +Archival metadata for the **original analog capture** — gear, process, and scanning — written into exported JPEG, TIFF, and PNG files as EXIF and embedded XMP. -### Custom +### Original analog gear -These fields are written into the exported file's metadata (XMP/EXIF as supported by the format). +Pick gear from your library (or a saved preset). Use **Manage…** to edit cameras, lenses, film stocks, and gear presets. Starter data is seeded into `~/NegPy/gear/` on first launch. -* **Film**: Film stock name — e.g. `Portra 400`, `HP5+`. -* **Format**: Film format dropdown — `35mm`, `120`, `4×5`, `8×10`, or `Other`. -* **Format (Custom)**: Free-text field that appears only when Format = `Other` (e.g. `6×7`). -* **Developer**: Developer name and dilution, e.g. `D-76 1+1`. -* **Push / Pull**: Process push or pull from `Push +3` down to `Pull -3`, with `Normal` in the middle. -* **Scanning**: Scanner or scanning method, e.g. `Noritsu HS-1800`. -* **Sync custom metadata to all files in batch export**: When on, all files in a batch export receive these custom metadata values. +* **Preset**: One-click camera + lens + film combination. +* **Camera / Lens / Film stock**: Dropdowns from the gear library. Changing any item clears the preset selection. +* **Clear**: Clears gear preset and library selections for this frame. -### Inherited (from source) +Structured fields for the **original capture** (camera, lens, film ISO) are written to **standard EXIF** when you set gear in the Metadata tab — so Lightroom and other DAMs show your film camera and lens. Scan-only tags from the source file (`FocalLengthIn35mmFormat`, scan exposure/ISO, etc.) are stripped so they do not mix with capture data. The **digitization rig** (DSLR, film scanner, copy-stand setup) is preserved in `negpy:Scan*` XMP tags only. -Camera, Lens, and Exposure are populated read-only from the source file's EXIF. Each has a small lock-button on the right — click it to unlock the field and override the inherited value for this specific edit. Clicking again re-locks to the EXIF source. +If you have **not** set capture gear, standard EXIF is left as-is (your scanner or DSLR remains visible in Lightroom) and scan data is mirrored to `negpy:Scan*` XMP. Process-only fields (developer, format) never overwrite camera/lens EXIF. -* **Camera**: `Make` + `Model` from the source EXIF, or your override. -* **Lens**: `LensModel` from EXIF, or override. -* **Exposure**: Formatted exposure string (e.g. `1/125s f/2.0 ISO 400`), or override. +Capture fields are also written to `negpy:Capture*` in XMP for structured archival access. + +### Process + +* **Format**: Film format — `35mm`, `120`, `4×5`, `8×10`, `110`, or `Other`. +* **Format (Custom)**: Shown when Format = `Other` (e.g. `6×7`). +* **Developer**: Developer and dilution, e.g. `D-76 1+1`. +* **Push / Pull**: `Push +3` … `Pull -3`, with `Normal` in the middle. + +### Scanning + +* **Scanning**: Scan method or notes (written to `negpy:ScanMethod` in XMP). EXIF `Software` is always `NegPy`. +* **Sync custom metadata to all files in batch export**: When on, batch export uses the current metadata tab values for every file. + +### Exposure + +Optional **original capture** exposure (shutter, aperture, film ISO). Click the lock icon to edit a free-text string (e.g. `1/125s f/2.8 ISO 400`). Scan exposure from the source file appears in the preview under **Scan**, not here. + +### Metadata preview + +Collapsible live preview grouped by **Original capture**, **Scan**, **Process**, and **File** — the tag values embedded on export. --- diff --git a/gear/cameras.json b/gear/cameras.json new file mode 100644 index 00000000..b0115dc6 --- /dev/null +++ b/gear/cameras.json @@ -0,0 +1,37 @@ +[ + { + "id": "cam-canon-ae1p", + "make": "Canon", + "model": "AE-1 Program", + "displayName": "Canon AE-1 Program", + "notes": "35mm SLR, 1981-1987, FD mount." + }, + { + "id": "cam-nikon-fm2", + "make": "Nikon", + "model": "FM2", + "displayName": "Nikon FM2", + "notes": "35mm fully mechanical SLR, F-mount." + }, + { + "id": "cam-pentax-k1000", + "make": "Pentax", + "model": "K1000", + "displayName": "Pentax K1000", + "notes": "35mm mechanical SLR, K-mount." + }, + { + "id": "cam-olympus-om1", + "make": "Olympus", + "model": "OM-1", + "displayName": "Olympus OM-1", + "notes": "35mm compact mechanical SLR, OM-mount." + }, + { + "id": "cam-mamiya-645", + "make": "Mamiya", + "model": "M645 1000s", + "displayName": "Mamiya 645 1000s", + "notes": "Medium-format 6x4.5 SLR." + } +] diff --git a/gear/film_stocks.json b/gear/film_stocks.json new file mode 100644 index 00000000..3d9d0296 --- /dev/null +++ b/gear/film_stocks.json @@ -0,0 +1,67 @@ +[ + { + "id": "film-portra-400", + "displayName": "Kodak Portra 400", + "manufacturer": "Kodak", + "stockName": "Portra 400", + "iso": 400, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Professional color negative, warm and gentle skin tones." + }, + { + "id": "film-portra-160", + "displayName": "Kodak Portra 160", + "manufacturer": "Kodak", + "stockName": "Portra 160", + "iso": 160, + "format": "35mm", + "colorType": "ColorNegative" + }, + { + "id": "film-gold-200", + "displayName": "Kodak Gold 200", + "manufacturer": "Kodak", + "stockName": "Gold 200", + "iso": 200, + "format": "35mm", + "colorType": "ColorNegative" + }, + { + "id": "film-tri-x-400", + "displayName": "Kodak Tri-X 400", + "manufacturer": "Kodak", + "stockName": "Tri-X 400", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative" + }, + { + "id": "film-hp5-400", + "displayName": "Ilford HP5 Plus", + "manufacturer": "Ilford", + "stockName": "HP5 Plus 400", + "iso": 400, + "format": "35mm", + "colorType": "B&W Negative" + }, + { + "id": "film-ektachrome-e100", + "displayName": "Kodak Ektachrome E100", + "manufacturer": "Kodak", + "stockName": "Ektachrome E100", + "iso": 100, + "format": "35mm", + "colorType": "ColorSlide" + }, + { + "id": "film-cinestill-800t", + "displayName": "CineStill 800T", + "manufacturer": "CineStill", + "stockName": "800T", + "iso": 800, + "format": "35mm", + "colorType": "ColorNegative", + "notes": "Tungsten-balanced color negative with characteristic halation." + } +] diff --git a/gear/gear_presets.json b/gear/gear_presets.json new file mode 100644 index 00000000..0772a046 --- /dev/null +++ b/gear/gear_presets.json @@ -0,0 +1,16 @@ +[ + { + "id": "preset-ae1p-fd50-portra400", + "displayName": "AE-1P / FD 50 f/1.4 / Portra 400", + "cameraId": "cam-canon-ae1p", + "lensId": "lens-canon-fd-50-1.4", + "filmStockId": "film-portra-400" + }, + { + "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", + "filmStockId": "film-tri-x-400" + } +] diff --git a/gear/lenses.json b/gear/lenses.json new file mode 100644 index 00000000..280509a6 --- /dev/null +++ b/gear/lenses.json @@ -0,0 +1,42 @@ +[ + { + "id": "lens-canon-fd-50-1.4", + "displayName": "Canon FD 50mm f/1.4", + "lensModel": "FD 50mm f/1.4 S.S.C.", + "make": "Canon", + "focalLength": 50, + "maxAperture": 1.4 + }, + { + "id": "lens-canon-fd-28-2.8", + "displayName": "Canon FD 28mm f/2.8", + "lensModel": "FD 28mm f/2.8", + "make": "Canon", + "focalLength": 28, + "maxAperture": 2.8 + }, + { + "id": "lens-nikkor-50-1.8", + "displayName": "Nikkor 50mm f/1.8 AI-S", + "lensModel": "Nikkor 50mm f/1.8 AI-S", + "make": "Nikon", + "focalLength": 50, + "maxAperture": 1.8 + }, + { + "id": "lens-pentax-smc-50-1.7", + "displayName": "SMC Pentax-M 50mm f/1.7", + "lensModel": "SMC Pentax-M 50mm f/1.7", + "make": "Pentax", + "focalLength": 50, + "maxAperture": 1.7 + }, + { + "id": "lens-olympus-zuiko-50-1.8", + "displayName": "Olympus Zuiko 50mm f/1.8", + "lensModel": "Zuiko Auto-S 50mm f/1.8", + "make": "Olympus", + "focalLength": 50, + "maxAperture": 1.8 + } +] diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 44db4f25..16435ec4 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -1083,6 +1083,16 @@ def delete_flatfield_profile(self, name: str) -> None: if self.session.repo.get_global_setting("flatfield_active_profile") == name: self.set_active_flatfield_profile("") + def load_gear_library(self): + from negpy.services.assets.gear import GearProfiles + + return GearProfiles.load_library() + + def save_gear_library(self, library) -> None: + from negpy.services.assets.gear import GearProfiles + + GearProfiles.save_library(library) + def set_flatfield_enabled(self, enabled: bool) -> None: """ Per-image toggle to enable/disable flat-field correction for the current frame. diff --git a/negpy/desktop/main.py b/negpy/desktop/main.py index 3277cb10..061e99ed 100644 --- a/negpy/desktop/main.py +++ b/negpy/desktop/main.py @@ -10,6 +10,7 @@ from negpy.desktop.view.main_window import MainWindow from negpy.infrastructure.storage.repository import StorageRepository from negpy.services.assets.crosstalk import CrosstalkProfiles +from negpy.services.assets.gear import GearProfiles from negpy.kernel.system.config import APP_CONFIG, BASE_USER_DIR from negpy.kernel.system.logging import get_logger, setup_logging from negpy.kernel.system.override import apply as apply_override @@ -50,12 +51,14 @@ def _bootstrap_environment() -> None: APP_CONFIG.cache_dir, APP_CONFIG.user_icc_dir, APP_CONFIG.crosstalk_dir, + APP_CONFIG.gear_dir, APP_CONFIG.contact_sheet_templates_dir, APP_CONFIG.default_export_dir, ] for d in dirs: os.makedirs(d, exist_ok=True) CrosstalkProfiles.seed_example() + GearProfiles.seed_example() def main() -> None: diff --git a/negpy/desktop/view/sidebar/metadata.py b/negpy/desktop/view/sidebar/metadata.py index 8dd76eda..c582027d 100644 --- a/negpy/desktop/view/sidebar/metadata.py +++ b/negpy/desktop/view/sidebar/metadata.py @@ -1,5 +1,5 @@ -import piexif import qtawesome as qta +from dataclasses import asdict, replace from PyQt6.QtCore import QTimer from PyQt6.QtWidgets import ( QCheckBox, @@ -7,56 +7,97 @@ QHBoxLayout, QLabel, QLineEdit, + QPushButton, QToolButton, + QVBoxLayout, QWidget, ) from negpy.desktop.view.sidebar.base import BaseSidebar from negpy.desktop.view.styles.templates import field_label, section_subheader from negpy.desktop.view.styles.theme import THEME - -FORMAT_OPTIONS = ["35mm", "120", "4×5", "8×10", "Other"] +from negpy.desktop.view.widgets.collapsible import CollapsibleSection +from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog +from negpy.features.metadata.gear_logic import metadata_from_gear +from negpy.features.metadata.gear_models import GearLibrary +from negpy.features.metadata.payload import build_metadata_payload +from negpy.services.assets.gear import GearProfiles + +FORMAT_OPTIONS = ["35mm", "120", "4×5", "8×10", "110", "Other"] PUSH_PULL_OPTIONS = ["Push +3", "Push +2", "Push +1", "Normal", "Pull -1", "Pull -2", "Pull -3"] PUSH_PULL_VALUES = [3, 2, 1, 0, -1, -2, -3] +_NONE = "— None —" + class MetadataSidebar(BaseSidebar): - """ - Panel for setting custom analog photography metadata on exported files. - """ + """Panel for analog gear metadata written to exported files.""" def _init_ui(self) -> None: self.layout.setSpacing(10) conf = self.state.config.metadata + self._gear_library: GearLibrary = GearProfiles.load_library() self.update_timer = QTimer() self.update_timer.setSingleShot(True) self.update_timer.setInterval(500) self.update_timer.timeout.connect(self._persist_all_metadata_settings) - self._dirty = False - # ── CUSTOM METADATA ────────────────────────────────────────────── - self.layout.addWidget(section_subheader("CUSTOM METADATA")) + self.preview_timer = QTimer() + self.preview_timer.setSingleShot(True) + self.preview_timer.setInterval(100) + self.preview_timer.timeout.connect(self._update_preview) - self.layout.addWidget(field_label("Film")) - self.film_edit = QLineEdit() - self.film_edit.setPlaceholderText("e.g. Portra 400") - self.film_edit.setText(conf.film) - self.film_edit.setToolTip("Film stock name") - self.layout.addWidget(self.film_edit) + self._dirty = False + self._exif_locked = {"exposure": True} + + # ── ORIGINAL ANALOG GEAR ───────────────────────────────────────── + self.layout.addWidget(section_subheader("ORIGINAL ANALOG GEAR")) + + preset_row = QHBoxLayout() + preset_row.setSpacing(4) + self.layout.addWidget(field_label("Preset")) + self.preset_combo = QComboBox() + self.preset_combo.setToolTip("Reusable camera + lens + film combination") + preset_row.addWidget(self.preset_combo, 1) + self.preset_clear_btn = QPushButton("Clear") + self.preset_clear_btn.setToolTip("Clear gear preset selection") + preset_row.addWidget(self.preset_clear_btn) + self.layout.addLayout(preset_row) + + self.layout.addWidget(field_label("Camera")) + self.camera_combo = QComboBox() + self.camera_combo.setToolTip("Original film camera body") + self.layout.addWidget(self.camera_combo) + + self.layout.addWidget(field_label("Lens")) + self.lens_combo = QComboBox() + self.lens_combo.setToolTip("Original lens used on the film camera") + self.layout.addWidget(self.lens_combo) + + self.layout.addWidget(field_label("Film stock")) + self.film_stock_combo = QComboBox() + self.film_stock_combo.setToolTip("Film stock used for the original capture") + self.layout.addWidget(self.film_stock_combo) + + self.manage_btn = QPushButton(" Manage…") + self.manage_btn.setIcon(qta.icon("fa5s.cog", color=THEME.text_primary)) + self.manage_btn.setToolTip("Edit cameras, lenses, film stocks, and gear presets") + self.layout.addWidget(self.manage_btn) + + # ── PROCESS ────────────────────────────────────────────────────── + self.layout.addWidget(section_subheader("PROCESS")) self.layout.addWidget(field_label("Format")) self.format_combo = QComboBox() self.format_combo.addItems(FORMAT_OPTIONS) if conf.format in FORMAT_OPTIONS: self.format_combo.setCurrentText(conf.format) - self.format_combo.setToolTip("Film format") self.layout.addWidget(self.format_combo) self.format_other_edit = QLineEdit() self.format_other_edit.setPlaceholderText("e.g. 6×7") self.format_other_edit.setText(conf.format_other) - self.format_other_edit.setToolTip("Custom format") self.format_other_edit.setVisible(conf.format == "Other") self.layout.addWidget(self.format_other_edit) @@ -64,7 +105,6 @@ def _init_ui(self) -> None: self.developer_edit = QLineEdit() self.developer_edit.setPlaceholderText("e.g. D-76 1+1") self.developer_edit.setText(conf.developer) - self.developer_edit.setToolTip("Developer and dilution") self.layout.addWidget(self.developer_edit) self.layout.addWidget(field_label("Push / Pull")) @@ -72,56 +112,60 @@ def _init_ui(self) -> None: self.push_pull_combo.addItems(PUSH_PULL_OPTIONS) idx = PUSH_PULL_VALUES.index(conf.push_pull) if conf.push_pull in PUSH_PULL_VALUES else 3 self.push_pull_combo.setCurrentIndex(idx) - self.push_pull_combo.setToolTip("Push or pull processing") self.layout.addWidget(self.push_pull_combo) + # ── SCANNING ───────────────────────────────────────────────────── + self.layout.addWidget(section_subheader("SCANNING")) + self.layout.addWidget(field_label("Scanning")) self.scanning_edit = QLineEdit() - self.scanning_edit.setPlaceholderText("e.g. Noritsu HS-1800") + self.scanning_edit.setPlaceholderText("e.g. DSLR copy-stand scan") self.scanning_edit.setText(conf.scanning) - self.scanning_edit.setToolTip("Scanner or scanning method") self.layout.addWidget(self.scanning_edit) self.sync_check = QCheckBox("Sync custom metadata to all files in batch export") self.sync_check.setChecked(conf.sync_to_batch) - self.sync_check.setToolTip("When checked, all exported files get these custom metadata values") self.layout.addWidget(self.sync_check) - # ── INHERITED FROM SOURCE ──────────────────────────────────────── - self.layout.addWidget(section_subheader("INHERITED FROM SOURCE")) + # ── EXPOSURE ───────────────────────────────────────────────────── + self.layout.addWidget(section_subheader("EXPOSURE")) - hint = QLabel("Locked by default — click 🔓 to edit") + hint = QLabel("Optional original capture exposure — click 🔓 to edit") hint.setStyleSheet(f"font-size: {THEME.font_size_xs}px; color: {THEME.text_muted};") self.layout.addWidget(hint) - self._exif_locked: dict[str, bool] = { - "camera": True, - "lens": True, - "exposure": True, - } - - # Camera row - self.camera_label = field_label("Camera") - self.layout.addWidget(self.camera_label) - self.camera_edit = self._make_exif_field("camera") - - # Lens row - self.lens_label = field_label("Lens") - self.layout.addWidget(self.lens_label) - self.lens_edit = self._make_exif_field("lens") - - # Exposure row self.exposure_label = field_label("Exposure") self.layout.addWidget(self.exposure_label) self.exposure_edit = self._make_exif_field("exposure") - # Hidden until EXIF is available - self._set_inherited_visible(False) + # ── METADATA PREVIEW ───────────────────────────────────────────── + self.preview_content = QWidget() + preview_layout = QVBoxLayout(self.preview_content) + preview_layout.setContentsMargins(0, 0, 0, 0) + preview_layout.setSpacing(4) + preview_hint = QLabel("Written to exported files on export.") + preview_hint.setWordWrap(True) + preview_hint.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_xs}px;") + preview_layout.addWidget(preview_hint) + + self.preview_rows = QVBoxLayout() + self.preview_rows.setSpacing(2) + preview_layout.addLayout(self.preview_rows) + + self.preview_empty = QLabel("Select gear or enter process metadata to see a preview.") + self.preview_empty.setWordWrap(True) + self.preview_empty.setStyleSheet(f"color: {THEME.text_muted}; font-size: {THEME.font_size_xs}px;") + preview_layout.addWidget(self.preview_empty) + + self.preview_section = CollapsibleSection("Metadata preview", expanded=True) + self.preview_section.set_content(self.preview_content) + self.layout.addWidget(self.preview_section) + + self._refresh_gear_combos() self.layout.addStretch() def _make_exif_field(self, key: str) -> QLineEdit: - """Create a QHBoxLayout row: QLineEdit + lock/unlock QToolButton.""" row = QHBoxLayout() row.setSpacing(4) @@ -139,14 +183,10 @@ def _make_exif_field(self, key: str) -> QLineEdit: row.addWidget(edit) row.addWidget(lock_btn) self.layout.addLayout(row) - - # Store lock button reference for visibility/reset setattr(self, f"_{key}_lock_btn", lock_btn) - return edit def _apply_lock_style(self, edit: QLineEdit, locked: bool) -> None: - """Style a QLineEdit based on lock state.""" if locked: edit.setStyleSheet(f"color: {THEME.text_secondary};") edit.setReadOnly(True) @@ -155,47 +195,144 @@ def _apply_lock_style(self, edit: QLineEdit, locked: bool) -> None: edit.setReadOnly(False) def _update_lock_icon(self, btn: QToolButton, locked: bool) -> None: - """Set the lock/unlock icon on a QToolButton.""" icon_name = "fa5s.lock" if locked else "fa5s.lock-open" color = THEME.text_muted if locked else THEME.text_primary btn.setIcon(qta.icon(icon_name, color=color)) def _toggle_exif_lock(self, key: str, edit: QLineEdit, btn: QToolButton, checked: bool) -> None: - """Handle lock/unlock toggle for an EXIF field.""" locked = not checked self._exif_locked[key] = locked self._apply_lock_style(edit, locked=locked) self._update_lock_icon(btn, locked=locked) if not locked: edit.setFocus() + else: + self._update_exif_display() + self._mark_dirty() def _connect_signals(self) -> None: - # Custom metadata changes → debounced persist - self.film_edit.textChanged.connect(self._mark_dirty) + self.preset_combo.currentIndexChanged.connect(self._on_preset_changed) + self.preset_clear_btn.clicked.connect(self._on_preset_clear) + self.camera_combo.currentIndexChanged.connect(self._on_gear_changed) + self.lens_combo.currentIndexChanged.connect(self._on_gear_changed) + self.film_stock_combo.currentIndexChanged.connect(self._on_gear_changed) + self.manage_btn.clicked.connect(self._open_gear_library) + self.format_combo.currentTextChanged.connect(self._on_format_changed) self.format_other_edit.textChanged.connect(self._mark_dirty) self.developer_edit.textChanged.connect(self._mark_dirty) self.push_pull_combo.currentIndexChanged.connect(self._mark_dirty) self.scanning_edit.textChanged.connect(self._mark_dirty) self.sync_check.toggled.connect(self._mark_dirty) + self.exposure_edit.textChanged.connect(self._mark_dirty) - # EXIF override edits → debounced persist - for edit in self._exif_edits().values(): - edit.textChanged.connect(self._mark_dirty) - - # External sync triggers self.controller.session.file_selected.connect(self._on_file_selected) - def _exif_edits(self) -> dict: - return { - "camera": self.camera_edit, - "lens": self.lens_edit, - "exposure": self.exposure_edit, - } + def _refresh_gear_combos(self) -> None: + conf = self.state.config.metadata + self._gear_library = GearProfiles.load_library() + + def fill(combo: QComboBox, items, selected_id: str) -> None: + combo.blockSignals(True) + combo.clear() + combo.addItem(_NONE, "") + for item in items: + label = item.resolved_display_name if hasattr(item, "resolved_display_name") else item.display_name + combo.addItem(label, item.id) + idx = combo.findData(selected_id or "") + combo.setCurrentIndex(idx if idx >= 0 else 0) + combo.blockSignals(False) + + self.preset_combo.blockSignals(True) + self.preset_combo.clear() + self.preset_combo.addItem(_NONE, "") + for p in self._gear_library.gear_presets: + self.preset_combo.addItem(p.display_name or "Unnamed preset", p.id) + pidx = self.preset_combo.findData(conf.gear_preset_id or "") + self.preset_combo.setCurrentIndex(pidx if pidx >= 0 else 0) + self.preset_combo.blockSignals(False) + + fill(self.camera_combo, self._gear_library.cameras, conf.camera_id) + fill(self.lens_combo, self._gear_library.lenses, conf.lens_id) + fill(self.film_stock_combo, self._gear_library.film_stocks, conf.film_stock_id) + + def _on_preset_changed(self, _idx: int) -> None: + preset_id = self.preset_combo.currentData() or "" + if not preset_id: + return + new_meta = metadata_from_gear( + self.state.config.metadata, + self._gear_library, + gear_preset_id=preset_id, + ) + self._apply_metadata_config(new_meta) + + def _on_preset_clear(self) -> None: + cleared = replace( + self.state.config.metadata, + gear_preset_id="", + camera_id="", + lens_id="", + film_stock_id="", + camera_make="", + camera_model="", + lens_make="", + lens_model="", + focal_length_mm=None, + max_aperture=None, + film_iso=None, + film_manufacturer="", + film_color_type="", + film="", + ) + self._apply_metadata_config(cleared) + + def _on_gear_changed(self, *_args) -> None: + new_meta = metadata_from_gear( + self.state.config.metadata, + self._gear_library, + gear_preset_id="", + camera_id=self.camera_combo.currentData() or "", + lens_id=self.lens_combo.currentData() or "", + film_stock_id=self.film_stock_combo.currentData() or "", + ) + new_meta = replace(new_meta, gear_preset_id="") + self.preset_combo.blockSignals(True) + self.preset_combo.setCurrentIndex(0) + self.preset_combo.blockSignals(False) + self._apply_metadata_config(new_meta, refresh_combos=False) + + def _apply_metadata_config(self, new_meta, *, refresh_combos: bool = True) -> None: + self.update_config_section( + "metadata", + persist=True, + render=False, + readback_metrics=False, + **asdict(new_meta), + ) + if refresh_combos: + self._refresh_gear_combos() + self.sync_ui() + self._schedule_preview() + + def _open_gear_library(self) -> None: + dlg = GearLibraryDialog(self._gear_library, parent=self) + dlg.library_changed.connect(self._on_library_changed) + if dlg.exec(): + self._on_library_changed() + + def _on_library_changed(self) -> None: + self._gear_library = GearProfiles.load_library() + self._refresh_gear_combos() + self._schedule_preview() def _mark_dirty(self) -> None: self._dirty = True self.update_timer.start() + self._schedule_preview() + + def _schedule_preview(self) -> None: + self.preview_timer.start() def _on_format_changed(self, text: str) -> None: self.format_other_edit.setVisible(text == "Other") @@ -209,25 +346,29 @@ def _persist_all_metadata_settings(self) -> None: 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() + self.update_config_section( "metadata", persist=True, render=False, readback_metrics=False, - film=self.film_edit.text().strip(), + gear_preset_id=self.preset_combo.currentData() or "", + camera_id=self.camera_combo.currentData() or "", + lens_id=self.lens_combo.currentData() or "", + film_stock_id=self.film_stock_combo.currentData() or "", 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(), - camera_override=self.camera_edit.text().strip(), - lens_override=self.lens_edit.text().strip(), - exposure_override=self.exposure_edit.text().strip(), + exposure_override=exposure_override, ) def sync_ui(self) -> None: - """Sync custom metadata widgets and EXIF overrides from config (undo/redo, file switch).""" if self._dirty: return @@ -235,7 +376,8 @@ def sync_ui(self) -> None: self.block_signals(True) try: - self.film_edit.setText(conf.film) + self._refresh_gear_combos() + if conf.format in FORMAT_OPTIONS: self.format_combo.setCurrentText(conf.format) else: @@ -248,16 +390,17 @@ def sync_ui(self) -> None: self.scanning_edit.setText(conf.scanning) self.sync_check.setChecked(conf.sync_to_batch) - # Refresh EXIF override fields from config - self._set_exif_text_quiet("camera", conf.camera_override) - self._set_exif_text_quiet("lens", conf.lens_override) - self._set_exif_text_quiet("exposure", conf.exposure_override) + if conf.exposure_override: + self._set_exif_text_quiet("exposure", conf.exposure_override) + else: + self._update_exif_display() finally: self.block_signals(False) + self._schedule_preview() + def _set_exif_text_quiet(self, key: str, text: str) -> None: - """Set EXIF edit text without triggering signals or dirty flag.""" - edit = self._exif_edits().get(key) + edit = getattr(self, f"{key}_edit", None) if edit is None: return edit.blockSignals(True) @@ -267,129 +410,66 @@ def _set_exif_text_quiet(self, key: str, text: str) -> None: edit.blockSignals(False) def _on_file_selected(self, _path: str) -> None: - """Called when the active file changes — sync widgets + EXIF display.""" self._dirty = False self._reset_exif_locks() self.sync_ui() - self._update_exif_display() def _reset_exif_locks(self) -> None: - """Reset all EXIF fields to locked state.""" - for key, edit in self._exif_edits().items(): - self._exif_locked[key] = True - self._apply_lock_style(edit, locked=True) - btn = getattr(self, f"_{key}_lock_btn", None) - if btn is not None: - btn.blockSignals(True) - btn.setChecked(False) - btn.blockSignals(False) - self._update_lock_icon(btn, locked=True) + self._exif_locked["exposure"] = True + self._apply_lock_style(self.exposure_edit, locked=True) + btn = getattr(self, "_exposure_lock_btn", None) + if btn is not None: + btn.blockSignals(True) + btn.setChecked(False) + btn.blockSignals(False) + self._update_lock_icon(btn, locked=True) def _update_exif_display(self) -> None: - """Update the inherited EXIF section from source EXIF or config overrides.""" conf = self.state.config.metadata - source_exif = self.state.source_exif - current_hash = self.state.current_file_hash - - if current_hash and current_hash in source_exif: - exif = source_exif[current_hash] - self._update_inherited_display(exif, conf) - else: - # No EXIF available — clear fields - self._set_inherited_visible(False) - - def _update_inherited_display(self, exif: dict, conf) -> None: - """Parse piexif-format EXIF dict and set QLineEdit text, preferring overrides.""" - zeroth = exif.get("0th", {}) if isinstance(exif, dict) else {} - exif_tags = exif.get("Exif", {}) if isinstance(exif, dict) else {} - - make = _safe_str(zeroth.get(piexif.ImageIFD.Make, "")) - model = _safe_str(zeroth.get(piexif.ImageIFD.Model, "")) - lens = _safe_str(exif_tags.get(piexif.ExifIFD.LensModel, "")) - - # Camera: override > source EXIF - if conf.camera_override: - camera = conf.camera_override + if conf.exposure_override: + self._set_exif_text_quiet("exposure", conf.exposure_override) else: - camera = f"{make} {model}".strip() + self._set_exif_text_quiet("exposure", "") - # Lens: override > source EXIF - if conf.lens_override: - lens_text = conf.lens_override - else: - lens_text = lens + def _update_preview(self) -> None: + while self.preview_rows.count(): + item = self.preview_rows.takeAt(0) + if item.widget(): + item.widget().deleteLater() - # Exposure: override > source EXIF - if conf.exposure_override: - exp_text = conf.exposure_override - else: - exp_parts = _format_exposure(exif_tags) - exp_text = exp_parts if exp_parts else "" - - self._set_exif_text_quiet("camera", camera) - self._set_exif_text_quiet("lens", lens_text) - self._set_exif_text_quiet("exposure", exp_text) - - has_any = bool(camera or lens_text or exp_text) - self._set_inherited_visible(has_any) - - def _set_inherited_visible(self, visible: bool) -> None: - for key in ("camera", "lens", "exposure"): - label = getattr(self, f"{key}_label", None) - edit = self._exif_edits().get(key) - btn = getattr(self, f"_{key}_lock_btn", None) - if label: - label.setVisible(visible) - if edit: - edit.setVisible(visible) - if btn: - btn.setVisible(visible) + conf = self.state.config.metadata + source_exif = None + current_hash = self.state.current_file_hash + 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) + sections = payload.to_preview_sections() + + self.preview_empty.setVisible(not sections) + mono = f"font-family: Consolas, monospace; font-size: {THEME.font_size_xs}px;" + + for title, rows in sections: + header = QLabel(title) + header.setStyleSheet( + f"color: {THEME.text_secondary}; font-size: {THEME.font_size_xs}px; font-weight: 600;" + ) + self.preview_rows.addWidget(header) + for label, value in rows: + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(6) + lbl = QLabel(label) + lbl.setStyleSheet(f"color: {THEME.text_muted}; {mono}") + lbl.setFixedWidth(110) + val = QLabel(value) + val.setWordWrap(True) + val.setStyleSheet(f"color: {THEME.text_primary}; {mono}") + row_layout.addWidget(lbl) + row_layout.addWidget(val, 1) + self.preview_rows.addWidget(row) def block_signals(self, blocked: bool) -> None: for w in self.findChildren(QWidget): w.blockSignals(blocked) - - -def _safe_str(value) -> str: - """Convert EXIF value to string, handling bytes.""" - if isinstance(value, bytes): - return value.decode("utf-8", errors="replace").strip("\x00") - if value is None: - return "" - return str(value).strip("\x00") - - -def _format_exposure(exif_tags: dict) -> str: - """Format exposure time, f-number, and ISO into a human-readable string.""" - exposure_time = exif_tags.get(piexif.ExifIFD.ExposureTime) - f_number = exif_tags.get(piexif.ExifIFD.FNumber) - iso = exif_tags.get(piexif.ExifIFD.ISOSpeedRatings) - - parts = [] - - if exposure_time is not None: - if isinstance(exposure_time, tuple) and len(exposure_time) == 2: - num, den = exposure_time - if num == 0: - pass - elif num == 1: - parts.append(f"1/{den}s") - else: - parts.append(f"{num}/{den}s") - elif isinstance(exposure_time, (int, float)): - parts.append(f"{exposure_time}s") - - if f_number is not None: - if isinstance(f_number, tuple) and len(f_number) == 2: - num, den = f_number - val = num / den if den else 0 - parts.append(f"f/{val:.1f}") - elif isinstance(f_number, (int, float)): - parts.append(f"f/{f_number:.1f}") - - if iso is not None: - if isinstance(iso, tuple) and len(iso) == 2: - iso = iso[0] // iso[1] if iso[1] else 0 - parts.append(f"ISO {iso}") - - return " ".join(parts) diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 5d52ff43..0728dbc6 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -204,6 +204,7 @@ def _connect_signals(self) -> None: self.controller.pixel_readout_rgb.connect(self.hist_widget.set_marker) self.controller.tone_drag_changed.connect(self.curve_widget.set_active_param) self.controller.config_updated.connect(self.export_sidebar.sync_ui) + self.controller.config_updated.connect(self.metadata_sidebar.sync_ui) self.controls_panel.modified_synced.connect(self._sync_tab_edited) def _sync_tab_edited(self) -> None: diff --git a/negpy/desktop/view/widgets/gear_library_dialog.py b/negpy/desktop/view/widgets/gear_library_dialog.py new file mode 100644 index 00000000..c796adab --- /dev/null +++ b/negpy/desktop/view/widgets/gear_library_dialog.py @@ -0,0 +1,445 @@ +"""Modal dialog for managing the analog gear library.""" + +from __future__ import annotations + +import qtawesome as qta +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import ( + QComboBox, + QDialog, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from negpy.desktop.view.styles.templates import field_label +from negpy.desktop.view.styles.theme import THEME +from negpy.features.metadata.gear_models import ( + Camera, + FilmColorType, + FilmFormat, + FilmStock, + GearLibrary, + GearPreset, + Lens, +) +from negpy.services.assets.gear import GearProfiles + +_CATEGORIES = [ + ("cameras", "Cameras"), + ("lenses", "Lenses"), + ("film_stocks", "Film Stocks"), + ("gear_presets", "Gear Presets"), +] + +_CATEGORY_FIELDS: dict[str, frozenset[str]] = { + "cameras": frozenset({"display_name", "make", "model", "notes"}), + "lenses": frozenset({"display_name", "make", "lens_model", "focal", "aperture", "notes"}), + "film_stocks": frozenset( + {"display_name", "manufacturer", "stock_name", "iso", "format", "color_type", "notes"} + ), + "gear_presets": frozenset({"display_name", "preset_camera", "preset_lens", "preset_film", "notes"}), +} + + +class GearLibraryDialog(QDialog): + library_changed = pyqtSignal() + + def __init__(self, library: GearLibrary | None = None, parent=None): + super().__init__(parent) + self._library = library or GearProfiles.load_library() + self._category = "cameras" + self._selected_idx = -1 + self._updating = False + + self.setWindowTitle("Gear Library") + self.resize(820, 560) + self._init_ui() + self._select_category("cameras") + + def library(self) -> GearLibrary: + return self._library + + def _init_ui(self) -> None: + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Category list + left = QWidget() + left.setFixedWidth(140) + left.setStyleSheet(f"background: {THEME.bg_panel}; border-right: 1px solid {THEME.border_primary};") + left_layout = QVBoxLayout(left) + left_layout.setContentsMargins(8, 8, 8, 8) + + cat_label = QLabel("LIBRARY") + cat_label.setStyleSheet(f"color: {THEME.text_muted}; font-size: 10px; font-weight: bold;") + left_layout.addWidget(cat_label) + + self.category_list = QListWidget() + for key, label in _CATEGORIES: + self.category_list.addItem(QListWidgetItem(label)) + self.category_list.setProperty("keys", [k for k, _ in _CATEGORIES]) + self.category_list.currentRowChanged.connect(self._on_category_changed) + left_layout.addWidget(self.category_list) + root.addWidget(left) + + # Item list + mid = QWidget() + mid.setFixedWidth(220) + mid.setStyleSheet(f"background: {THEME.bg_panel}; border-right: 1px solid {THEME.border_primary};") + mid_layout = QVBoxLayout(mid) + mid_layout.setContentsMargins(8, 8, 8, 8) + + self.items_label = QLabel("ITEMS") + self.items_label.setStyleSheet(f"color: {THEME.text_muted}; font-size: 10px; font-weight: bold;") + mid_layout.addWidget(self.items_label) + + self.item_list = QListWidget() + self.item_list.currentRowChanged.connect(self._on_item_changed) + mid_layout.addWidget(self.item_list) + + btn_row = QHBoxLayout() + self.add_btn = QPushButton() + self.add_btn.setIcon(qta.icon("fa5s.plus", color=THEME.text_primary)) + self.add_btn.setToolTip("Add item") + self.add_btn.clicked.connect(self._add_item) + self.dup_btn = QPushButton() + self.dup_btn.setIcon(qta.icon("fa5s.copy", color=THEME.text_primary)) + self.dup_btn.setToolTip("Duplicate") + self.dup_btn.clicked.connect(self._duplicate_item) + self.del_btn = QPushButton() + self.del_btn.setIcon(qta.icon("fa5s.trash-alt", color=THEME.text_primary)) + self.del_btn.setToolTip("Delete") + self.del_btn.clicked.connect(self._delete_item) + for b in (self.add_btn, self.dup_btn, self.del_btn): + b.setFixedWidth(36) + btn_row.addWidget(b) + btn_row.addStretch() + mid_layout.addLayout(btn_row) + + root.addWidget(mid) + + # Form — single layout; rows are shown/hidden per category (never removeRow). + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(16, 16, 16, 16) + + self.display_name_edit = QLineEdit() + self.make_edit = QLineEdit() + self.model_edit = QLineEdit() + self.lens_model_edit = QLineEdit() + self.focal_spin = QDoubleSpinBox() + self.focal_spin.setRange(0, 2000) + self.focal_spin.setSuffix(" mm") + self.aperture_spin = QDoubleSpinBox() + self.aperture_spin.setRange(0, 64) + self.aperture_spin.setDecimals(1) + self.aperture_spin.setPrefix("f/") + self.manufacturer_edit = QLineEdit() + self.stock_name_edit = QLineEdit() + self.iso_spin = QSpinBox() + self.iso_spin.setRange(1, 12800) + self.format_combo = QComboBox() + self.format_combo.addItems([e.value for e in FilmFormat]) + self.color_combo = QComboBox() + self.color_combo.addItems([e.value for e in FilmColorType]) + self.notes_edit = QLineEdit() + self.preset_camera_combo = QComboBox() + self.preset_lens_combo = QComboBox() + self.preset_film_combo = QComboBox() + + for w in ( + self.display_name_edit, + self.make_edit, + self.model_edit, + self.lens_model_edit, + self.manufacturer_edit, + self.stock_name_edit, + self.notes_edit, + ): + w.textChanged.connect(self._on_form_changed) + self.focal_spin.valueChanged.connect(self._on_form_changed) + self.aperture_spin.valueChanged.connect(self._on_form_changed) + self.iso_spin.valueChanged.connect(self._on_form_changed) + self.format_combo.currentIndexChanged.connect(self._on_form_changed) + self.color_combo.currentIndexChanged.connect(self._on_form_changed) + self.preset_camera_combo.currentIndexChanged.connect(self._on_form_changed) + self.preset_lens_combo.currentIndexChanged.connect(self._on_form_changed) + self.preset_film_combo.currentIndexChanged.connect(self._on_form_changed) + + self.form_panel = QWidget() + self.form_layout = QFormLayout(self.form_panel) + self.form_layout.setSpacing(8) + self._form_rows: dict[str, tuple[QLabel, QWidget]] = {} + self._register_form_row("display_name", "Display name", self.display_name_edit) + self._register_form_row("make", "Make", self.make_edit) + self._register_form_row("model", "Model", self.model_edit) + self._register_form_row("lens_model", "Lens model", self.lens_model_edit) + self._register_form_row("focal", "Focal length", self.focal_spin) + self._register_form_row("aperture", "Max aperture", self.aperture_spin) + self._register_form_row("manufacturer", "Manufacturer", self.manufacturer_edit) + self._register_form_row("stock_name", "Stock name", self.stock_name_edit) + self._register_form_row("iso", "ISO", self.iso_spin) + self._register_form_row("format", "Format", self.format_combo) + self._register_form_row("color_type", "Color type", self.color_combo) + self._register_form_row("preset_camera", "Camera", self.preset_camera_combo) + self._register_form_row("preset_lens", "Lens", self.preset_lens_combo) + self._register_form_row("preset_film", "Film stock", self.preset_film_combo) + self._register_form_row("notes", "Notes", self.notes_edit) + + right_layout.addWidget(self.form_panel) + right_layout.addStretch() + + close_row = QHBoxLayout() + close_row.addStretch() + save_btn = QPushButton("Done") + save_btn.clicked.connect(self.accept) + close_row.addWidget(save_btn) + right_layout.addLayout(close_row) + + root.addWidget(right) + + def _register_form_row(self, key: str, label_text: str, widget: QWidget) -> None: + label = field_label(label_text) + self.form_layout.addRow(label, widget) + self._form_rows[key] = (label, widget) + + def _show_form_for_category(self, category: str) -> None: + visible = _CATEGORY_FIELDS[category] + for key, (label, widget) in self._form_rows.items(): + show = key in visible + label.setVisible(show) + widget.setVisible(show) + + def _current_items(self) -> list: + if self._category == "cameras": + return self._library.cameras + if self._category == "lenses": + return self._library.lenses + if self._category == "film_stocks": + return self._library.film_stocks + return self._library.gear_presets + + def _set_current_items(self, items: list) -> None: + if self._category == "cameras": + self._library.cameras = items + elif self._category == "lenses": + self._library.lenses = items + elif self._category == "film_stocks": + self._library.film_stocks = items + else: + self._library.gear_presets = items + + def _item_label(self, item) -> str: + if isinstance(item, Camera): + return item.resolved_display_name + if isinstance(item, Lens): + return item.resolved_display_name + if isinstance(item, FilmStock): + return item.resolved_display_name + if isinstance(item, GearPreset): + return item.display_name or "Unnamed preset" + return str(item) + + def _select_category(self, key: str) -> None: + for i, (k, _) in enumerate(_CATEGORIES): + if k == key: + self.category_list.setCurrentRow(i) + break + + def _on_category_changed(self, row: int) -> None: + if row < 0: + return + self._category = _CATEGORIES[row][0] + self._rebuild_item_list() + self._show_form_for_category(self._category) + if self._category == "gear_presets": + self._refresh_preset_combos() + + def _rebuild_item_list(self) -> None: + self.item_list.blockSignals(True) + self.item_list.clear() + for item in self._current_items(): + self.item_list.addItem(QListWidgetItem(self._item_label(item))) + self.item_list.blockSignals(False) + if self._current_items(): + self.item_list.setCurrentRow(0) + else: + self._selected_idx = -1 + self._clear_form() + + def _refresh_preset_combos(self) -> None: + for combo, items, none_label in ( + (self.preset_camera_combo, self._library.cameras, "— None —"), + (self.preset_lens_combo, self._library.lenses, "— None —"), + (self.preset_film_combo, self._library.film_stocks, "— None —"), + ): + combo.blockSignals(True) + combo.clear() + combo.addItem(none_label, "") + for item in items: + combo.addItem(self._item_label(item), item.id) + combo.blockSignals(False) + + def _on_item_changed(self, row: int) -> None: + if row < 0 or row >= len(self._current_items()): + self._selected_idx = -1 + self._clear_form() + return + self._selected_idx = row + self._populate_form(self._current_items()[row]) + + def _populate_form(self, item) -> None: + self._updating = True + try: + if isinstance(item, Camera): + self.display_name_edit.setText(item.display_name) + self.make_edit.setText(item.make) + self.model_edit.setText(item.model) + self.notes_edit.setText(item.notes) + elif isinstance(item, Lens): + self.display_name_edit.setText(item.display_name) + self.make_edit.setText(item.make) + self.lens_model_edit.setText(item.lens_model) + self.focal_spin.setValue(item.focal_length_mm or 0) + self.aperture_spin.setValue(item.max_aperture or 0) + self.notes_edit.setText(item.notes) + elif isinstance(item, FilmStock): + self.display_name_edit.setText(item.display_name) + self.manufacturer_edit.setText(item.manufacturer) + self.stock_name_edit.setText(item.stock_name) + self.iso_spin.setValue(item.iso) + idx = self.format_combo.findText(item.format.value) + if idx >= 0: + self.format_combo.setCurrentIndex(idx) + idx = self.color_combo.findText(item.color_type.value) + if idx >= 0: + self.color_combo.setCurrentIndex(idx) + self.notes_edit.setText(item.notes) + elif isinstance(item, GearPreset): + self._refresh_preset_combos() + self.display_name_edit.setText(item.display_name) + self._set_combo_by_id(self.preset_camera_combo, item.camera_id) + self._set_combo_by_id(self.preset_lens_combo, item.lens_id) + self._set_combo_by_id(self.preset_film_combo, item.film_stock_id) + self.notes_edit.setText(item.notes) + finally: + self._updating = False + + def _set_combo_by_id(self, combo: QComboBox, item_id: str) -> None: + idx = combo.findData(item_id or "") + combo.setCurrentIndex(idx if idx >= 0 else 0) + + def _clear_form(self) -> None: + self._updating = True + try: + for w in ( + self.display_name_edit, + self.make_edit, + self.model_edit, + self.lens_model_edit, + self.manufacturer_edit, + self.stock_name_edit, + self.notes_edit, + ): + w.clear() + self.focal_spin.setValue(0) + self.aperture_spin.setValue(0) + self.iso_spin.setValue(100) + finally: + self._updating = False + + def _on_form_changed(self, *_args) -> None: + if self._updating or self._selected_idx < 0: + return + items = list(self._current_items()) + item = items[self._selected_idx] + + if isinstance(item, Camera): + item.display_name = self.display_name_edit.text().strip() + item.make = self.make_edit.text().strip() + item.model = self.model_edit.text().strip() + item.notes = self.notes_edit.text().strip() + elif isinstance(item, Lens): + item.display_name = self.display_name_edit.text().strip() + item.make = self.make_edit.text().strip() + item.lens_model = self.lens_model_edit.text().strip() + item.focal_length_mm = self.focal_spin.value() or None + item.max_aperture = self.aperture_spin.value() or None + item.notes = self.notes_edit.text().strip() + elif isinstance(item, FilmStock): + item.display_name = self.display_name_edit.text().strip() + item.manufacturer = self.manufacturer_edit.text().strip() + item.stock_name = self.stock_name_edit.text().strip() + item.iso = self.iso_spin.value() + item.format = FilmFormat(self.format_combo.currentText()) + item.color_type = FilmColorType(self.color_combo.currentText()) + item.notes = self.notes_edit.text().strip() + elif isinstance(item, GearPreset): + item.display_name = self.display_name_edit.text().strip() + item.camera_id = self.preset_camera_combo.currentData() or "" + item.lens_id = self.preset_lens_combo.currentData() or "" + item.film_stock_id = self.preset_film_combo.currentData() or "" + item.notes = self.notes_edit.text().strip() + + items[self._selected_idx] = item + self._set_current_items(items) + self.item_list.item(self._selected_idx).setText(self._item_label(item)) + GearProfiles.save_library(self._library) + self.library_changed.emit() + + def _add_item(self) -> None: + if self._category == "cameras": + item = Camera(make="New", model="Camera") + elif self._category == "lenses": + item = Lens(lens_model="New lens") + elif self._category == "film_stocks": + item = FilmStock(stock_name="New stock") + else: + item = GearPreset(display_name="New preset") + items = list(self._current_items()) + items.append(item) + self._set_current_items(items) + GearProfiles.save_library(self._library) + self._rebuild_item_list() + self.item_list.setCurrentRow(len(items) - 1) + self.library_changed.emit() + + def _duplicate_item(self) -> None: + if self._selected_idx < 0: + return + import copy + + items = list(self._current_items()) + dup = copy.deepcopy(items[self._selected_idx]) + from negpy.features.metadata.gear_models import _new_id + + dup.id = _new_id() + items.append(dup) + self._set_current_items(items) + GearProfiles.save_library(self._library) + self._rebuild_item_list() + self.item_list.setCurrentRow(len(items) - 1) + self.library_changed.emit() + + def _delete_item(self) -> None: + if self._selected_idx < 0: + return + if QMessageBox.question(self, "Delete", "Delete this item?") != QMessageBox.StandardButton.Yes: + return + items = list(self._current_items()) + del items[self._selected_idx] + self._set_current_items(items) + GearProfiles.save_library(self._library) + self._rebuild_item_list() + self.library_changed.emit() diff --git a/negpy/domain/models.py b/negpy/domain/models.py index 4d08dc1d..e9069097 100644 --- a/negpy/domain/models.py +++ b/negpy/domain/models.py @@ -327,6 +327,24 @@ def from_flat_dict(cls, data: Dict[str, Any]) -> "WorkspaceConfig": else: data.pop("same_as_source", None) + # Metadata: migrate legacy combined override fields to structured gear fields. + if data.get("camera_override") and not data.get("camera_model"): + co = str(data.pop("camera_override", "")).strip() + if co and not data.get("camera_make"): + parts = co.split(None, 1) + if len(parts) == 2: + data["camera_make"], data["camera_model"] = parts[0], parts[1] + else: + data["camera_model"] = co + elif co: + data["camera_model"] = co + else: + data.pop("camera_override", None) + if data.get("lens_override") and not data.get("lens_model"): + data["lens_model"] = str(data.pop("lens_override", "")).strip() + else: + data.pop("lens_override", None) + config_classes = [ ProcessConfig, ExposureConfig, diff --git a/negpy/domain/types.py b/negpy/domain/types.py index 27704cf8..8ddcbff1 100644 --- a/negpy/domain/types.py +++ b/negpy/domain/types.py @@ -35,6 +35,7 @@ class AppConfig: cache_dir: str user_icc_dir: str crosstalk_dir: str + gear_dir: str contact_sheet_templates_dir: str default_export_dir: str adobe_rgb_profile: str diff --git a/negpy/features/metadata/exif_read.py b/negpy/features/metadata/exif_read.py new file mode 100644 index 00000000..51dc9b11 --- /dev/null +++ b/negpy/features/metadata/exif_read.py @@ -0,0 +1,145 @@ +"""Read scan-rig metadata from source-file EXIF (piexif dict).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + +import piexif + + +def safe_str(value: Any) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace").strip("\x00") + if value is None: + return "" + return str(value).strip("\x00") + + +def rational_to_float(value: Any) -> Optional[float]: + if isinstance(value, tuple) and len(value) == 2: + num, den = value + if den == 0: + return None + return num / den + if isinstance(value, (int, float)): + return float(value) + return None + + +def rational_to_int(value: Any) -> Optional[int]: + f = rational_to_float(value) + return int(round(f)) if f is not None else None + + +def format_exposure(exif_tags: dict) -> str: + """Format scan exposure time, f-number, and ISO from an Exif IFD dict.""" + parts: list[str] = [] + + exposure_time = exif_tags.get(piexif.ExifIFD.ExposureTime) + if exposure_time is not None: + if isinstance(exposure_time, tuple) and len(exposure_time) == 2: + num, den = exposure_time + if num == 1: + parts.append(f"1/{den}s") + elif num: + parts.append(f"{num}/{den}s") + elif isinstance(exposure_time, (int, float)): + parts.append(f"{exposure_time}s") + + f_number = exif_tags.get(piexif.ExifIFD.FNumber) + f_val = rational_to_float(f_number) + if f_val is not None: + parts.append(f"f/{f_val:.1f}") + + iso = exif_tags.get(piexif.ExifIFD.ISOSpeedRatings) + if iso is not None: + if isinstance(iso, tuple) and len(iso) == 2: + iso = iso[0] // iso[1] if iso[1] else 0 + parts.append(f"ISO {iso}") + + return " ".join(parts) + + +@dataclass(frozen=True) +class ScanExif: + """DSLR / scanner rig metadata read from the source file before export overwrite.""" + + camera_make: str = "" + camera_model: str = "" + lens_make: str = "" + lens_model: str = "" + focal_length_mm: Optional[float] = None + aperture: Optional[float] = None + iso: Optional[int] = None + exposure: str = "" + + def has_any(self) -> bool: + return bool( + self.camera_make + or self.camera_model + or self.lens_make + or self.lens_model + or self.focal_length_mm is not None + or self.aperture is not None + or self.iso is not None + or self.exposure + ) + + +# Tags that describe the digitization rig; strip when writing film capture to standard EXIF. +_SCAN_RESIDUAL_EXIF_TAGS = frozenset( + { + piexif.ExifIFD.FocalLengthIn35mmFilm, + piexif.ExifIFD.LensSerialNumber, + piexif.ExifIFD.BodySerialNumber, + piexif.ExifIFD.LensSpecification, + piexif.ExifIFD.SubjectDistance, + piexif.ExifIFD.DigitalZoomRatio, + } +) + +_SCAN_EXPOSURE_EXIF_TAGS = frozenset( + { + piexif.ExifIFD.ExposureTime, + piexif.ExifIFD.FNumber, + piexif.ExifIFD.ISOSpeedRatings, + } +) + + +def strip_scan_exif_for_capture(exif_dict: dict) -> None: + """Remove scan-rig EXIF so film capture fields are not mixed with digitization data.""" + exif = exif_dict.get("Exif") + if not isinstance(exif, dict): + return + for tag in _SCAN_RESIDUAL_EXIF_TAGS: + exif.pop(tag, None) + for tag in _SCAN_EXPOSURE_EXIF_TAGS: + exif.pop(tag, None) + + +def extract_scan_from_exif(source_exif: dict | None) -> ScanExif: + if not source_exif or not isinstance(source_exif, dict): + return ScanExif() + + zeroth = source_exif.get("0th", {}) or {} + exif_tags = source_exif.get("Exif", {}) or {} + + iso_raw = exif_tags.get(piexif.ExifIFD.ISOSpeedRatings) + iso: Optional[int] = None + if isinstance(iso_raw, int): + iso = iso_raw + elif iso_raw is not None: + iso = rational_to_int(iso_raw) + + return ScanExif( + camera_make=safe_str(zeroth.get(piexif.ImageIFD.Make, "")), + camera_model=safe_str(zeroth.get(piexif.ImageIFD.Model, "")), + lens_make=safe_str(exif_tags.get(piexif.ExifIFD.LensMake, "")), + lens_model=safe_str(exif_tags.get(piexif.ExifIFD.LensModel, "")), + focal_length_mm=rational_to_float(exif_tags.get(piexif.ExifIFD.FocalLength)), + aperture=rational_to_float(exif_tags.get(piexif.ExifIFD.FNumber)), + iso=iso, + exposure=format_exposure(exif_tags), + ) diff --git a/negpy/features/metadata/gear_logic.py b/negpy/features/metadata/gear_logic.py new file mode 100644 index 00000000..402fbfcf --- /dev/null +++ b/negpy/features/metadata/gear_logic.py @@ -0,0 +1,87 @@ +"""Resolve gear library selections into MetadataConfig updates.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Optional + +from negpy.features.metadata.gear_models import GearLibrary +from negpy.features.metadata.models import MetadataConfig + + +def metadata_from_gear( + config: MetadataConfig, + library: GearLibrary, + *, + gear_preset_id: str = "", + camera_id: str = "", + lens_id: str = "", + film_stock_id: str = "", + clear_preset: bool = False, +) -> MetadataConfig: + """Build updated MetadataConfig from gear library selections.""" + preset_id = "" if clear_preset else (gear_preset_id or config.gear_preset_id) + cam_id = camera_id if camera_id != "" else config.camera_id + lens_id_val = lens_id if lens_id != "" else config.lens_id + film_id = film_stock_id if film_stock_id != "" else config.film_stock_id + + if preset_id: + preset = library.get_gear_preset(preset_id) + if preset: + cam_id = preset.camera_id or cam_id + lens_id_val = preset.lens_id or lens_id_val + film_id = preset.film_stock_id or film_id + + camera_make = "" + camera_model = "" + lens_make = "" + lens_model = "" + focal_length: Optional[float] = None + max_aperture: Optional[float] = None + film = config.film + film_manufacturer = "" + film_iso: Optional[int] = None + film_format = config.format + film_color_type = "" + + if cam_id: + cam = library.get_camera(cam_id) + if cam: + camera_make = cam.make + camera_model = cam.model + + if lens_id_val: + lens = library.get_lens(lens_id_val) + if lens: + lens_make = lens.make + lens_model = lens.lens_model or lens.resolved_display_name + focal_length = lens.focal_length_mm + max_aperture = lens.max_aperture + + if film_id: + stock = library.get_film_stock(film_id) + if stock: + film = stock.full_film_label + film_manufacturer = stock.manufacturer + film_iso = stock.iso + film_format = stock.format.value + film_color_type = stock.color_type.value + + return replace( + config, + gear_preset_id=preset_id, + camera_id=cam_id, + lens_id=lens_id_val, + film_stock_id=film_id, + camera_make=camera_make, + camera_model=camera_model, + lens_make=lens_make, + lens_model=lens_model, + focal_length_mm=focal_length, + max_aperture=max_aperture, + film=film, + film_manufacturer=film_manufacturer, + film_iso=film_iso, + format=film_format if film_id else config.format, + film_color_type=film_color_type, + ) diff --git a/negpy/features/metadata/gear_models.py b/negpy/features/metadata/gear_models.py new file mode 100644 index 00000000..af4780c4 --- /dev/null +++ b/negpy/features/metadata/gear_models.py @@ -0,0 +1,264 @@ +"""Domain models for the analog gear library (cameras, lenses, film stocks, presets).""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional + + +class FilmFormat(str, Enum): + FORMAT_35MM = "35mm" + FORMAT_120 = "120" + FORMAT_110 = "110" + FORMAT_4X5 = "4×5" + FORMAT_8X10 = "8×10" + OTHER = "Other" + + @classmethod + def from_storage(cls, value: str) -> "FilmFormat": + """Parse a format string from gear JSON (native value or legacy alias).""" + if value in {e.value for e in cls}: + return cls(value) + legacy = { + "Format35mm": cls.FORMAT_35MM, + "Format120": cls.FORMAT_120, + "Format110": cls.FORMAT_110, + "LargeFormat": cls.FORMAT_4X5, + } + return legacy.get(value, cls.OTHER) + + def to_storage(self) -> str: + return self.value + + +class FilmColorType(str, Enum): + COLOR_NEGATIVE = "ColorNegative" + BW_NEGATIVE = "B&W Negative" + COLOR_SLIDE = "ColorSlide" + BW_SLIDE = "B&W Slide" + OTHER = "Other" + + @classmethod + def from_storage(cls, value: str) -> "FilmColorType": + """Parse a color-type string from gear JSON (native value or legacy alias).""" + if value in {e.value for e in cls}: + return cls(value) + legacy = { + "ColorNegative": cls.COLOR_NEGATIVE, + "BlackAndWhiteNegative": cls.BW_NEGATIVE, + "ColorSlide": cls.COLOR_SLIDE, + "BlackAndWhiteSlide": cls.BW_SLIDE, + } + return legacy.get(value, cls.OTHER) + + def to_storage(self) -> str: + return self.value + + +def _new_id() -> str: + return uuid.uuid4().hex + + +@dataclass +class Camera: + id: str = field(default_factory=_new_id) + make: str = "" + model: str = "" + display_name: str = "" + serial_number: str = "" + notes: str = "" + + @property + def resolved_display_name(self) -> str: + if self.display_name.strip(): + return self.display_name.strip() + return f"{self.make} {self.model}".strip() + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "make": self.make, + "model": self.model, + "displayName": self.display_name, + "serialNumber": self.serial_number or None, + "notes": self.notes or None, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Camera": + return cls( + id=str(data.get("id") or _new_id()), + make=str(data.get("make") or ""), + model=str(data.get("model") or ""), + display_name=str(data.get("displayName") or data.get("display_name") or ""), + serial_number=str(data.get("serialNumber") or data.get("serial_number") or ""), + notes=str(data.get("notes") or ""), + ) + + +@dataclass +class Lens: + id: str = field(default_factory=_new_id) + lens_model: str = "" + make: str = "" + display_name: str = "" + focal_length_mm: Optional[float] = None + max_aperture: Optional[float] = None + serial_number: str = "" + notes: str = "" + + @property + def resolved_display_name(self) -> str: + if self.display_name.strip(): + return self.display_name.strip() + if self.lens_model.strip(): + return self.lens_model.strip() + return "Unnamed lens" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "id": self.id, + "lensModel": self.lens_model, + "make": self.make or None, + "displayName": self.display_name, + "serialNumber": self.serial_number or None, + "notes": self.notes or None, + } + if self.focal_length_mm is not None: + d["focalLength"] = self.focal_length_mm + if self.max_aperture is not None: + d["maxAperture"] = self.max_aperture + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Lens": + fl = data.get("focalLength", data.get("focal_length_mm")) + ap = data.get("maxAperture", data.get("max_aperture")) + return cls( + id=str(data.get("id") or _new_id()), + lens_model=str(data.get("lensModel") or data.get("lens_model") or ""), + make=str(data.get("make") or ""), + display_name=str(data.get("displayName") or data.get("display_name") or ""), + focal_length_mm=float(fl) if fl is not None else None, + max_aperture=float(ap) if ap is not None else None, + serial_number=str(data.get("serialNumber") or data.get("serial_number") or ""), + notes=str(data.get("notes") or ""), + ) + + +@dataclass +class FilmStock: + id: str = field(default_factory=_new_id) + manufacturer: str = "" + stock_name: str = "" + display_name: str = "" + iso: int = 100 + format: FilmFormat = FilmFormat.FORMAT_35MM + color_type: FilmColorType = FilmColorType.COLOR_NEGATIVE + notes: str = "" + + @property + def resolved_display_name(self) -> str: + if self.display_name.strip(): + return self.display_name.strip() + if self.manufacturer.strip(): + return f"{self.manufacturer} {self.stock_name}".strip() + return self.stock_name.strip() + + @property + def full_film_label(self) -> str: + if self.manufacturer.strip(): + return f"{self.manufacturer} {self.stock_name}".strip() + return self.stock_name.strip() + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "displayName": self.display_name, + "manufacturer": self.manufacturer, + "stockName": self.stock_name, + "iso": self.iso, + "format": self.format.to_storage(), + "colorType": self.color_type.to_storage(), + "notes": self.notes or None, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FilmStock": + fmt_raw = data.get("format", "35mm") + if isinstance(fmt_raw, FilmFormat): + fmt = fmt_raw + else: + fmt = FilmFormat.from_storage(str(fmt_raw)) + + ct_raw = data.get("colorType", data.get("color_type", "ColorNegative")) + if isinstance(ct_raw, FilmColorType): + ct = ct_raw + else: + ct = FilmColorType.from_storage(str(ct_raw)) + + return cls( + id=str(data.get("id") or _new_id()), + manufacturer=str(data.get("manufacturer") or ""), + stock_name=str(data.get("stockName") or data.get("stock_name") or ""), + display_name=str(data.get("displayName") or data.get("display_name") or ""), + iso=int(data.get("iso") or 100), + format=fmt, + color_type=ct, + notes=str(data.get("notes") or ""), + ) + + +@dataclass +class GearPreset: + id: str = field(default_factory=_new_id) + display_name: str = "" + camera_id: str = "" + lens_id: str = "" + film_stock_id: str = "" + notes: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "displayName": self.display_name, + "cameraId": self.camera_id or None, + "lensId": self.lens_id or None, + "filmStockId": self.film_stock_id or None, + "notes": self.notes or None, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "GearPreset": + return cls( + id=str(data.get("id") or _new_id()), + display_name=str(data.get("displayName") or data.get("display_name") or ""), + camera_id=str(data.get("cameraId") or data.get("camera_id") or ""), + lens_id=str(data.get("lensId") or data.get("lens_id") or ""), + film_stock_id=str(data.get("filmStockId") or data.get("film_stock_id") or ""), + notes=str(data.get("notes") or ""), + ) + + +@dataclass +class GearLibrary: + """In-memory snapshot of all gear library collections.""" + + cameras: list[Camera] = field(default_factory=list) + lenses: list[Lens] = field(default_factory=list) + film_stocks: list[FilmStock] = field(default_factory=list) + gear_presets: list[GearPreset] = field(default_factory=list) + + def get_camera(self, camera_id: str) -> Optional[Camera]: + return next((c for c in self.cameras if c.id == camera_id), None) + + def get_lens(self, lens_id: str) -> Optional[Lens]: + return next((lens for lens in self.lenses if lens.id == lens_id), None) + + def get_film_stock(self, film_stock_id: str) -> Optional[FilmStock]: + return next((f for f in self.film_stocks if f.id == film_stock_id), None) + + def get_gear_preset(self, preset_id: str) -> Optional[GearPreset]: + return next((p for p in self.gear_presets if p.id == preset_id), None) diff --git a/negpy/features/metadata/models.py b/negpy/features/metadata/models.py index 2eb08c0b..e8e3e566 100644 --- a/negpy/features/metadata/models.py +++ b/negpy/features/metadata/models.py @@ -1,4 +1,16 @@ from dataclasses import dataclass +from typing import Optional + + +PUSH_PULL_LABELS = { + -3: "Pull -3", + -2: "Pull -2", + -1: "Pull -1", + 0: "Normal", + 1: "Push +1", + 2: "Push +2", + 3: "Push +3", +} @dataclass(frozen=True) @@ -8,15 +20,29 @@ class MetadataConfig: Empty strings = field not set (nothing written to export). """ + # Gear library references (empty = manual entry / not linked) + gear_preset_id: str = "" + camera_id: str = "" + lens_id: str = "" + film_stock_id: str = "" + + # Structured gear fields (resolved from library or manual) + camera_make: str = "" + camera_model: str = "" + lens_make: str = "" + lens_model: str = "" + focal_length_mm: Optional[float] = None + max_aperture: Optional[float] = None + film_iso: Optional[int] = None + film_manufacturer: str = "" + film_color_type: str = "" + film: str = "" - format: str = "" # "35mm" | "120" | "4x5" | "8x10" | "Other" | "" + format: str = "" # "35mm" | "120" | "4×5" | "8×10" | "110" | "Other" | "" format_other: str = "" # shown when format == "Other" developer: str = "" push_pull: int = 0 # -3..+3, 0 = Normal scanning: str = "" sync_to_batch: bool = False - # EXIF field overrides (editable by user, empty = use source EXIF) - camera_override: str = "" - lens_override: str = "" - exposure_override: str = "" + exposure_override: str = "" # free-text e.g. "1/125s f/2.8 ISO 400"; empty = use source EXIF diff --git a/negpy/features/metadata/payload.py b/negpy/features/metadata/payload.py new file mode 100644 index 00000000..251c8a4b --- /dev/null +++ b/negpy/features/metadata/payload.py @@ -0,0 +1,331 @@ +"""Resolve MetadataConfig + gear library into export-ready metadata.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Optional + +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 + +_NEGPY_SOFTWARE = "NegPy" +NEGPY_SOFTWARE = _NEGPY_SOFTWARE + + +@dataclass(frozen=True) +class ExifWriteFlags: + """Which standard EXIF fields to write from film capture (vs leave source scan EXIF).""" + + camera: bool = False + lens: bool = False + film_iso: bool = False + exposure: bool = False + + @property + def strip_scan_residuals(self) -> bool: + return self.camera or self.lens or self.film_iso or self.exposure + + +def has_capture_gear(config: MetadataConfig) -> bool: + """True when the user provided gear/exposure data that should replace scan EXIF.""" + if config.camera_id or config.lens_id or config.film_stock_id: + return True + if config.camera_make or config.camera_model or config.lens_make or config.lens_model: + return True + if config.film or config.film_iso is not None: + return True + if config.focal_length_mm is not None or config.max_aperture is not None: + return True + if config.exposure_override.strip(): + return True + return False + + +def compute_exif_write_flags(config: MetadataConfig, payload: "MetadataPayload") -> ExifWriteFlags: + """Decide which standard EXIF tags to overwrite with film capture data.""" + if not has_capture_gear(config): + return ExifWriteFlags() + + return ExifWriteFlags( + camera=bool(payload.camera_make or payload.camera_model or config.camera_id), + lens=bool( + payload.lens_make + or payload.lens_model + or config.lens_id + or payload.focal_length_mm is not None + or payload.max_aperture is not None + ), + film_iso=payload.iso is not None, + exposure=bool(payload.capture_exposure), + ) + + +@dataclass(frozen=True) +class MetadataPayload: + """Resolved metadata that will be written to exported files.""" + + # Original analog capture (standard EXIF when exif_flags permit; negpy:Capture* in XMP) + camera_make: str = "" + camera_model: str = "" + lens_make: str = "" + lens_model: str = "" + focal_length_mm: Optional[float] = None + max_aperture: Optional[float] = None + iso: Optional[int] = None + film_stock: str = "" + film_manufacturer: str = "" + film_format: str = "" + film_color_type: str = "" + capture_exposure: str = "" + + # Digitization rig (negpy:Scan* XMP only; source EXIF when capture gear not set) + scan_camera_make: str = "" + scan_camera_model: str = "" + scan_lens_make: str = "" + scan_lens_model: str = "" + scan_focal_length_mm: Optional[float] = None + scan_aperture: Optional[float] = None + scan_iso: Optional[int] = None + scan_exposure: str = "" + scan_method: str = "" + + image_description: str = "" + developer: str = "" + push_pull: str = "" + notes: str = "" + exif_flags: ExifWriteFlags = ExifWriteFlags() + + def camera_display(self) -> str: + return f"{self.camera_make} {self.camera_model}".strip() + + def lens_display(self) -> str: + return self.lens_model or self.lens_make + + def scan_camera_display(self) -> str: + return f"{self.scan_camera_make} {self.scan_camera_model}".strip() + + def to_preview_sections(self) -> list[tuple[str, list[tuple[str, str]]]]: + """Grouped preview: original capture, scan rig, process.""" + sections: list[tuple[str, list[tuple[str, str]]]] = [] + + capture: list[tuple[str, str]] = [] + if self.camera_make: + capture.append(("Camera make", self.camera_make)) + if self.camera_model: + capture.append(("Camera model", self.camera_model)) + if self.lens_make: + capture.append(("Lens make", self.lens_make)) + if self.lens_model: + capture.append(("Lens model", self.lens_model)) + if self.focal_length_mm is not None: + capture.append(("Focal length", f"{self.focal_length_mm:g} mm")) + if self.max_aperture is not None: + capture.append(("Max aperture", f"f/{self.max_aperture:g}")) + if self.capture_exposure: + capture.append(("Exposure", self.capture_exposure)) + if self.iso is not None: + capture.append(("Film ISO", str(self.iso))) + if self.film_stock: + capture.append(("Film stock", self.film_stock)) + if self.film_manufacturer: + capture.append(("Film manufacturer", self.film_manufacturer)) + if self.film_format: + capture.append(("Film format", self.film_format)) + if self.film_color_type: + capture.append(("Film type", self.film_color_type)) + if capture: + sections.append(("Original capture", capture)) + + scan: list[tuple[str, str]] = [] + if self.scan_camera_make: + scan.append(("Camera make", self.scan_camera_make)) + if self.scan_camera_model: + scan.append(("Camera model", self.scan_camera_model)) + if self.scan_lens_make: + scan.append(("Lens make", self.scan_lens_make)) + if self.scan_lens_model: + scan.append(("Lens model", self.scan_lens_model)) + if self.scan_focal_length_mm is not None: + scan.append(("Focal length", f"{self.scan_focal_length_mm:g} mm")) + if self.scan_aperture is not None: + scan.append(("Aperture", f"f/{self.scan_aperture:g}")) + if self.scan_exposure: + scan.append(("Exposure", self.scan_exposure)) + if self.scan_iso is not None: + scan.append(("ISO", str(self.scan_iso))) + if self.scan_method: + scan.append(("Scan method", self.scan_method)) + if scan: + sections.append(("Scan", scan)) + + process: list[tuple[str, str]] = [] + if self.film_format and not capture: + process.append(("Format", self.film_format)) + if self.developer: + process.append(("Developer", self.developer)) + if self.push_pull and self.push_pull != "Normal": + process.append(("Push / pull", self.push_pull)) + if process: + sections.append(("Process", process)) + + summary: list[tuple[str, str]] = [] + if self.image_description: + summary.append(("Image description", self.image_description)) + summary.append(("Software", _NEGPY_SOFTWARE)) + if self.exif_flags.strip_scan_residuals: + summary.append(("EXIF capture tags", "Written to standard EXIF")) + elif self.scan_camera_make or self.scan_camera_model: + summary.append(("EXIF camera/lens", "Source scan device (capture not set)")) + if summary: + sections.append(("File", summary)) + + return sections + + def to_preview_pairs(self) -> list[tuple[str, str]]: + pairs: list[tuple[str, str]] = [] + for _title, rows in self.to_preview_sections(): + pairs.extend(rows) + return pairs + + def has_any_data(self) -> bool: + return bool(self.to_preview_pairs()) + + +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.""" + 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: + parts.append(payload.film_stock) + if payload.iso is not None: + parts.append(f"ISO {payload.iso}") + if parts: + return " • ".join(parts) + return payload.film_stock or "" + + +def build_metadata_payload( + config: MetadataConfig, + gear: Optional[GearLibrary] = None, + source_exif: Optional[dict[str, Any]] = None, +) -> MetadataPayload: + """Resolve gear IDs, config fields, and source scan EXIF into an export payload.""" + camera_make = config.camera_make + camera_model = config.camera_model + lens_make = config.lens_make + lens_model = config.lens_model + focal_length = config.focal_length_mm + max_aperture = config.max_aperture + iso = config.film_iso + film_manufacturer = config.film_manufacturer + film_format = config.format_other if config.format == "Other" else config.format + film_color_type = config.film_color_type + film_stock = config.film + + if gear is not None: + if config.camera_id: + cam = gear.get_camera(config.camera_id) + if cam: + if not camera_make: + camera_make = cam.make + if not camera_model: + camera_model = cam.model + if config.lens_id: + lens = gear.get_lens(config.lens_id) + if lens: + if not lens_make: + lens_make = lens.make + if not lens_model: + lens_model = lens.lens_model or lens.resolved_display_name + if focal_length is None: + focal_length = lens.focal_length_mm + if max_aperture is None: + max_aperture = lens.max_aperture + if config.film_stock_id: + stock = gear.get_film_stock(config.film_stock_id) + if stock: + if not film_stock: + film_stock = stock.full_film_label + if not film_manufacturer: + film_manufacturer = stock.manufacturer + if iso is None: + iso = stock.iso + if not film_format: + film_format = stock.format.value + if not film_color_type: + film_color_type = stock.color_type.value + + scan: ScanExif = extract_scan_from_exif(source_exif) + push_pull = PUSH_PULL_LABELS.get(config.push_pull, "Normal") + capture_exposure = config.exposure_override.strip() + + draft = MetadataPayload( + camera_make=camera_make.strip(), + camera_model=camera_model.strip(), + lens_make=lens_make.strip(), + lens_model=lens_model.strip(), + focal_length_mm=focal_length, + max_aperture=max_aperture, + iso=iso, + film_stock=film_stock.strip(), + film_manufacturer=film_manufacturer.strip(), + film_format=film_format.strip(), + film_color_type=film_color_type.strip(), + capture_exposure=capture_exposure, + scan_camera_make=scan.camera_make, + scan_camera_model=scan.camera_model, + scan_lens_make=scan.lens_make, + scan_lens_model=scan.lens_model, + scan_focal_length_mm=scan.focal_length_mm, + scan_aperture=scan.aperture, + scan_iso=scan.iso, + scan_exposure=scan.exposure, + scan_method=config.scanning.strip(), + developer=config.developer.strip(), + push_pull=push_pull, + ) + + desc = build_image_description(draft) + if not desc and config.film: + desc = config.film.strip() + + exif_flags = compute_exif_write_flags(config, draft) + + return MetadataPayload( + camera_make=draft.camera_make, + camera_model=draft.camera_model, + lens_make=draft.lens_make, + lens_model=draft.lens_model, + focal_length_mm=draft.focal_length_mm, + max_aperture=draft.max_aperture, + iso=draft.iso, + film_stock=draft.film_stock, + film_manufacturer=draft.film_manufacturer, + film_format=draft.film_format, + film_color_type=draft.film_color_type, + capture_exposure=draft.capture_exposure, + scan_camera_make=draft.scan_camera_make, + scan_camera_model=draft.scan_camera_model, + scan_lens_make=draft.scan_lens_make, + scan_lens_model=draft.scan_lens_model, + scan_focal_length_mm=draft.scan_focal_length_mm, + scan_aperture=draft.scan_aperture, + scan_iso=draft.scan_iso, + scan_exposure=draft.scan_exposure, + scan_method=draft.scan_method, + image_description=desc, + developer=draft.developer, + push_pull=draft.push_pull, + exif_flags=exif_flags, + ) diff --git a/negpy/features/metadata/writer.py b/negpy/features/metadata/writer.py index 06da48ac..89356b5b 100644 --- a/negpy/features/metadata/writer.py +++ b/negpy/features/metadata/writer.py @@ -1,30 +1,28 @@ -"""Pure functions to embed custom metadata into exported image bytes via piexif.""" +"""Pure functions to embed custom metadata into exported image bytes via piexif + XMP.""" +import copy import io import logging import re +import struct from fractions import Fraction from typing import Optional import piexif import tifffile -from PIL import Image +from PIL import Image, PngImagePlugin +from negpy.features.metadata.exif_read import strip_scan_exif_for_capture +from negpy.features.metadata.gear_models import GearLibrary from negpy.features.metadata.models import MetadataConfig +from negpy.features.metadata.payload import NEGPY_SOFTWARE, MetadataPayload, build_metadata_payload +from negpy.features.metadata.xmp import build_xmp_bytes +from negpy.services.assets.gear import GearProfiles _log = logging.getLogger(__name__) - -# Push/pull label mapping -PUSH_PULL_LABELS = { - -3: "Pull -3", - -2: "Pull -2", - -1: "Pull -1", - 0: "Normal", - 1: "Push +1", - 2: "Push +2", - 3: "Push +3", -} +_XMP_APP1_HEADER = b"http://ns.adobe.com/xap/1.0/\x00" +_TIFF_XMP_TAG = 700 # XMLPacket def _parse_exposure_str(text: str) -> dict: @@ -66,57 +64,79 @@ def _parse_exposure_str(text: str) -> dict: return result -def _build_custom_exif(config: MetadataConfig) -> dict: - """Build a piexif-format EXIF dict containing only the custom metadata fields.""" +def _rational_tuple(value: float) -> tuple[int, int]: + f = Fraction(value).limit_denominator(1000) + return f.numerator, f.denominator - zeroth: dict = {} - exif: dict = {} - if config.film: - zeroth[piexif.ImageIFD.ImageDescription] = config.film +def _apex_from_f_number(f_number: float) -> float: + import math + + return 2.0 * math.log(f_number, 2.0) + - if config.scanning: - zeroth[piexif.ImageIFD.Software] = config.scanning +def _exif_ascii(text: str) -> bytes: + return text.encode("ascii", errors="replace") - # Pack film/format/developer/push_pull into UserComment - user_comment_parts = {} - if config.film: - user_comment_parts["film"] = config.film - fmt_value = config.format_other if config.format == "Other" else config.format - if fmt_value: - user_comment_parts["format"] = fmt_value - if config.developer: - user_comment_parts["developer"] = config.developer - if config.push_pull != 0: - user_comment_parts["push_pull"] = PUSH_PULL_LABELS.get(config.push_pull, str(config.push_pull)) + +def _build_custom_exif(payload: MetadataPayload) -> dict: + """Build a piexif-format EXIF dict from a resolved metadata payload.""" + + zeroth: dict = {} + exif: dict = {} + flags = payload.exif_flags + + if payload.image_description: + zeroth[piexif.ImageIFD.ImageDescription] = _exif_ascii(payload.image_description) + elif payload.film_stock: + zeroth[piexif.ImageIFD.ImageDescription] = _exif_ascii(payload.film_stock) + + zeroth[piexif.ImageIFD.Software] = _exif_ascii(NEGPY_SOFTWARE) + + if flags.camera: + if payload.camera_make: + zeroth[piexif.ImageIFD.Make] = _exif_ascii(payload.camera_make) + if payload.camera_model: + zeroth[piexif.ImageIFD.Model] = _exif_ascii(payload.camera_model) + + if flags.lens: + if payload.lens_make: + exif[piexif.ExifIFD.LensMake] = _exif_ascii(payload.lens_make) + if payload.lens_model: + exif[piexif.ExifIFD.LensModel] = _exif_ascii(payload.lens_model) + if payload.focal_length_mm is not None: + exif[piexif.ExifIFD.FocalLength] = _rational_tuple(payload.focal_length_mm) + if payload.max_aperture is not None: + exif[piexif.ExifIFD.FNumber] = _rational_tuple(payload.max_aperture) + exif[piexif.ExifIFD.MaxApertureValue] = _rational_tuple(_apex_from_f_number(payload.max_aperture)) + + if flags.film_iso and payload.iso is not None: + exif[piexif.ExifIFD.ISOSpeedRatings] = payload.iso + + user_comment_parts: dict[str, str] = {} + if payload.film_stock: + user_comment_parts["film"] = payload.film_stock + if payload.film_format: + user_comment_parts["format"] = payload.film_format + if payload.developer: + user_comment_parts["developer"] = payload.developer + if payload.push_pull and payload.push_pull != "Normal": + user_comment_parts["push_pull"] = payload.push_pull if user_comment_parts: - # EXIF UserComment: 8-byte character code prefix + encoded content. - # ASCII prefix is universally supported; UNICODE/UTF-16-LE causes garbled - # output in most EXIF readers (ExifTool, macOS Preview, Lightroom). lines = [f"{k.replace('_', ' ').title()}: {v}" for k, v in user_comment_parts.items()] uc_bytes = b"ASCII\x00\x00\x00" + "\n".join(lines).encode("ascii") exif[piexif.ExifIFD.UserComment] = uc_bytes - # ── EXIF field overrides ───────────────────────────────────────────── - if config.camera_override: - zeroth[piexif.ImageIFD.Model] = config.camera_override - - if config.lens_override: - exif[piexif.ExifIFD.LensModel] = config.lens_override - - if config.exposure_override: - parsed = _parse_exposure_str(config.exposure_override) - exif.update(parsed) + if flags.exposure and payload.capture_exposure: + exif.update(_parse_exposure_str(payload.capture_exposure)) return {"0th": zeroth, "Exif": exif, "GPS": {}, "Interop": {}, "1st": {}} def _sanitize_exif(exif_dict: dict) -> dict: - """Drop entries piexif can't serialize: RATIONAL/SRATIONAL stored as raw bytes, and - SHORT tags whose (malformed) value overflows 0..65535. ASCII tags (type 2) legitimately - use bytes and are left untouched.""" - _RATIONAL_TYPES = {5, 10} # RATIONAL, SRATIONAL + """Drop entries piexif can't serialize.""" + _RATIONAL_TYPES = {5, 10} def _short_overflows(value) -> bool: vals = value if isinstance(value, (tuple, list)) else (value,) @@ -133,38 +153,21 @@ def _short_overflows(value) -> bool: tag_type = tags_info.get(tag, {}).get("type") if isinstance(value, bytes) and tag_type in _RATIONAL_TYPES: continue - if tag_type == 3 and _short_overflows(value): # SHORT out of range + if tag_type == 3 and _short_overflows(value): continue clean[tag] = value result[ifd_name] = clean return result -# IFD0 tags copied from an embedded RAW preview/thumbnail IFD. Valid inside the RAW -# container but invalid in a standalone JPEG APP1 block — ExifTool fails with -# "Can't read SubIFD data" / "Error reading StripOffsets data" when they remain. _JPEG_STRIP_0TH = frozenset( { - 254, # NewSubfileType - 256, - 257, - 258, - 259, - 262, # preview image structure - 273, - 277, - 278, - 279, - 284, # strip layout - 330, # SubIFDs - 513, - 514, # JpegIFOffset / JpegIFByteCount + 254, 256, 257, 258, 259, 262, 273, 277, 278, 279, 284, 330, 513, 514, } ) def _prepare_jpeg_exif(exif_dict: dict) -> dict: - """Sanitize source EXIF and drop RAW preview IFD baggage before JPEG serialization.""" prepared = _sanitize_exif(exif_dict) prepared.pop("thumbnail", None) prepared["1st"] = {} @@ -175,80 +178,141 @@ def _prepare_jpeg_exif(exif_dict: dict) -> dict: return prepared +def _resolve_payload( + config: MetadataConfig, + gear: Optional[GearLibrary], + source_exif: Optional[dict], +) -> MetadataPayload: + if gear is None: + gear = GearProfiles.load_library() + return build_metadata_payload(config, gear, source_exif) + + def embed_metadata( image_bytes: bytes, config: MetadataConfig, source_exif: Optional[dict], + gear: Optional[GearLibrary] = None, ) -> bytes: """ - Insert custom metadata + preserved source EXIF into exported image bytes. - - Args: - image_bytes: JPEG or TIFF image bytes from the rendering pipeline. - config: MetadataConfig with user-entered custom fields. - source_exif: piexif-format EXIF dict from the source file (or None). - - Returns: - Image bytes with embedded metadata. + Insert custom metadata + preserved source EXIF + XMP into exported image bytes. """ - # Start with source EXIF if available, otherwise empty shell + payload = _resolve_payload(config, gear, source_exif) + if source_exif is not None: - merged = source_exif + merged = copy.deepcopy(source_exif) else: merged = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}} - # Overlay custom metadata - custom = _build_custom_exif(config) + if payload.exif_flags.strip_scan_residuals: + strip_scan_exif_for_capture(merged) + + custom = _build_custom_exif(payload) for ifd_name in ("0th", "Exif", "GPS", "Interop", "1st"): if ifd_name in custom and custom[ifd_name]: if ifd_name not in merged: merged[ifd_name] = {} merged[ifd_name].update(custom[ifd_name]) - # Pixels are exported upright; declare normal orientation so viewers don't re-rotate. merged.setdefault("0th", {})[piexif.ImageIFD.Orientation] = 1 if isinstance(merged.get("1st"), dict): merged["1st"].pop(piexif.ImageIFD.Orientation, None) + xmp_bytes = build_xmp_bytes(payload) if payload.has_any_data() else None + try: output = io.BytesIO() if image_bytes[:2] == b"\xff\xd8": - # JPEG APP1 (EXIF) must fit in a single 64 KB segment. - exif_bytes = _dump_exif_within_app1_limit(merged, config) - piexif.insert(exif_bytes, image_bytes, output) + exif_bytes = _dump_exif_within_app1_limit(merged, payload) + jpeg_buf = io.BytesIO() + piexif.insert(exif_bytes, image_bytes, jpeg_buf) + jpeg_with_exif = jpeg_buf.getvalue() + output = io.BytesIO() + result = _inject_jpeg_xmp(jpeg_with_exif, xmp_bytes) if xmp_bytes else jpeg_with_exif + output.write(result) elif image_bytes[:8] == b"\x89PNG\r\n\x1a\n": - # PNG stores EXIF in an eXIf chunk (no 64 KB cap); Pillow writes it. exif_bytes = piexif.dump(_sanitize_exif(merged)) - _rewrite_png_with_metadata(image_bytes, exif_bytes, output) + _rewrite_png_with_metadata(image_bytes, exif_bytes, output, xmp_bytes) else: - # TIFF has no 64 KB EXIF cap (tifffile writes a separate IFD). exif_bytes = piexif.dump(_sanitize_exif(merged)) - _rewrite_tiff_with_metadata(image_bytes, exif_bytes, output) + _rewrite_tiff_with_metadata(image_bytes, exif_bytes, output, xmp_bytes) return output.getvalue() except Exception: _log.warning("metadata embed failed", exc_info=True) return image_bytes -# JPEG APP1 segment is 2 bytes for the length field (incl. itself) → 65535 max, -# leaving 65533 for the EXIF payload that piexif.insert prefixes with \xff\xe1+len. _APP1_EXIF_LIMIT = 65533 -def _dump_exif_within_app1_limit(merged: dict, config: MetadataConfig) -> bytes: - """ - Serialize EXIF for a JPEG so it always fits the 64 KB APP1 segment. +def _strip_jpeg_xmp_segments(data: bytes) -> bytes: + out = bytearray(data[:2]) + i = 2 + n = len(data) + while i < n: + if data[i] != 0xFF: + out.extend(data[i:]) + break + marker = data[i + 1] + if marker == 0xD9: + out.extend(data[i:]) + break + if marker in range(0xD0, 0xD8): + out.extend(data[i : i + 2]) + i += 2 + continue + if i + 4 > n: + out.extend(data[i:]) + break + seg_len = struct.unpack(">H", data[i + 2 : i + 4])[0] + seg_end = i + 2 + seg_len + if marker == 0xE1 and seg_end <= n: + payload_start = i + 4 + if data[payload_start : payload_start + len(_XMP_APP1_HEADER)] == _XMP_APP1_HEADER: + i = seg_end + continue + out.extend(data[i:seg_end]) + i = seg_end + return bytes(out) + + +def _inject_jpeg_xmp(jpeg_bytes: bytes, xmp_bytes: bytes) -> bytes: + """Insert or replace an XMP APP1 segment in a JPEG.""" + if not xmp_bytes: + return jpeg_bytes + cleaned = _strip_jpeg_xmp_segments(jpeg_bytes) + payload = _XMP_APP1_HEADER + xmp_bytes + seg_len = len(payload) + 2 + if seg_len > 65535: + _log.warning("XMP packet too large for JPEG APP1; skipping XMP embed") + return jpeg_bytes + xmp_segment = b"\xff\xe1" + struct.pack(">H", seg_len) + payload + insert_at = 2 + i = 2 + n = len(cleaned) + while i < n: + if cleaned[i] != 0xFF: + break + marker = cleaned[i + 1] + if marker in range(0xD0, 0xD8): + i += 2 + continue + if i + 4 > n: + break + seg_len = struct.unpack(">H", cleaned[i + 2 : i + 4])[0] + seg_end = i + 2 + seg_len + if marker in (0xE0, 0xE1, 0xED, 0xFE): + insert_at = seg_end + i = seg_end + continue + break + return cleaned[:insert_at] + xmp_segment + cleaned[insert_at:] - Tries to keep as much source EXIF as fits, dropping the usual offenders first - (thumbnail, then MakerNote). If a non-standard large tag (e.g. embedded XMP in - ImageDescription) still overflows, falls back to NegPy's own small fields, and finally - to orientation-only — which is guaranteed to fit, so piexif.insert can never overflow. - """ + +def _dump_exif_within_app1_limit(merged: dict, payload: MetadataPayload) -> bytes: candidate = _prepare_jpeg_exif(merged) def _fits() -> Optional[bytes]: - # Treat both an oversized result and a dump failure (e.g. malformed source - # thumbnail) as "needs more trimming". try: b = piexif.dump(candidate) except Exception: @@ -259,35 +323,26 @@ def _fits() -> Optional[bytes]: if exif_bytes is not None: return exif_bytes - # 1) Drop MakerNote (can be tens of KB on some bodies). if isinstance(candidate.get("Exif"), dict): candidate["Exif"].pop(piexif.ExifIFD.MakerNote, None) exif_bytes = _fits() if exif_bytes is not None: return exif_bytes - # 2) Source EXIF still too big (e.g. a bloated ImageDescription/XMP/GPS): discard it and - # keep only NegPy's own fields, which are always small. _log.warning("source EXIF too large for JPEG APP1; keeping only NegPy metadata") - candidate = _prepare_jpeg_exif(_build_custom_exif(config)) + candidate = _prepare_jpeg_exif(_build_custom_exif(payload)) candidate.setdefault("0th", {})[piexif.ImageIFD.Orientation] = 1 exif_bytes = _fits() if exif_bytes is not None: return exif_bytes - # 3) Absolute floor — orientation only. Cannot exceed the limit. candidate = {"0th": {piexif.ImageIFD.Orientation: 1}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}} return piexif.dump(candidate) -# TIFF type codes we know how to map onto tifffile extratags. -_TIFF_TYPE_SCALAR = {3, 4, 8, 9} # SHORT, LONG, SSHORT, SLONG -_TIFF_TYPE_RATIONAL = {5, 10} # RATIONAL, SRATIONAL - -# Tags tifffile owns. TAG_FILTERED covers the core image IFD (and the EXIF/GPS -# sub-IFD pointers, conveniently); the rest correspond to tifffile.imwrite -# kwargs we already pass (description, resolution, software, iccprofile). -_TIFFFILE_RESERVED_TAGS: set[int] = set(tifffile.TIFF.TAG_FILTERED) | {270, 282, 283, 296, 305, 34675} +_TIFF_TYPE_SCALAR = {3, 4, 8, 9} +_TIFF_TYPE_RATIONAL = {5, 10} +_TIFFFILE_RESERVED_TAGS: set[int] = set(tifffile.TIFF.TAG_FILTERED) | {270, 282, 283, 296, 305, 34675, _TIFF_XMP_TAG} def _decode_ascii(value: object) -> str | None: @@ -299,13 +354,6 @@ def _decode_ascii(value: object) -> str | None: def _exif_bytes_to_extratags(exif_bytes: bytes) -> tuple[str | None, list[tuple]]: - """Flatten a piexif EXIF block into ``(description, extratags)`` for tifffile. - - EXIF/GPS sub-IFD entries are hoisted into the main IFD because tifffile - has no API for writing sub-IFDs, and PIL's own ``exif=`` path is broken - for TIFF (see ``_rewrite_tiff_with_metadata``). The description is split - out so it can be passed via ``description=`` instead of ``extratags``. - """ exif_dict = piexif.load(exif_bytes) description = _decode_ascii(exif_dict.get("0th", {}).get(piexif.ImageIFD.ImageDescription)) @@ -327,14 +375,13 @@ def _exif_bytes_to_extratags(exif_bytes: bytes) -> tuple[str | None, list[tuple] def _build_extratag(tag: int, ttype: int, value: object) -> tuple | None: - """Coerce a piexif value into a tifffile extratag tuple, or None if untranslatable.""" - if ttype == 2: # ASCII + if ttype == 2: text = _decode_ascii(value) if text is None: return None - return (tag, ttype, 0, text, True) # count=0: tifffile sizes ASCII itself + return (tag, ttype, 0, text, True) - if ttype in (1, 7): # BYTE, UNDEFINED + if ttype in (1, 7): if not isinstance(value, (bytes, bytearray)): return None return (tag, ttype, len(value), bytes(value), True) @@ -350,8 +397,6 @@ def _build_extratag(tag: int, ttype: int, value: object) -> tuple | None: if isinstance(value, tuple) and len(value) == 2 and all(isinstance(v, int) for v in value): return (tag, ttype, 1, value, True) if isinstance(value, (list, tuple)) and all(isinstance(v, tuple) and len(v) == 2 for v in value): - # tifffile internally doubles count for RATIONAL and unpacks `*value`, - # so multi-element values must be a flat sequence of ints. flat = [n for pair in value for n in pair] return (tag, ttype, len(value), flat, True) return None @@ -359,32 +404,30 @@ def _build_extratag(tag: int, ttype: int, value: object) -> tuple | None: return None -def _rewrite_png_with_metadata(image_bytes: bytes, exif_bytes: bytes, output: io.BytesIO) -> None: - """Re-save a PNG with EXIF stored in an eXIf chunk, preserving the ICC profile. - - piexif has no PNG support, so Pillow writes the eXIf chunk. The embedded ICC - profile is read back from the source bytes and re-attached so it survives the - round-trip. - """ +def _rewrite_png_with_metadata( + image_bytes: bytes, + exif_bytes: bytes, + output: io.BytesIO, + xmp_bytes: Optional[bytes] = None, +) -> None: with Image.open(io.BytesIO(image_bytes)) as im: im.load() icc = im.info.get("icc_profile") - save_kwargs = {"format": "PNG", "compress_level": 6, "exif": exif_bytes} + pnginfo = PngImagePlugin.PngInfo() + if xmp_bytes: + pnginfo.add_itxt("XML:com.adobe.xmp", xmp_bytes.decode("utf-8"), zip=False) + save_kwargs: dict = {"format": "PNG", "compress_level": 6, "exif": exif_bytes, "pnginfo": pnginfo} if icc: save_kwargs["icc_profile"] = icc im.save(output, **save_kwargs) -def _rewrite_tiff_with_metadata(image_bytes: bytes, exif_bytes: bytes, output: io.BytesIO) -> None: - """Re-encode a TIFF with EXIF metadata via tifffile. - - PIL's ``img.save(format="TIFF", exif=...)`` path is doubly unusable here: - it writes the EXIF sub-IFD pointer as a dict-coerced LONG8 which libtiff - rejects with ``_TIFFVSetField: Bad LONG8 ... EXIFIFDOffset``, and it has - no 16-bit RGB mode so it would silently downconvert the image to 8-bit. - Round-tripping through tifffile preserves the pixel data; EXIF tags are - folded into the main IFD via ``extratags``. - """ +def _rewrite_tiff_with_metadata( + image_bytes: bytes, + exif_bytes: bytes, + output: io.BytesIO, + xmp_bytes: Optional[bytes] = None, +) -> None: with tifffile.TiffFile(io.BytesIO(image_bytes)) as tf: page = tf.pages[0] arr = page.asarray() @@ -395,6 +438,9 @@ def _rewrite_tiff_with_metadata(image_bytes: bytes, exif_bytes: bytes, output: i description, extratags = _exif_bytes_to_extratags(exif_bytes) description = _fold_user_comment_into_description(description, extratags) + if xmp_bytes: + extratags.append((_TIFF_XMP_TAG, 7, len(xmp_bytes), xmp_bytes, True)) + tifffile.imwrite( output, arr, @@ -408,21 +454,12 @@ def _rewrite_tiff_with_metadata(image_bytes: bytes, exif_bytes: bytes, output: i def _fold_user_comment_into_description(description: str | None, extratags: list[tuple]) -> str | None: - """Mirror UserComment into ImageDescription for TIFF output. - - UserComment lives in the EXIF sub-IFD on JPEG, but tifffile can only emit - it as a main-IFD tag — and most TIFF readers (macOS Preview, Lightroom) - don't expose UNDEFINED main-IFD tags. Folding the text into description - keeps every custom field (film, format, developer, push/pull) visible. - """ uc_text: str | None = None for entry in extratags: tag, _ttype, _count, value, _ = entry if tag != piexif.ExifIFD.UserComment or not isinstance(value, (bytes, bytearray)): continue raw = bytes(value) - # EXIF spec: 8-byte character-code prefix + payload. We only decode - # ASCII; UNICODE (UTF-16-LE) and JIS would garble under ASCII decode. if raw[:8] == b"ASCII\x00\x00\x00": uc_text = raw[8:].decode("ascii", "replace").rstrip("\x00").strip() break diff --git a/negpy/features/metadata/xmp.py b/negpy/features/metadata/xmp.py new file mode 100644 index 00000000..3303e5e0 --- /dev/null +++ b/negpy/features/metadata/xmp.py @@ -0,0 +1,112 @@ +"""Build Adobe XMP packets from resolved metadata payloads.""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET + +from negpy.features.metadata.payload import MetadataPayload + +_XMP_BEGIN = '' +_XMP_END = '' + +_NS = { + "x": "adobe:ns:meta/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "dc": "http://purl.org/dc/elements/1.1/", + "negpy": "https://negpy.app/ns/1.0/", +} + +for prefix, uri in _NS.items(): + ET.register_namespace(prefix, uri) + + +def _to_rational(value: float) -> str: + num = round(value * 1000.0) + return f"{num}/1000" + + +def _sub(parent: ET.Element, prefix: str, tag: str, text: str) -> None: + ET.SubElement(parent, f"{{{_NS[prefix]}}}{tag}").text = text + + +def build_xmp_xml(payload: MetadataPayload, *, standalone: bool = True) -> str: + """Build a standards-compliant XMP packet string.""" + root = ET.Element(f"{{{_NS['x']}}}xmpmeta") + rdf = ET.SubElement(root, f"{{{_NS['rdf']}}}RDF") + desc = ET.SubElement(rdf, f"{{{_NS['rdf']}}}Description") + desc.set(f"{{{_NS['rdf']}}}about", "") + + for prefix in ("dc", "negpy"): + desc.set(f"xmlns:{prefix}", _NS[prefix]) + + # negpy namespace — original film capture (structured mirror; standard EXIF when flagged) + if payload.camera_make: + _sub(desc, "negpy", "CaptureCameraMake", payload.camera_make) + if payload.camera_model: + _sub(desc, "negpy", "CaptureCameraModel", payload.camera_model) + if payload.lens_make: + _sub(desc, "negpy", "CaptureLensMake", payload.lens_make) + if payload.lens_model: + _sub(desc, "negpy", "CaptureLensModel", payload.lens_model) + if payload.focal_length_mm is not None: + _sub(desc, "negpy", "CaptureFocalLength", _to_rational(payload.focal_length_mm)) + if payload.max_aperture is not None: + _sub(desc, "negpy", "CaptureMaxAperture", _to_rational(payload.max_aperture)) + if payload.capture_exposure: + _sub(desc, "negpy", "CaptureExposure", payload.capture_exposure) + if payload.iso is not None: + _sub(desc, "negpy", "CaptureFilmISO", str(payload.iso)) + if payload.film_stock: + _sub(desc, "negpy", "CaptureFilmStock", payload.film_stock) + if payload.film_manufacturer: + _sub(desc, "negpy", "CaptureFilmManufacturer", payload.film_manufacturer) + if payload.film_format: + _sub(desc, "negpy", "CaptureFilmFormat", payload.film_format) + if payload.film_color_type: + _sub(desc, "negpy", "CaptureFilmColorType", payload.film_color_type) + if payload.developer: + _sub(desc, "negpy", "Developer", payload.developer) + if payload.push_pull and payload.push_pull != "Normal": + _sub(desc, "negpy", "PushPull", payload.push_pull) + if payload.notes: + _sub(desc, "negpy", "Notes", payload.notes) + if payload.scan_method: + _sub(desc, "negpy", "ScanMethod", payload.scan_method) + + # Digitization rig — always from source snapshot + if payload.scan_camera_make: + _sub(desc, "negpy", "ScanCameraMake", payload.scan_camera_make) + if payload.scan_camera_model: + _sub(desc, "negpy", "ScanCameraModel", payload.scan_camera_model) + if payload.scan_lens_make: + _sub(desc, "negpy", "ScanLensMake", payload.scan_lens_make) + if payload.scan_lens_model: + _sub(desc, "negpy", "ScanLensModel", payload.scan_lens_model) + if payload.scan_focal_length_mm is not None: + _sub(desc, "negpy", "ScanFocalLength", _to_rational(payload.scan_focal_length_mm)) + if payload.scan_aperture is not None: + _sub(desc, "negpy", "ScanAperture", _to_rational(payload.scan_aperture)) + if payload.scan_exposure: + _sub(desc, "negpy", "ScanExposure", payload.scan_exposure) + if payload.scan_iso is not None: + _sub(desc, "negpy", "ScanISO", str(payload.scan_iso)) + + keywords: list[str] = [] + for val in (payload.film_stock, payload.film_manufacturer, payload.film_format, payload.film_color_type): + if val and val not in keywords: + keywords.append(val) + if keywords: + subject = ET.SubElement(desc, f"{{{_NS['dc']}}}subject") + bag = ET.SubElement(subject, f"{{{_NS['rdf']}}}Bag") + for kw in keywords: + li = ET.SubElement(bag, f"{{{_NS['rdf']}}}li") + li.text = kw + + body = ET.tostring(root, encoding="unicode", xml_declaration=False) + if standalone: + return f"{_XMP_BEGIN}\n{body}\n{_XMP_END}" + return body + + +def build_xmp_bytes(payload: MetadataPayload, *, standalone: bool = True) -> bytes: + return build_xmp_xml(payload, standalone=standalone).encode("utf-8") diff --git a/negpy/kernel/system/config.py b/negpy/kernel/system/config.py index 25311a9c..9c601c45 100644 --- a/negpy/kernel/system/config.py +++ b/negpy/kernel/system/config.py @@ -29,6 +29,7 @@ cache_dir=os.path.join(BASE_USER_DIR, "cache"), user_icc_dir=os.path.join(BASE_USER_DIR, "icc"), crosstalk_dir=os.path.join(BASE_USER_DIR, "crosstalk"), + gear_dir=os.path.join(BASE_USER_DIR, "gear"), contact_sheet_templates_dir=os.path.join(BASE_USER_DIR, "contact_sheets"), default_export_dir=os.path.join(BASE_USER_DIR, "export"), adobe_rgb_profile=get_resource_path("icc/AdobeCompat-v4.icc"), diff --git a/negpy/services/assets/gear.py b/negpy/services/assets/gear.py new file mode 100644 index 00000000..2a8865db --- /dev/null +++ b/negpy/services/assets/gear.py @@ -0,0 +1,112 @@ +"""JSON-backed gear library (cameras, lenses, film stocks, gear presets).""" + +from __future__ import annotations + +import json +import os +import shutil +from typing import TypeVar + +from negpy.features.metadata.gear_models import ( + Camera, + FilmStock, + GearLibrary, + GearPreset, + Lens, +) +from negpy.kernel.system.config import APP_CONFIG +from negpy.kernel.system.paths import get_resource_path + +T = TypeVar("T") + +_CAMERAS_FILE = "cameras.json" +_LENSES_FILE = "lenses.json" +_FILM_STOCKS_FILE = "film_stocks.json" +_GEAR_PRESETS_FILE = "gear_presets.json" + + +class GearProfiles: + """ + JSON I/O for analog gear libraries under APP_CONFIG.gear_dir. + Disk I/O on dropdown/dialog open and on save — never per render. + """ + + @staticmethod + def _gear_dir() -> str: + path = APP_CONFIG.gear_dir + os.makedirs(path, exist_ok=True) + return path + + @staticmethod + def _read_list(path: str, factory: type[T]) -> list[T]: + if not os.path.isfile(path): + return [] + try: + with open(path, encoding="utf-8") as f: + raw = json.load(f) + if not isinstance(raw, list): + return [] + return [factory.from_dict(item) for item in raw if isinstance(item, dict)] # type: ignore[attr-defined] + except (OSError, json.JSONDecodeError, TypeError, ValueError): + return [] + + @staticmethod + def _write_list(path: str, items: list) -> None: + tmp = path + ".tmp" + data = [item.to_dict() for item in items] + with open(tmp, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + os.replace(tmp, path) + + @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), + ) + + @staticmethod + def save_cameras(cameras: list[Camera]) -> None: + GearProfiles._write_list(os.path.join(GearProfiles._gear_dir(), _CAMERAS_FILE), cameras) + + @staticmethod + def save_lenses(lenses: list[Lens]) -> None: + GearProfiles._write_list(os.path.join(GearProfiles._gear_dir(), _LENSES_FILE), lenses) + + @staticmethod + def save_film_stocks(film_stocks: list[FilmStock]) -> None: + GearProfiles._write_list(os.path.join(GearProfiles._gear_dir(), _FILM_STOCKS_FILE), film_stocks) + + @staticmethod + def save_gear_presets(presets: list[GearPreset]) -> None: + GearProfiles._write_list(os.path.join(GearProfiles._gear_dir(), _GEAR_PRESETS_FILE), presets) + + @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) + + @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 diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py new file mode 100644 index 00000000..4f5fd780 --- /dev/null +++ b/tests/metadata/test_gear_payload.py @@ -0,0 +1,442 @@ +"""Tests for gear library and metadata payload resolution.""" + + + +import os + + + +import piexif + +import pytest + + + +from negpy.features.metadata.gear_models import Camera, FilmStock, GearLibrary, GearPreset, Lens + +from negpy.features.metadata.gear_logic import metadata_from_gear + +from negpy.features.metadata.models import MetadataConfig + +from negpy.features.metadata.payload import build_metadata_payload, build_image_description, has_capture_gear + +from negpy.features.metadata.xmp import build_xmp_xml + +from negpy.features.metadata.writer import embed_metadata + +from negpy.services.assets.gear import GearProfiles + + + + + +@pytest.fixture + +def gear_dir(tmp_path, monkeypatch): + + monkeypatch.setattr("negpy.services.assets.gear.APP_CONFIG.gear_dir", str(tmp_path)) + + return tmp_path + + + + + +def test_seed_example_copies_bundled_files(gear_dir): + + GearProfiles.seed_example() + + assert os.path.isfile(os.path.join(gear_dir, "cameras.json")) + + + + + +def test_load_and_save_library(gear_dir): + + library = GearLibrary( + + cameras=[Camera(id="c1", make="Canon", model="AE-1")], + + lenses=[Lens(id="l1", lens_model="50mm", make="Canon")], + + film_stocks=[FilmStock(id="f1", manufacturer="Kodak", stock_name="Portra 400", iso=400)], + + gear_presets=[GearPreset(id="p1", display_name="Test", camera_id="c1", lens_id="l1", film_stock_id="f1")], + + ) + + GearProfiles.save_library(library) + + loaded = GearProfiles.load_library() + + assert len(loaded.cameras) == 1 + + assert loaded.cameras[0].make == "Canon" + + assert loaded.gear_presets[0].display_name == "Test" + + + + + +def test_metadata_from_gear_preset(): + + library = GearLibrary( + + cameras=[Camera(id="c1", make="Canon", model="AE-1 Program")], + + lenses=[Lens(id="l1", lens_model="FD 50mm f/1.4", make="Canon", focal_length_mm=50, max_aperture=1.4)], + + film_stocks=[FilmStock(id="f1", manufacturer="Kodak", stock_name="Portra 400", iso=400)], + + gear_presets=[GearPreset(id="p1", display_name="Combo", camera_id="c1", lens_id="l1", film_stock_id="f1")], + + ) + + config = metadata_from_gear(MetadataConfig(), library, gear_preset_id="p1") + + assert config.camera_make == "Canon" + + assert config.camera_model == "AE-1 Program" + + assert config.film == "Kodak Portra 400" + + assert config.film_iso == 400 + + + + + +def test_build_image_description(): + + from negpy.features.metadata.payload import MetadataPayload + + + + payload = MetadataPayload( + + camera_make="Canon", + + camera_model="AE-1", + + lens_model="50mm f/1.4", + + film_stock="Portra 400", + + iso=400, + + ) + + assert build_image_description(payload) == "Canon AE-1 • 50mm f/1.4 • Portra 400 • ISO 400" + + + + + +def test_build_metadata_payload_preview_pairs(): + + library = GearLibrary( + + cameras=[Camera(id="c1", make="Canon", model="AE-1")], + + lenses=[], + + film_stocks=[], + + gear_presets=[], + + ) + + config = MetadataConfig(camera_id="c1", developer="D-76 1+1") + + payload = build_metadata_payload(config, library) + + pairs = dict(payload.to_preview_pairs()) + + assert pairs["Camera make"] == "Canon" + + assert pairs["Developer"] == "D-76 1+1" + + assert payload.exif_flags.camera is True + + + + + +def test_developer_only_does_not_trigger_capture_exif(): + + assert has_capture_gear(MetadataConfig(developer="D-76")) is False + + + + + +def test_xmp_contains_negpy_capture_namespace(): + + from negpy.features.metadata.payload import MetadataPayload + + + + payload = MetadataPayload( + + film_stock="Portra 400", + + film_manufacturer="Kodak", + + film_format="35mm", + + developer="D-76", + + ) + + xml = build_xmp_xml(payload) + + assert "negpy:CaptureFilmStock" in xml + + assert "negpy:CaptureFilmManufacturer" in xml + + assert "negpy:Developer" in xml + + assert "tiff:Make" not in xml + + + + + +def test_scan_rig_preserved_in_xmp_while_exif_shows_analog(): + + library = GearLibrary( + + cameras=[Camera(id="c1", make="Nikon", model="FM2")], + + lenses=[Lens(id="l1", lens_model="Nikkor 28mm f/2.8 AIS", make="Nikkor", focal_length_mm=28, max_aperture=2.8)], + + film_stocks=[], + + gear_presets=[], + + ) + + source_exif = { + + "0th": { + + piexif.ImageIFD.Make: b"NIKON CORPORATION", + + piexif.ImageIFD.Model: b"NIKON D750", + + }, + + "Exif": { + + piexif.ExifIFD.LensMake: b"NIKON", + + piexif.ExifIFD.LensModel: b"AF-S 60mm f/2.8G", + + piexif.ExifIFD.FocalLength: (600, 10), + + piexif.ExifIFD.FocalLengthIn35mmFilm: 60, + + piexif.ExifIFD.ExposureTime: (1, 640), + + piexif.ExifIFD.FNumber: (56, 10), + + piexif.ExifIFD.ISOSpeedRatings: 100, + + }, + + "GPS": {}, + + "Interop": {}, + + "1st": {}, + + } + + config = MetadataConfig(camera_id="c1", lens_id="l1", scanning="DSLR copy-stand") + + payload = build_metadata_payload(config, library, source_exif) + + + + assert payload.camera_model == "FM2" + + assert payload.scan_camera_make == "NIKON CORPORATION" + + assert payload.exif_flags.camera is True + + assert payload.exif_flags.lens is True + + + + xml = build_xmp_xml(payload) + + assert "negpy:ScanCameraMake" in xml + + assert "negpy:CaptureCameraModel" in xml + + assert "NIKON CORPORATION" in xml + + + + + +def test_embed_jpeg_analog_exif_and_scan_xmp(): + + from PIL import Image + + + + buf = __import__("io").BytesIO() + + Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="JPEG") + + jpeg = buf.getvalue() + + + + source_exif = { + + "0th": { + + piexif.ImageIFD.Make: b"NIKON CORPORATION", + + piexif.ImageIFD.Model: b"NIKON D750", + + }, + + "Exif": { + + piexif.ExifIFD.LensMake: b"NIKON", + + piexif.ExifIFD.LensModel: b"AF-S 60mm f/2.8G", + + piexif.ExifIFD.FocalLength: (600, 10), + + piexif.ExifIFD.FocalLengthIn35mmFilm: 60, + + piexif.ExifIFD.ISOSpeedRatings: 100, + + }, + + "GPS": {}, + + "Interop": {}, + + "1st": {}, + + } + + + + library = GearLibrary( + + cameras=[Camera(id="c1", make="Nikon", model="FM2")], + + lenses=[Lens(id="l1", lens_model="Nikkor 28mm f/2.8 AIS", make="Nikkor", focal_length_mm=28, max_aperture=2.8)], + + film_stocks=[FilmStock(id="f1", manufacturer="Kodak", stock_name="Portra 400", iso=400)], + + gear_presets=[], + + ) + + config = MetadataConfig( + + camera_id="c1", + + lens_id="l1", + + film_stock_id="f1", + + film="Portra 400", + + scanning="DSLR scan", + + ) + + out = embed_metadata(jpeg, config, source_exif, gear=library) + + + + assert b"http://ns.adobe.com/xap/1.0/" in out + + assert b"negpy:ScanCameraMake" in out + + assert b"negpy:CaptureCameraModel" in out + + assert b"NIKON CORPORATION" in out + + loaded = piexif.load(out) + + assert loaded["0th"][piexif.ImageIFD.Make] == b"Nikon" + + assert loaded["0th"][piexif.ImageIFD.Model] == b"FM2" + + assert loaded["Exif"][piexif.ExifIFD.LensModel] == b"Nikkor 28mm f/2.8 AIS" + + assert loaded["Exif"][piexif.ExifIFD.FocalLength] == (28, 1) + + assert piexif.ExifIFD.FocalLengthIn35mmFilm not in loaded["Exif"] + + assert loaded["Exif"][piexif.ExifIFD.ISOSpeedRatings] == 400 + + assert loaded["0th"][piexif.ImageIFD.Software] == b"NegPy" + + + + + +def test_embed_keeps_scan_exif_when_capture_not_set(): + + from PIL import Image + + + + buf = __import__("io").BytesIO() + + Image.new("RGB", (8, 8), (128, 64, 32)).save(buf, format="JPEG") + + jpeg = buf.getvalue() + + + + source_exif = { + + "0th": { + + piexif.ImageIFD.Make: b"Plustek", + + piexif.ImageIFD.Model: b"OpticFilm 8200", + + }, + + "Exif": { + + piexif.ExifIFD.LensModel: b"", + + piexif.ExifIFD.ISOSpeedRatings: 200, + + }, + + "GPS": {}, + + "Interop": {}, + + "1st": {}, + + } + + out = embed_metadata(jpeg, MetadataConfig(developer="HC-110"), source_exif) + + + + loaded = piexif.load(out) + + assert loaded["0th"][piexif.ImageIFD.Make] == b"Plustek" + + assert loaded["0th"][piexif.ImageIFD.Model] == b"OpticFilm 8200" + + assert loaded["Exif"][piexif.ExifIFD.ISOSpeedRatings] == 200 + diff --git a/tests/test_metadata_writer.py b/tests/test_metadata_writer.py index 163c88f2..560a4a4b 100644 --- a/tests/test_metadata_writer.py +++ b/tests/test_metadata_writer.py @@ -111,7 +111,7 @@ def test_embed_trims_oversized_nonstandard_tag_keeps_custom_fields() -> None: "Interop": {}, "1st": {}, } - config = MetadataConfig(camera_override="MyCam") + config = MetadataConfig(camera_model="MyCam") out = embed_metadata(_jpeg(), config, source_exif) diff --git a/tests/test_override_config.py b/tests/test_override_config.py index 4a8a1189..65e8005e 100644 --- a/tests/test_override_config.py +++ b/tests/test_override_config.py @@ -29,6 +29,7 @@ def _make_app_config(**kwargs) -> AppConfig: cache_dir="/tmp/cache", user_icc_dir="/tmp/icc", crosstalk_dir="/tmp/crosstalk", + gear_dir="/tmp/gear", contact_sheet_templates_dir="/tmp/contact_sheets", default_export_dir="/tmp/export", adobe_rgb_profile="/tmp/adobe.icc",