From 9fa42151fa8d244f0064ae387e317180cbd806ab Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 1 Aug 2026 11:17:25 +0200 Subject: [PATCH 1/3] Add metadata and capture roll/frame to export filename templates, with safe frame padding and pre-export settings flush --- docs/TEMPLATING.md | 24 +- docs/USER_GUIDE.md | 5 +- negpy/desktop/controller.py | 41 ++- negpy/desktop/view/sidebar/export.py | 9 + negpy/desktop/view/sidebar/metadata.py | 37 +++ .../view/widgets/export_settings_form.py | 6 +- negpy/desktop/workers/export.py | 1 + negpy/features/metadata/models.py | 4 + negpy/services/export/templating.py | 119 ++++++- scripts/ir_filament_evidence.py | 301 ++++++++++++++++++ tests/test_capture_controller.py | 32 ++ tests/test_export_flush.py | 63 ++++ tests/test_templating.py | 128 +++++++- 13 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 scripts/ir_filament_evidence.py create mode 100644 tests/test_export_flush.py diff --git a/docs/TEMPLATING.md b/docs/TEMPLATING.md index 961d3e43..159ed845 100644 --- a/docs/TEMPLATING.md +++ b/docs/TEMPLATING.md @@ -12,12 +12,32 @@ NegPy uses **Jinja2** for dynamic file naming in both the **Export** and **Scan* | :--- | :--- | :--- | | `{{ original_name }}` | Base filename of the source file (without extension). | `DSC0123` | | `{{ colorspace }}` | Target export color space. | `sRGB`, `Adobe RGB` | -| `{{ format }}` | Export file format. | `JPEG`, `TIFF` | +| `{{ format }}` | Export file format (JPEG, TIFF, …). | `JPEG`, `TIFF` | | `{{ paper_ratio }}` | Selected aspect ratio. | `3:2`, `Original` | | `{{ size }}` | Print size in cm (Empty if "Original Resolution" is used). | `30cm` | | `{{ dpi }}` | Export DPI (Empty if "Original Resolution" is used). | `300dpi` | +| `{{ target_px }}` | Target long-edge size in pixels (Empty unless Pixels mode). | `2048px` | | `{{ border }}` | Inserts "border" if width > 0, else empty. | `border` | | `{{ date }}` | Current date in YYYYMMDD format. | `20260125` | +| `{{ roll }}` | Scanlight capture roll name (Metadata → Roll), or parsed from a `{roll}_Frame{NNN}` stem. Not the Roll Analysis normalization name. | `Roll001` | +| `{{ frame }}` | Capture frame number (integer), or parsed from the stem; unset (`none`) if unknown. Prefer `{{ frame\|pad(3) }}` or `{{ frame_padded }}` for zero-padding — `"%03d" % frame` only works when frame is set (otherwise the whole pattern falls back to `original_name`). | `12` | +| `{{ frame_padded }}` | Zero-padded frame (`012`), or empty if unknown. Same as `{{ frame\|pad(3) }}`. | `012` | +| `{{ camera }}` | Camera make + model. | `Mamiya 7` | +| `{{ camera_make }}` / `{{ camera_model }}` | Camera make / model separately. | `Mamiya`, `7` | +| `{{ lens }}` | Lens model (or make if model is empty). | `80mm f/4` | +| `{{ lens_make }}` / `{{ lens_model }}` | Lens make / model separately. | | +| `{{ focal_length }}` | Lens focal length in mm. | `80` | +| `{{ film }}` | Film stock name. | `Portra 400` | +| `{{ film_iso }}` | Film ISO. | `400` | +| `{{ film_manufacturer }}` | Film manufacturer. | `Kodak` | +| `{{ film_color_type }}` | Film color type. | `Color negative` | +| `{{ film_format }}` | Film format (35mm, 120, …). Distinct from export `{{ format }}`. | `35mm` | +| `{{ developer }}` | Developer. | `D-76 1+1` | +| `{{ push_pull }}` | Push/pull as an integer (−3…+3, 0 = Normal). | `1` | +| `{{ scanning }}` | Scanning method note. | `DSLR copy-stand` | +| `{{ exposure }}` | Exposure override text from Metadata. | `1/125s f/2.8` | + +Gear and process values come from the **Metadata** panel (or each file’s saved metadata in a batch). Empty fields render as empty strings so surrounding separators collapse cleanly. Path-unsafe characters in metadata values are stripped. ### Examples @@ -27,6 +47,8 @@ NegPy uses **Jinja2** for dynamic file naming in both the **Export** and **Scan* | `{{ date }}_{{ original_name }}_{{ colorspace }}` | `20260125_DSC0123_Adobe_RGB.jpg` | | `{{ original_name }}_{{ size }}_{{ dpi }}_{{ border }}` | `DSC0123_30cm_300dpi_border.jpg` | | `PRINT_{{ original_name }}_{{ paper_ratio }}` | `PRINT_DSC0123_3:2.jpg` | +| `{{ roll }}_Frame{{ frame\|pad(3) }}_{{ film }}_{{ film_iso }}` | `Roll001_Frame012_Portra_400_400.jpg` | +| `{{ film }}_{{ camera }}_{{ original_name }}` | `Portra_400_Mamiya_7_DSC0123.jpg` | --- diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 41e83d87..66d6ac05 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -455,11 +455,11 @@ The primary **Export** action. Its chevron menu picks the scope: current frame ( * **Input / Output ICC**: soft-proof against, and optionally embed, an ICC profile. Output is the destination profile (default); Input treats the profile as the source (when a scan's profile is known but untagged). * **Paper Aspect Ratio**: final print ratio, or *Original* (no resize). * **Resolution**: *Original* (full RAW resolution), *Print* (long-edge **Size** in cm + **DPI**), or *Pixels* (long-edge **px**; short side follows the paper ratio). -* **Destination**: **Filename Pattern** (a Jinja2 template, see [TEMPLATING.md](TEMPLATING.md)), **Overwrite** toggle, and output location (subfolder of source / same as source / an absolute **Export Path** with a browse button). +* **Destination**: **Filename Pattern** (a Jinja2 template with export settings plus Metadata fields such as roll, camera, film — see [TEMPLATING.md](TEMPLATING.md)), **Overwrite** toggle, and output location (subfolder of source / same as source / an absolute **Export Path** with a browse button). ### Collapsible sections -* **Presets**: a checklist of export presets (each a saved Format/Size/Colour recipe). **Manage** edits them; **Export Presets** renders the frame(s) with every enabled preset at once. +* **Presets**: a checklist of export presets (each a saved Format/Size/Colour/**Destination**/filename recipe). **Manage** edits them; **Export Presets** renders the frame(s) with every enabled preset at once — each preset uses **its own** destination, not the sidebar Destination above. * **Sidecars**: **Save on export** writes a `.negpy` edit sidecar next to each source on every export; **Export sidecars** writes them for all visible frames now. (Edits always stay in the database too; sidecars are optional archival copies.) * **Contact Sheet**: render all visible frames into a single sheet. Choose a **Template** or set **Cell / Gap / Margin / Max tiles** by hand, pick an output **Path**, and **Export contact sheet**. * **Preview** (affects the on-screen preview only, never the file): @@ -489,6 +489,7 @@ Archival metadata for the **original analog capture** (camera, lens, film, proce **Scanning:** * **Scanning**: scan method/notes (EXIF `Software` is always `NegPy`). +* **Roll / Frame**: Scanlight capture roll name and frame number. Stamped automatically on capture; editable here. Available in export filename templates as `{{ roll }}` / `{{ frame }}` (not the Roll Analysis normalization name). * **Sync custom metadata to all files in batch export**: apply this tab's values to every file in a batch. **Exposure**: optional original shutter/aperture/ISO. Click the lock to edit a free-text string (e.g. `1/125s f/2.8 ISO 400`). diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index e7140e67..639127dd 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -1,7 +1,7 @@ import os import time from dataclasses import dataclass, fields, replace -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import numpy as np from PyQt6.QtCore import Q_ARG, QMetaObject, QObject, Qt, QThread, QTimer, pyqtSignal @@ -101,6 +101,8 @@ class _PendingCaptureImport: process_mode: Optional[ProcessMode] = None detect_mode: bool = False + capture_roll: str = "" + capture_frame: Optional[int] = None def _capture_import_key(path: str) -> str: @@ -289,6 +291,7 @@ def __init__(self, session_manager: DesktopSessionManager): self._autocrop_dispatched = 0 self._autocrop_preflight_skipped = 0 self._autocrop_cancel_requested = False + self.flush_export_settings: Optional[Callable[[], None]] = None self.preview_service = PreviewManager() self.batch_autocrop_preview_service = PreviewManager() @@ -1061,6 +1064,17 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b ) self.state.config = replace(self.state.config, process=process) self.state.is_dirty = True + if pending_import is not None and (pending_import.capture_roll or pending_import.capture_frame is not None): + meta = self.state.config.metadata + self.state.config = replace( + self.state.config, + metadata=replace( + meta, + capture_roll=pending_import.capture_roll or meta.capture_roll, + capture_frame=(pending_import.capture_frame if pending_import.capture_frame is not None else meta.capture_frame), + ), + ) + self.state.is_dirty = True rgbscan = self.state.config.rgbscan stitch = self.state.config.stitch @@ -2542,18 +2556,31 @@ def _on_capture_finished(self, paths: list) -> None: # RGB-Scan (triplet merge) is on only for an actual RGB triplet — off for a single # white-light slide OR a normal (non-Scanlight) camera scan. self.session.repo.save_global_setting("rgbscan_mode", rgb and not white) + capture_roll = getattr(req, "roll_name", "") if req is not None else "" + capture_frame = getattr(req, "frame_number", None) if req is not None else None if white: # slides/B&W force a positive process mode = (req.white_process_mode or "auto").lower() target = {"e-6": ProcessMode.E6, "b&w": ProcessMode.BW}.get(mode) self._pending_capture_imports[_capture_import_key(paths[0])] = _PendingCaptureImport( process_mode=target, detect_mode=target is None, + capture_roll=capture_roll, + capture_frame=capture_frame, ) elif rgb: # Independently exposed RGB channels have no broadband orange-mask signal for # the normal classifier. They are negative scans unless capture metadata says # otherwise, so carry C-41 through discovery instead of guessing from the merge. - self._pending_capture_imports[_capture_import_key(paths[0])] = _PendingCaptureImport(process_mode=ProcessMode.C41) + self._pending_capture_imports[_capture_import_key(paths[0])] = _PendingCaptureImport( + process_mode=ProcessMode.C41, + capture_roll=capture_roll, + capture_frame=capture_frame, + ) + elif req is not None: + self._pending_capture_imports[_capture_import_key(paths[0])] = _PendingCaptureImport( + capture_roll=capture_roll, + capture_frame=capture_frame, + ) self._pending_scanned_file = paths[0] self.request_asset_discovery(list(paths)) @@ -2900,8 +2927,15 @@ def export_history_step(self, index: int) -> None: self.session.jump_to_step(index) self.request_export() + def _flush_export_ui(self) -> None: + """Push pending Export-panel edits into state before any export path reads config.""" + flush = self.flush_export_settings + if flush is not None: + flush() + def request_export(self) -> None: """Exports the current file using the settings currently shown in the Export panel.""" + self._flush_export_ui() if self._batch_busy("export"): return if not self.state.current_file_path: @@ -2948,6 +2982,7 @@ def request_export_selected(self) -> None: def request_batch_export(self, override_settings: bool = False, files: list[dict] | None = None) -> None: """Batch-exports the given files (all visible by default) using current settings, optionally applied to all.""" + self._flush_export_ui() if self._batch_busy("export"): return export_path = self._ensure_valid_export_path() @@ -3089,6 +3124,7 @@ def _confirm_bulk_export(self, text: str) -> bool: return reply == QMessageBox.StandardButton.Yes def _dispatch_preset_export(self, files: list[dict]) -> None: + self._flush_export_ui() if self._batch_busy("export"): return if not files: @@ -3155,6 +3191,7 @@ def _contact_sheet_output_dir(self, visible_files: list) -> Optional[str]: def request_contact_sheet(self) -> None: """Renders all visible files small and writes darkroom contact sheet(s).""" + self._flush_export_ui() if self._batch_busy("contact sheet"): return visible_files = [self.state.uploaded_files[i] for i in self.session.asset_model.visible_actual_indices_ordered()] diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index 30450c61..6a34e64a 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -75,6 +75,8 @@ def _init_ui(self) -> None: self._refresh_export_enabled() def _connect_signals(self) -> None: + self.controller.flush_export_settings = self._flush_export_settings + self.form.changed.connect(self.update_timer.start) self.form.changed.connect(self._refresh_proof_mismatch_warning) self.form.changed.connect(self._refresh_export_enabled) @@ -757,7 +759,13 @@ def _set_export_scope(self, key: str, persist: bool = True) -> None: if persist: self.controller.session.repo.save_global_setting("export_scope", key) + def _flush_export_settings(self) -> None: + """Stop the debounce timer and write the form into state immediately.""" + self.update_timer.stop() + self._persist_all_export_settings() + def _on_export_clicked(self) -> None: + self._flush_export_settings() scope = self._export_scope if scope == "selected": self.controller.request_export_selected() @@ -778,6 +786,7 @@ def _set_preset_scope(self, key: str, persist: bool = True) -> None: self.controller.session.repo.save_global_setting("preset_export_scope", key) def _on_export_presets_clicked(self) -> None: + self._flush_export_settings() scope = self._preset_scope if scope == "selected": self.controller.request_preset_export_selected() diff --git a/negpy/desktop/view/sidebar/metadata.py b/negpy/desktop/view/sidebar/metadata.py index e9f8c562..1a5cae9a 100644 --- a/negpy/desktop/view/sidebar/metadata.py +++ b/negpy/desktop/view/sidebar/metadata.py @@ -137,6 +137,29 @@ def _init_ui(self) -> None: self.scanning_edit.setText(conf.scanning) scan.addWidget(self.scanning_edit) + roll_row = QHBoxLayout() + roll_row.setSpacing(THEME.space_sm) + roll_col = QVBoxLayout() + roll_col.setSpacing(THEME.space_md) + roll_col.addWidget(field_label("Roll")) + self.capture_roll_edit = QLineEdit() + self.capture_roll_edit.setPlaceholderText("e.g. Roll001") + self.capture_roll_edit.setText(conf.capture_roll) + self.capture_roll_edit.setToolTip("Scan capture roll name (Scanlight). Used in export filename templates as {{ roll }}.") + roll_col.addWidget(self.capture_roll_edit) + frame_col = QVBoxLayout() + frame_col.setSpacing(THEME.space_md) + frame_col.addWidget(field_label("Frame")) + self.capture_frame_edit = QLineEdit() + self.capture_frame_edit.setPlaceholderText("e.g. 12") + if conf.capture_frame is not None: + self.capture_frame_edit.setText(str(conf.capture_frame)) + self.capture_frame_edit.setToolTip("Scan capture frame number. Used in export filename templates as {{ frame }}.") + frame_col.addWidget(self.capture_frame_edit) + roll_row.addLayout(roll_col, 2) + roll_row.addLayout(frame_col, 1) + scan.addLayout(roll_row) + self.sync_check = QCheckBox("Sync custom metadata to all files in batch export") self.sync_check.setChecked(conf.sync_to_batch) scan.addWidget(self.sync_check) @@ -255,6 +278,8 @@ def _connect_signals(self) -> None: 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.capture_roll_edit.textChanged.connect(self._mark_dirty) + self.capture_frame_edit.textChanged.connect(self._mark_dirty) self.sync_check.toggled.connect(self._mark_dirty) self.exposure_edit.textChanged.connect(self._mark_dirty) @@ -415,6 +440,14 @@ def _persist_all_metadata_settings(self) -> None: if not self._exif_locked.get("exposure", True): exposure_override = self.exposure_edit.text().strip() + frame_text = self.capture_frame_edit.text().strip() + capture_frame = None + if frame_text: + try: + capture_frame = int(frame_text) + except ValueError: + capture_frame = self.state.config.metadata.capture_frame + self.update_config_section( "metadata", persist=True, @@ -429,6 +462,8 @@ def _persist_all_metadata_settings(self) -> None: 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(), + capture_roll=self.capture_roll_edit.text().strip(), + capture_frame=capture_frame, sync_to_batch=self.sync_check.isChecked(), exposure_override=exposure_override, ) @@ -455,6 +490,8 @@ def sync_ui(self) -> None: 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.scanning_edit.setText(conf.scanning) + self.capture_roll_edit.setText(conf.capture_roll) + self.capture_frame_edit.setText("" if conf.capture_frame is None else str(conf.capture_frame)) self.sync_check.setChecked(conf.sync_to_batch) if conf.exposure_override: diff --git a/negpy/desktop/view/widgets/export_settings_form.py b/negpy/desktop/view/widgets/export_settings_form.py index 25d0dc50..3e8bfa3e 100644 --- a/negpy/desktop/view/widgets/export_settings_form.py +++ b/negpy/desktop/view/widgets/export_settings_form.py @@ -326,7 +326,11 @@ def _build_destination(self, root: QVBoxLayout) -> None: "Jinja2 template. Variables:\n" "{{ original_name }}, {{ colorspace }}, {{ format }},\n" "{{ paper_ratio }}, {{ size }}, {{ dpi }}, {{ target_px }},\n" - "{{ border }}, {{ date }}" + "{{ border }}, {{ date }},\n" + "{{ roll }}, {{ frame }}, {{ frame|pad(3) }}, {{ frame_padded }},\n" + "{{ camera }}, {{ lens }}, {{ film }}, {{ film_iso }}, {{ film_format }},\n" + "{{ developer }}, {{ push_pull }}, {{ scanning }}, {{ exposure }}\n" + "(see docs/TEMPLATING.md for the full list)" ) self.filename_edit.textChanged.connect(self._on_changed) filename_row.addWidget(self.filename_edit) diff --git a/negpy/desktop/workers/export.py b/negpy/desktop/workers/export.py index 8e95c500..886723b7 100644 --- a/negpy/desktop/workers/export.py +++ b/negpy/desktop/workers/export.py @@ -80,6 +80,7 @@ def resolve_export_naming(task: ExportTask) -> tuple[str, str, str]: task.export_settings, border_size=task.params.finish.border_size, half=int(task.file_info.get("half") or 0), + metadata=task.metadata_config, ) return out_dir, filename, ext diff --git a/negpy/features/metadata/models.py b/negpy/features/metadata/models.py index a63f30e2..28cfb23e 100644 --- a/negpy/features/metadata/models.py +++ b/negpy/features/metadata/models.py @@ -45,6 +45,10 @@ class MetadataConfig: scanning: str = "" sync_to_batch: bool = False + # Scanlight capture identity (not process.roll_name / Roll Analysis) + capture_roll: str = "" + capture_frame: Optional[int] = None + # When True, export copies source EXIF/XMP unchanged — NegPy writes no metadata. protect_original_metadata: bool = False diff --git a/negpy/services/export/templating.py b/negpy/services/export/templating.py index a985396d..5d1bb88a 100644 --- a/negpy/services/export/templating.py +++ b/negpy/services/export/templating.py @@ -1,9 +1,107 @@ import os import re from datetime import datetime -from typing import Union +from typing import Optional, Union + from jinja2.sandbox import SandboxedEnvironment + from negpy.domain.models import ExportConfig, ExportPreset, ExportResolutionMode +from negpy.features.metadata.models import MetadataConfig +from negpy.kernel.system.logging import get_logger + +logger = get_logger("export.templating") + +_CAPTURE_STEM_RE = re.compile(r"^(?P.+)_Frame(?P\d+)", re.IGNORECASE) +_PATH_UNSAFE_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def parse_capture_stem(stem: str) -> tuple[str, Optional[int]]: + """Parse Scanlight `{roll}_Frame{NNN}` from a basename stem. Returns ("", None) if no match.""" + m = _CAPTURE_STEM_RE.match(stem) + if not m: + return "", None + return m.group("roll"), int(m.group("frame")) + + +def _path_safe(value: object) -> str: + """Make a template value safe for use in a filename (empty if unset).""" + if value is None: + return "" + if isinstance(value, bool): + return "" + if isinstance(value, (int, float)): + if isinstance(value, float) and value == int(value): + text = str(int(value)) + else: + text = str(value) + else: + text = str(value).strip() + if not text: + return "" + return _PATH_UNSAFE_RE.sub("", text).strip(" .") + + +def _film_format(metadata: MetadataConfig) -> str: + if metadata.format == "Other": + return metadata.format_other.strip() + return metadata.format.strip() + + +def pad_filter(value: object, width: int = 3) -> str: + """Zero-pad an int for filenames; empty when unset (safe for missing frame).""" + if value is None or value == "": + return "" + try: + return f"{int(value):0{int(width)}d}" + except (TypeError, ValueError): + return "" + + +def _frame_padded(frame: Optional[int]) -> str: + return pad_filter(frame, 3) + + +def _metadata_context(original_stem: str, metadata: Optional[MetadataConfig]) -> dict: + meta = metadata or MetadataConfig() + parsed_roll, parsed_frame = parse_capture_stem(original_stem) + + roll = meta.capture_roll.strip() or parsed_roll or "" + frame: Optional[int] = None + if meta.capture_frame is not None: + frame = meta.capture_frame + elif parsed_frame is not None: + frame = parsed_frame + + camera = f"{meta.camera_make} {meta.camera_model}".strip() + lens = meta.lens_model.strip() or meta.lens_make.strip() + + return { + "roll": _path_safe(roll), + "frame": frame, + "frame_padded": _frame_padded(frame), + "camera": _path_safe(camera), + "camera_make": _path_safe(meta.camera_make), + "camera_model": _path_safe(meta.camera_model), + "lens": _path_safe(lens), + "lens_make": _path_safe(meta.lens_make), + "lens_model": _path_safe(meta.lens_model), + "focal_length": _path_safe(meta.focal_length_mm), + "film": _path_safe(meta.film), + "film_iso": _path_safe(meta.film_iso), + "film_manufacturer": _path_safe(meta.film_manufacturer), + "film_color_type": _path_safe(meta.film_color_type), + "film_format": _path_safe(_film_format(meta)), + "developer": _path_safe(meta.developer), + "push_pull": meta.push_pull, + "scanning": _path_safe(meta.scanning), + "exposure": _path_safe(meta.exposure_override), + } + + +def _jinja_env() -> SandboxedEnvironment: + env = SandboxedEnvironment() + env.filters["pad"] = pad_filter + return env def render_export_filename( @@ -11,19 +109,24 @@ def render_export_filename( export_settings: Union[ExportConfig, ExportPreset], border_size: float = 0.0, half: int = 0, + metadata: Optional[MetadataConfig] = None, ) -> str: """ Renders the export filename using Jinja2 templates. Supported variables: - original_name: Original filename without extension - colorspace: Target color space - - format: JPEG/TIFF + - format: JPEG/TIFF (export file format) - paper_ratio: e.g. 3:2 - size: Export size in cm (PRINT mode only, else empty) - dpi: Export DPI (PRINT mode only, else empty) - target_px: Target long edge in pixels (TARGET_PX mode only, else empty) - border: "border" if border size > 0, else empty - date: Current date in YYYYMMDD format + - roll / frame / frame_padded: Scanlight capture roll and frame (metadata or stem parse) + - camera / camera_make / camera_model, lens / lens_make / lens_model / focal_length + - film / film_iso / film_manufacturer / film_color_type / film_format + - developer / push_pull / scanning / exposure """ original_name = os.path.splitext(os.path.basename(original_path))[0] if half: @@ -49,8 +152,11 @@ def render_export_filename( "border": "border" if border_size > 0 else "", "date": datetime.now().strftime("%Y%m%d"), } + # Stem for parse fallback is the unsuffixed basename (half suffix is export-only). + stem_for_parse = os.path.splitext(os.path.basename(original_path))[0] + context.update(_metadata_context(stem_for_parse, metadata)) - env = SandboxedEnvironment() + env = _jinja_env() try: template = env.from_string(export_settings.filename_pattern) @@ -67,5 +173,10 @@ def render_export_filename( return original_name return rendered - except Exception: + except Exception as exc: + logger.warning( + "Export filename pattern failed (%r); falling back to original_name: %s", + export_settings.filename_pattern, + exc, + ) return original_name diff --git a/scripts/ir_filament_evidence.py b/scripts/ir_filament_evidence.py new file mode 100644 index 00000000..ef54c9e3 --- /dev/null +++ b/scripts/ir_filament_evidence.py @@ -0,0 +1,301 @@ +"""Measure NegPy's IR dust repair on filament (hair/scratch) defects. + +Dev tool, not shipped. The IR bake is judged by eye on the canvas, which makes +filament regressions invisible until they ship; this reproduces the bake outside +the GUI and puts numbers on it. + +Two modes: + + # synthetic frame with ground truth (the only mode that can score accuracy) + uv run python scripts/ir_filament_evidence.py + + # a real scan carrying an IR channel (no ground truth: coverage + grain only) + uv run python scripts/ir_filament_evidence.py --file /path/to/scan.tif + +Runs the bake twice, mirroring the app: once on a preview-scale buffer (mask 1:1 +with the pixels) and once full-res (detection stays pinned at +``preview_render_size``, so the mask is upsampled onto the buffer). Crops land in +``debug/ir_filament/``. + +Filament and speck are scored separately: they take different routes through +``route_ir_defects``, and only the filament is the problem. + +Metrics +------- +overrepair repaired area / true defect area. 1.0 is exact; a filament blurred + into a wide band scores several times that. +grain high-pass sigma inside the repair against the same measure on + ground truth (on a real scan, against a clean annulus). 1.0 keeps + grain, ~0 is the "plastic worm" a mean-based fill leaves. Reads + *above* 1.0 when the repair leaves residual defect structure, so + read it next to rmse, not alone. +rmse against ground truth over the defect core, and separately over the + part of the core crossing an image edge (where an isotropic fill + bridges instead of continuing the edge). Synthetic only; `src` + is the do-nothing baseline to beat. +""" + +import argparse +import os +import sys + +import cv2 +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from negpy.kernel.system.config import APP_CONFIG # noqa: E402 + +# No GPU work happens in the IR bake, and initializing it costs seconds per run. +APP_CONFIG.use_gpu = False + +from negpy.domain.models import WorkspaceConfig # noqa: E402 +from negpy.features.retouch.logic import downsample_ir, ir_defect_score, ir_detect_cutoff, ir_ratio_and_gain # noqa: E402 +from negpy.features.retouch.models import RetouchConfig # noqa: E402 +from negpy.kernel.image.logic import working_oetf_encode # noqa: E402 +from negpy.services.rendering.image_processor import ImageProcessor, _detection_downsample # noqa: E402 + +OUT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "debug", "ir_filament") + +# Synthetic fixture: physical attenuation of a neutral-density defect. The visible +# planes follow the IR transmittance raised to the refraction gammas the bake fits, +# so the division tier has something real to recover. +_SYNTH_GAMMAS = (1.2, 1.4, 1.55) +_SYNTH_IR_CLEAN = 0.90 +_SYNTH_GRAIN_SIGMA = 0.013 + + +def synthetic_frame(long_edge: int, seed: int = 7): + """Film-like frame with three defects that each take a different route: an opaque + 3 px filament (scores at floor, routes to the inpaint), a semi-transparent one + (partial score, so the fill owns it whatever the routing does), and a round speck + as the compact control. All three cross the same hard tone edges. + + Returns ``(clean, source, ir, regions, edges)`` at full res: ground truth, the + scan the bake sees, the IR plane, per-defect opacity maps in [0, 1], and the + image edges the filaments have to be continued across. + """ + h, w = int(long_edge * 2 / 3), int(long_edge) + rng = np.random.default_rng(seed) + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + + base = 0.28 + 0.30 * (xx / w) + 0.12 * np.sin(2.0 * np.pi * yy / h) + edges = np.zeros((h, w), dtype=np.uint8) + # Hard tone steps at three angles: the filament crosses each one, so a fill that + # averages a disc bridges them and a structure-following one does not. + for (x0, y0), (x1, y1), step in ( + ((int(0.30 * w), 0), (int(0.30 * w), h), 0.22), + ((0, int(0.55 * h)), (w, int(0.40 * h)), -0.18), + ((int(0.55 * w), 0), (w, int(0.85 * h)), 0.15), + ): + side = np.zeros((h, w), dtype=np.uint8) + cv2.line(side, (x0, y0), (x1, y1), 1, 1) + cv2.line(edges, (x0, y0), (x1, y1), 1, 3) + # Fill one side of the line by flooding from a corner it does not touch. + filled = side.copy() + cv2.floodFill(filled, np.zeros((h + 2, w + 2), np.uint8), (0, h - 1), 2) + base = base + step * (filled == 2).astype(np.float32) + + clean = np.repeat(base[..., None], 3, axis=2).astype(np.float32) + clean[:, :, 0] *= 1.04 + clean[:, :, 2] *= 0.93 + # Grain: white noise blurred to a grain size, so a high-pass sigma actually + # measures texture rather than per-pixel sensor noise. + grain = cv2.GaussianBlur(rng.normal(0.0, 1.0, (h, w, 3)).astype(np.float32), (0, 0), 0.8) + clean = np.clip(clean + grain * (_SYNTH_GRAIN_SIGMA / max(float(grain.std()), 1e-6)), 0.01, 1.0) + + def psf(core: np.ndarray) -> np.ndarray: + return np.clip(cv2.GaussianBlur(core, (0, 0), 0.9) * 1.4, 0.0, 1.0) + + def curve(y_off: float, opacity: float) -> np.ndarray: + core = np.zeros((h, w), dtype=np.float32) + t = np.linspace(0.0, 1.0, 900) + px = (0.12 + 0.76 * t) * w + py = (y_off + 0.42 * np.sin(2.4 * np.pi * t) * t) * h + cv2.polylines(core, [np.stack([px, py], -1).astype(np.int32)], False, 1.0, 3, cv2.LINE_AA) + return psf(core) * opacity + + spk = np.zeros((h, w), dtype=np.float32) + cv2.circle(spk, (int(0.20 * w), int(0.80 * h)), max(3, long_edge // 300), 1.0, -1) + regions = {"opaque-fil": curve(0.30, 1.0), "faint-fil": curve(0.62, 0.45), "speck": psf(spk)} + alpha = np.maximum.reduce(list(regions.values())) + + t_ir = 1.0 - 0.85 * alpha + ir = (_SYNTH_IR_CLEAN * t_ir).astype(np.float32) + source = clean.copy() + for c in range(3): + source[:, :, c] *= t_ir**_SYNTH_GAMMAS[c] + return clean, source.astype(np.float32), ir, regions, edges + + +def _detected_alpha(img: np.ndarray, ir: np.ndarray, threshold: float) -> np.ndarray: + """Stand-in for ground-truth opacity on a real scan: the at-floor IR score, + which is what the bake itself calls a defect.""" + ratio, _, _, _ = ir_ratio_and_gain(downsample_ir(ir, APP_CONFIG.preview_render_size), _detection_downsample(img)) + score = ir_defect_score(ratio, ir_detect_cutoff(threshold, True)) + at_floor = (score <= 0.05).astype(np.float32) + return at_floor if at_floor.shape == img.shape[:2] else cv2.resize(at_floor, img.shape[:2][::-1], interpolation=cv2.INTER_NEAREST) + + +def load_real(path: str): + """``(source, ir)`` from a scan file, or exit if it carries no IR channel.""" + proc = ImageProcessor() + img, ir, _ = proc._load_source_f32(path, WorkspaceConfig()) + if ir is None: + sys.exit(f"{path} carries no IR channel — the IR bake would be skipped entirely.") + return np.ascontiguousarray(img, dtype=np.float32), np.ascontiguousarray(ir, dtype=np.float32) + + +def bake(img: np.ndarray, ir: np.ndarray, threshold: float, key: str): + """Run the real ``_ir_bake`` + ``_hair_inpaint`` sequence. ``(out, routed)``.""" + proc = ImageProcessor() + cfg = WorkspaceConfig(retouch=RetouchConfig(ir_dust_remove=True, ir_attenuation=True, ir_threshold=threshold)) + out, _, degenerate, routed = proc._ir_bake(img, ir, cfg, key) + if degenerate: + sys.exit("IR plane read as degenerate (B&W/Kodachrome ghost) — the bake self-skipped.") + if routed is not None: + out = proc._hair_inpaint(out, [routed], key) + return np.ascontiguousarray(out, dtype=np.float32), routed + + +def _highpass_sigma(img: np.ndarray, sel: np.ndarray) -> float: + if not sel.any(): + return float("nan") + lum = img.mean(axis=2) if img.ndim == 3 else img + hp = lum - cv2.GaussianBlur(lum, (0, 0), 1.5) + return float(hp[sel].std()) + + +def _grow(mask: np.ndarray, r: int) -> np.ndarray: + return cv2.dilate(mask.astype(np.uint8), cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1,) * 2)) > 0 + + +def measure(src, out, clean, alpha, other, edges, routed, scale_px: float) -> dict: + """Coverage, grain and (with ground truth) accuracy over one defect. + + ``other`` masks the frame's *other* defects out of every selection, so a speck + can't contaminate the filament's numbers or vice versa. + """ + r = max(1, int(round(scale_px))) + elsewhere = ~_grow(other > 0.05, 4 * r) + truth = (alpha > 0.05) & elsewhere + # Core relative to this defect's own peak opacity — a semi-transparent filament + # never reaches an absolute 0.5 but still has a centre line. + core = (alpha >= 0.5 * float(alpha.max())) & elsewhere + changed = (np.abs(out - src).max(axis=2) > 0.002) & _grow(truth, 12 * r) & elsewhere + # Grain reference: ground truth in the same pixels, or (real scan) a clean annulus. + ref = _highpass_sigma(clean, core) if clean is not None else _highpass_sigma(out, _grow(truth, 8 * r) & ~_grow(truth, 2 * r) & elsewhere) + m = { + "defect_px": int(truth.sum()), + "repaired_px": int(changed.sum()), + "overrepair": float(changed.sum() / max(truth.sum(), 1)), + "grain": _highpass_sigma(out, core) / max(ref, 1e-9), + "routed": bool(routed is not None and routed.any() and (_resize_mask(routed, alpha.shape) & core).any()), + } + if clean is None: + return m + + def rmse(sel, a): + return float(np.sqrt(np.mean((a[sel] - clean[sel]) ** 2))) if sel.any() else float("nan") + + near_edge = core & (_grow(edges > 0, 6 * r)) + m.update( + rmse_src=rmse(core, src), + rmse_out=rmse(core, out), + rmse_edge_src=rmse(near_edge, src), + rmse_edge_out=rmse(near_edge, out), + residual_max=float(np.abs(out[core] - clean[core]).max()) if core.any() else float("nan"), + ) + return m + + +def _resize_mask(mask: np.ndarray, shape: tuple) -> np.ndarray: + if mask.shape[:2] == tuple(shape[:2]): + return mask > 0 + return cv2.resize(mask.astype(np.uint8), (shape[1], shape[0]), interpolation=cv2.INTER_NEAREST) > 0 + + +def _png(path: str, img: np.ndarray) -> None: + enc = np.asarray(working_oetf_encode(np.clip(np.ascontiguousarray(img, dtype=np.float32), 0.0, 1.0))) + if enc.ndim == 3: + enc = enc[:, :, ::-1] + cv2.imwrite(path, (np.clip(enc, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)) + + +def dump(tag: str, crop, src, out, clean, routed) -> None: + """Side-by-side crop around the busiest stretch of filament.""" + y0, y1, x0, x1 = crop + os.makedirs(OUT_DIR, exist_ok=True) + panels = [src[y0:y1, x0:x1], out[y0:y1, x0:x1]] + if clean is not None: + panels.append(clean[y0:y1, x0:x1]) + gap = np.ones((y1 - y0, 4, 3), dtype=np.float32) + _png(os.path.join(OUT_DIR, f"{tag}.png"), np.concatenate([p for pair in zip(panels, [gap] * len(panels)) for p in pair][:-1], axis=1)) + if routed is not None: + _png(os.path.join(OUT_DIR, f"{tag}_routed.png"), routed.astype(np.float32)) + + +def _crop_window(alpha: np.ndarray, size: int) -> tuple: + """Window centred on the densest defect neighbourhood.""" + h, w = alpha.shape + size = min(size, h, w) + dens = cv2.boxFilter((alpha > 0.05).astype(np.float32), -1, (size | 1, size | 1)) + cy, cx = np.unravel_index(int(np.argmax(dens)), dens.shape) + y0 = int(np.clip(cy - size // 2, 0, h - size)) + x0 = int(np.clip(cx - size // 2, 0, w - size)) + return y0, y0 + size, x0, x0 + size + + +_REPORT_KEYS = ("defect_px", "repaired_px", "overrepair", "grain", "rmse_src", "rmse_out", "rmse_edge_src", "rmse_edge_out", "residual_max") + + +def _report(rows: list) -> None: + keys = [k for k in _REPORT_KEYS if any(k in m for _, _, m in rows)] + print(f"\n{'run':<14}{'defect':<11}{'routed':<8}" + "".join(f"{k:>14}" for k in keys)) + print("-" * (33 + 14 * len(keys))) + for run, defect, m in rows: + cells = "".join(f"{m[k]:>14.4g}" if k in m else f"{'-':>14}" for k in keys) + print(f"{run:<14}{defect:<11}{('yes' if m['routed'] else 'no'):<8}{cells}") + print( + "\noverrepair 1.0 = exact, high = defect smeared into a band." + "\ngrain 1.0 = texture matches ground truth; ~0 = smooth 'plastic worm', >1 = residual defect." + "\nrmse_out must beat rmse_src (do nothing); rmse_edge_* isolates edge crossings." + f"\ncrops: {OUT_DIR}" + ) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--file", help="scan with an IR channel; omitted = synthetic frame with ground truth") + ap.add_argument("--threshold", type=float, default=RetouchConfig.ir_threshold, help="IR Threshold slider value") + ap.add_argument("--long-edge", type=int, default=4800, help="synthetic frame long edge (px)") + ap.add_argument("--crop", type=int, default=420, help="dumped crop size (px)") + args = ap.parse_args() + + if args.file: + src_full, ir_full = load_real(args.file) + clean_full, edges_full = None, np.zeros(src_full.shape[:2], np.uint8) + # No ground truth: the "defect" is whatever the detector itself scores at floor. + regions_full = {"detected": _detected_alpha(src_full, ir_full, args.threshold)} + else: + clean_full, src_full, ir_full, regions_full, edges_full = synthetic_frame(args.long_edge) + + rows = [] + for name, scale in (("preview", 1.0), ("full-res", max(1.0, max(src_full.shape[:2]) / APP_CONFIG.preview_render_size))): + shrink = scale == 1.0 + img = _detection_downsample(src_full) if shrink else src_full + ir = downsample_ir(ir_full, APP_CONFIG.preview_render_size) if shrink else ir_full + clean = None if clean_full is None else (_detection_downsample(clean_full) if shrink else clean_full) + edges = (_detection_downsample(edges_full) if shrink else edges_full) > 0.3 + regions = {k: (_detection_downsample(v) if shrink else v) for k, v in regions_full.items()} + + out, routed = bake(img, ir, args.threshold, name) + for defect, alpha in regions.items(): + other = np.maximum.reduce([v for k, v in regions.items() if k != defect]) if len(regions) > 1 else np.zeros_like(alpha) + rows.append((name, defect, measure(img, out, clean, alpha, other, edges, routed, scale))) + dump(f"{name}_{defect}", _crop_window(alpha, int(args.crop * scale)), img, out, clean, routed) + _report(rows) + + +if __name__ == "__main__": + main() diff --git a/tests/test_capture_controller.py b/tests/test_capture_controller.py index fca52006..d197dae8 100644 --- a/tests/test_capture_controller.py +++ b/tests/test_capture_controller.py @@ -142,3 +142,35 @@ def test_capture_intent_is_scoped_to_captured_primary_path(): def test_empty_paths_is_a_noop(): c = _run([]) c.request_asset_discovery.assert_not_called() # nothing captured → no discovery + + +def test_capture_stamps_roll_and_frame_on_import(): + c = _run( + ["r.ARW", "g.ARW", "b.ARW"], + rgb_mode=True, + white_mode=False, + roll_name="Summer24", + frame_number=12, + ) + pending = c._pending_capture_imports[os.path.normcase(os.path.abspath("r.ARW"))] + assert pending.capture_roll == "Summer24" + assert pending.capture_frame == 12 + + _hydrate_and_load(c, "r.ARW", ProcessMode.E6, autodetect=True) + + assert c.state.config.metadata.capture_roll == "Summer24" + assert c.state.config.metadata.capture_frame == 12 + + +def test_normal_scan_stamps_roll_without_process_override(): + c = _run(["frame.ARW"], rgb_mode=False, roll_name="Roll001", frame_number=3) + pending = c._pending_capture_imports[os.path.normcase(os.path.abspath("frame.ARW"))] + assert pending.process_mode is None + assert pending.capture_roll == "Roll001" + assert pending.capture_frame == 3 + + _hydrate_and_load(c, "frame.ARW", ProcessMode.C41, autodetect=True) + + assert c.state.config.process.process_mode == ProcessMode.C41 + assert c.state.config.metadata.capture_roll == "Roll001" + assert c.state.config.metadata.capture_frame == 3 diff --git a/tests/test_export_flush.py b/tests/test_export_flush.py new file mode 100644 index 00000000..d96be75c --- /dev/null +++ b/tests/test_export_flush.py @@ -0,0 +1,63 @@ +"""Export UI flush is invoked before export entry points read Destination/filename.""" + +from dataclasses import replace +from unittest.mock import MagicMock + +from negpy.desktop.controller import AppController +from negpy.desktop.session import AppState +from negpy.domain.models import ExportPresetOutputMode, WorkspaceConfig, ExportConfig + + +def test_flush_export_ui_invokes_callback(): + flush = MagicMock() + controller = MagicMock() + controller.flush_export_settings = flush + AppController._flush_export_ui(controller) + flush.assert_called_once() + + +def test_flush_export_ui_noop_without_callback(): + controller = MagicMock() + controller.flush_export_settings = None + AppController._flush_export_ui(controller) # must not raise + + +def test_request_export_calls_flush_export_ui(): + flush_ui = MagicMock() + controller = MagicMock() + controller._flush_export_ui = flush_ui + controller._batch_busy = MagicMock(return_value=False) + controller.state = AppState() + controller.state.current_file_path = "/tmp/shot.tif" + controller.state.current_file_hash = "abc" + controller.state.source_exif = {} + controller.state.flat_output = False + controller.state.gpu_enabled = False + controller.state.workspace_color_space = "Adobe RGB" + controller.state.icc_output_path = None + controller.state.config = replace( + WorkspaceConfig(), + export=ExportConfig( + output_mode=ExportPresetOutputMode.ABSOLUTE, + export_path="C:/Exports", + ), + ) + controller._ensure_valid_export_path = MagicMock(return_value="C:/Exports") + controller.effective_input_icc = MagicMock(return_value=None) + controller._run_export_tasks = MagicMock() + + AppController.request_export(controller) + + flush_ui.assert_called_once() + controller._run_export_tasks.assert_called_once() + + +def test_flush_export_settings_stops_timer_and_persists(): + from negpy.desktop.view.sidebar.export import ExportSidebar + + sidebar = MagicMock() + sidebar.update_timer = MagicMock() + sidebar._persist_all_export_settings = MagicMock() + ExportSidebar._flush_export_settings(sidebar) + sidebar.update_timer.stop.assert_called_once() + sidebar._persist_all_export_settings.assert_called_once() diff --git a/tests/test_templating.py b/tests/test_templating.py index 76981866..a1087315 100644 --- a/tests/test_templating.py +++ b/tests/test_templating.py @@ -1,6 +1,7 @@ from datetime import datetime from negpy.domain.models import ExportConfig, ExportFormat, ExportResolutionMode -from negpy.services.export.templating import render_export_filename +from negpy.features.metadata.models import MetadataConfig +from negpy.services.export.templating import parse_capture_stem, render_export_filename # ── Existing tests (unchanged behavior) ────────────────────────────────────── @@ -171,3 +172,128 @@ def test_invalid_pattern_fallback_preserves_original_name(): """Fallback path (bad template) gives verbatim original_name.""" conf = ExportConfig(filename_pattern="{{ invalid_var }}") assert render_export_filename("IMG-0001.orf", conf) == "IMG-0001" + + +# ── Metadata / capture-roll variables ───────────────────────────────────────── + + +def test_gear_vars_render(): + meta = MetadataConfig( + camera_make="Mamiya", + camera_model="7", + lens_model="80mm f/4", + film="Portra 400", + film_iso=400, + film_manufacturer="Kodak", + format="35mm", + developer="D-76 1+1", + push_pull=1, + scanning="DSLR copy-stand", + exposure_override="1/125s f/2.8", + focal_length_mm=80.0, + ) + conf = ExportConfig( + filename_pattern=( + "{{ film }}_{{ film_iso }}_{{ camera }}_{{ lens }}_{{ focal_length }}_" + "{{ film_format }}_{{ developer }}_{{ push_pull }}_{{ scanning }}_{{ exposure }}_" + "{{ original_name }}" + ) + ) + result = render_export_filename("DSC0123.orf", conf, metadata=meta) + assert result == ("Portra_400_400_Mamiya_7_80mm_f4_80_35mm_D_76_1+1_1_DSLR_copy_stand_1125s_f2.8_DSC0123") + + +def test_empty_gear_collapses_separators(): + conf = ExportConfig(filename_pattern="{{ film }}_{{ camera }}_{{ original_name }}_end") + result = render_export_filename("shot.jpg", conf, metadata=MetadataConfig()) + assert result == "shot_end" + + +def test_format_vs_film_format_no_collision(): + meta = MetadataConfig(format="120") + conf = ExportConfig( + export_fmt=ExportFormat.JPEG, + filename_pattern="{{ original_name }}_{{ format }}_{{ film_format }}", + ) + result = render_export_filename("img.jpg", conf, metadata=meta) + assert result == "img_JPEG_120" + + +def test_film_format_other(): + meta = MetadataConfig(format="Other", format_other="6×7") + conf = ExportConfig(filename_pattern="{{ film_format }}_{{ original_name }}") + assert render_export_filename("img.jpg", conf, metadata=meta) == "6×7_img" + + +def test_roll_frame_from_metadata(): + meta = MetadataConfig(capture_roll="Summer24", capture_frame=7) + conf = ExportConfig(filename_pattern='{{ roll }}_Frame{{ "%03d" % frame }}_{{ original_name }}') + result = render_export_filename("ignored_name.tif", conf, metadata=meta) + assert result == "Summer24_Frame007_ignored_name" + + +def test_roll_frame_parse_fallback_from_stem(): + conf = ExportConfig(filename_pattern='{{ roll }}_Frame{{ "%03d" % frame }}_{{ film }}') + result = render_export_filename( + "/rolls/Roll001_Frame012.tif", + conf, + metadata=MetadataConfig(film="Portra"), + ) + assert result == "Roll001_Frame012_Portra" + + +def test_metadata_roll_wins_over_stem_parse(): + meta = MetadataConfig(capture_roll="ManualRoll", capture_frame=3) + conf = ExportConfig(filename_pattern="{{ roll }}_{{ frame }}") + result = render_export_filename("Roll001_Frame012.tif", conf, metadata=meta) + assert result == "ManualRoll_3" + + +def test_path_unsafe_chars_sanitized(): + meta = MetadataConfig(camera_model='Foo/Bar:Baz*?"<>|', film="A\\B") + conf = ExportConfig(filename_pattern="{{ camera_model }}_{{ film }}_{{ original_name }}") + result = render_export_filename("shot.jpg", conf, metadata=meta) + assert result == "FooBarBaz_AB_shot" + assert "/" not in result + assert ":" not in result + assert "*" not in result + + +def test_half_frame_with_metadata(): + meta = MetadataConfig(film="HP5", capture_roll="R1", capture_frame=1) + conf = ExportConfig(filename_pattern="{{ roll }}_{{ film }}_{{ original_name }}") + result = render_export_filename("/x/IMG420.tif", conf, half=2, metadata=meta) + assert result == "R1_HP5_IMG420_2" + + +def test_parse_capture_stem(): + assert parse_capture_stem("Roll001_Frame012") == ("Roll001", 12) + assert parse_capture_stem("roll001_frame7") == ("roll001", 7) + assert parse_capture_stem("DSC0123") == ("", None) + + +def test_pad_filter_with_missing_frame_keeps_other_vars(): + """Missing frame must not abort the whole pattern when using |pad.""" + meta = MetadataConfig(capture_roll="Summer24", film="Portra", film_iso=400) + conf = ExportConfig(filename_pattern="{{ roll }}_Frame{{ frame|pad(3) }}_{{ film }}_{{ film_iso }}_{{ original_name }}") + result = render_export_filename("HighDef2 (3).tif", conf, metadata=meta) + assert result == "Summer24_Frame_Portra_400_HighDef2 (3)" + + +def test_frame_padded_var(): + meta = MetadataConfig(capture_frame=12) + conf = ExportConfig(filename_pattern="{{ frame_padded }}_{{ original_name }}") + assert render_export_filename("shot.tif", conf, metadata=meta) == "012_shot" + + +def test_percent_format_missing_frame_falls_back_to_original_name(): + """Legacy '%03d' % frame with unset frame still falls back (documented limitation).""" + conf = ExportConfig(filename_pattern='{{ roll }}_Frame{{ "%03d" % frame }}_{{ original_name }}') + result = render_export_filename("HighDef2 (3).tif", conf, metadata=MetadataConfig(capture_roll="R1")) + assert result == "HighDef2 (3)" + + +def test_percent_format_with_frame_set(): + meta = MetadataConfig(capture_roll="R1", capture_frame=12) + conf = ExportConfig(filename_pattern='{{ roll }}_Frame{{ "%03d" % frame }}') + assert render_export_filename("shot.tif", conf, metadata=meta) == "R1_Frame012" From 04717c6ba048592645715840b04cfb476bdf7e17 Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 1 Aug 2026 16:25:09 +0200 Subject: [PATCH 2/3] Fixed colliding line --- negpy/desktop/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 639127dd..551c2a81 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -1,7 +1,7 @@ import os import time from dataclasses import dataclass, fields, replace -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple import numpy as np from PyQt6.QtCore import Q_ARG, QMetaObject, QObject, Qt, QThread, QTimer, pyqtSignal From 9e24a787cd0f19815baa582049dc1b2223752bf8 Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 1 Aug 2026 16:50:43 +0200 Subject: [PATCH 3/3] Roll and frame number is now writen to the negpy xmp namespace on export if set, if not set, these fields is omitted --- docs/USER_GUIDE.md | 2 +- negpy/features/metadata/payload.py | 10 ++++++++++ negpy/features/metadata/xmp.py | 4 ++++ tests/metadata/test_gear_payload.py | 31 +++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 578e8862..730d1fc6 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -507,7 +507,7 @@ Archival metadata for the **original analog capture** (camera, lens, film, proce **Scanning:** * **Scanning**: scan method/notes (EXIF `Software` is always `NegPy`). -* **Roll / Frame**: Scanlight capture roll name and frame number. Stamped automatically on capture; editable here. Available in export filename templates as `{{ roll }}` / `{{ frame }}` (not the Roll Analysis normalization name). +* **Roll / Frame**: Scanlight capture roll name and frame number. Stamped automatically on capture; editable here. Available in export filename templates as `{{ roll }}` / `{{ frame }}`, and written to XMP as `negpy:CaptureRoll` / `negpy:CaptureFrame` when set (not the Roll Analysis normalization name). * **Sync custom metadata to all files in batch export**: apply this tab's values to every file in a batch. **Exposure**: optional original shutter/aperture/ISO. Click the lock to edit a free-text string (e.g. `1/125s f/2.8 ISO 400`). diff --git a/negpy/features/metadata/payload.py b/negpy/features/metadata/payload.py index 251c8a4b..f0984fb9 100644 --- a/negpy/features/metadata/payload.py +++ b/negpy/features/metadata/payload.py @@ -79,6 +79,8 @@ class MetadataPayload: film_format: str = "" film_color_type: str = "" capture_exposure: str = "" + capture_roll: str = "" + capture_frame: Optional[int] = None # Digitization rig (negpy:Scan* XMP only; source EXIF when capture gear not set) scan_camera_make: str = "" @@ -157,6 +159,10 @@ def to_preview_sections(self) -> list[tuple[str, list[tuple[str, str]]]]: scan.append(("ISO", str(self.scan_iso))) if self.scan_method: scan.append(("Scan method", self.scan_method)) + if self.capture_roll: + scan.append(("Roll", self.capture_roll)) + if self.capture_frame is not None: + scan.append(("Frame", str(self.capture_frame))) if scan: sections.append(("Scan", scan)) @@ -283,6 +289,8 @@ def build_metadata_payload( film_format=film_format.strip(), film_color_type=film_color_type.strip(), capture_exposure=capture_exposure, + capture_roll=config.capture_roll.strip(), + capture_frame=config.capture_frame, scan_camera_make=scan.camera_make, scan_camera_model=scan.camera_model, scan_lens_make=scan.lens_make, @@ -315,6 +323,8 @@ def build_metadata_payload( film_format=draft.film_format, film_color_type=draft.film_color_type, capture_exposure=draft.capture_exposure, + capture_roll=draft.capture_roll, + capture_frame=draft.capture_frame, scan_camera_make=draft.scan_camera_make, scan_camera_model=draft.scan_camera_model, scan_lens_make=draft.scan_lens_make, diff --git a/negpy/features/metadata/xmp.py b/negpy/features/metadata/xmp.py index 3303e5e0..49ed98f6 100644 --- a/negpy/features/metadata/xmp.py +++ b/negpy/features/metadata/xmp.py @@ -72,6 +72,10 @@ def build_xmp_xml(payload: MetadataPayload, *, standalone: bool = True) -> str: _sub(desc, "negpy", "Notes", payload.notes) if payload.scan_method: _sub(desc, "negpy", "ScanMethod", payload.scan_method) + if payload.capture_roll: + _sub(desc, "negpy", "CaptureRoll", payload.capture_roll) + if payload.capture_frame is not None: + _sub(desc, "negpy", "CaptureFrame", str(payload.capture_frame)) # Digitization rig — always from source snapshot if payload.scan_camera_make: diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py index aa37892e..3827db09 100644 --- a/tests/metadata/test_gear_payload.py +++ b/tests/metadata/test_gear_payload.py @@ -432,6 +432,37 @@ def test_xmp_contains_negpy_capture_namespace(): assert "negpy:Developer" in xml assert "tiff:Make" not in xml + assert "negpy:CaptureRoll" not in xml + assert "negpy:CaptureFrame" not in xml + + +def test_xmp_includes_capture_roll_and_frame_when_set(): + from negpy.features.metadata.payload import MetadataPayload + + xml = build_xmp_xml(MetadataPayload(capture_roll="Summer24", capture_frame=12)) + assert "negpy:CaptureRoll" in xml + assert ">Summer24<" in xml + assert "negpy:CaptureFrame" in xml + assert ">12<" in xml + + +def test_xmp_omits_empty_capture_roll_and_unset_frame(): + from negpy.features.metadata.payload import MetadataPayload + + xml = build_xmp_xml(MetadataPayload(capture_roll="", capture_frame=None, film_stock="HP5")) + assert "negpy:CaptureRoll" not in xml + assert "negpy:CaptureFrame" not in xml + assert "negpy:CaptureFilmStock" in xml + + +def test_build_metadata_payload_passes_capture_roll_frame(): + config = MetadataConfig(capture_roll="Roll001", capture_frame=3) + payload = build_metadata_payload(config) + assert payload.capture_roll == "Roll001" + assert payload.capture_frame == 3 + pairs = dict(payload.to_preview_pairs()) + assert pairs["Roll"] == "Roll001" + assert pairs["Frame"] == "3" def test_scan_rig_preserved_in_xmp_while_exif_shows_analog():