Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/TEMPLATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` |

---

Expand Down
5 changes: 3 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,11 +478,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):
Expand Down Expand Up @@ -512,6 +512,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 }}`, 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`).
Expand Down
40 changes: 38 additions & 2 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import time
from dataclasses import dataclass, fields, replace
from typing import Any, Dict, List, Optional, Tuple
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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -305,6 +307,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()
Expand Down Expand Up @@ -1085,6 +1088,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
Expand Down Expand Up @@ -2805,18 +2819,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))

Expand Down Expand Up @@ -3181,6 +3208,11 @@ 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_linear_output_export(self, files: list[dict] | None = None) -> None:
"""Export decoded linear buffers as untagged 16-bit TIFFs to the export folder."""
from negpy.services.export.linear_output import export_linear_output, is_linear_output_supported
Expand Down Expand Up @@ -3228,6 +3260,7 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None:

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:
Expand Down Expand Up @@ -3274,6 +3307,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()
Expand Down Expand Up @@ -3415,6 +3449,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:
Expand Down Expand Up @@ -3481,6 +3516,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()]
Expand Down
9 changes: 9 additions & 0 deletions negpy/desktop/view/sidebar/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -858,7 +860,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()
if self.state.linear_output:
scope = self._export_scope
if scope in ("all_current", "all_saved"):
Expand Down Expand Up @@ -898,6 +906,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()
Expand Down
37 changes: 37 additions & 0 deletions negpy/desktop/view/sidebar/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand All @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion negpy/desktop/view/widgets/export_settings_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/workers/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions negpy/features/metadata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading