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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Change: **Bundled gear and crosstalk profiles now load live from the app** instead of being copied into your docs folder. Docs folder is reserved for your personal profiles & gear
- Change: **Selecting a thumbnail now opens it** — a single click loads the frame into the viewer (Ctrl/Shift-click still builds a multi-selection for batch actions); double-clicking inside an open crop box now confirms and closes the tool. @linkmodo
- Change: **Cyan** now has an assignable keyboard shortcut alongside the other white-balance sliders, unbound by default (you should not be touching cyan!) — set it in the shortcut editor.
- Change: **Export UX pass** — one red **Export** button replaces the toolbar Export, "Export All" and the "Sync export settings" toggle; its chevron menu *selects* what the button does (current frame, selected, all visible) and remembers the choice, with **Export Presets** working the same way. Default sizing is now **Original** resolution, multi-frame exports ask for confirmation, and failed frames are reported instead of a false "complete".
- Fix: **crop no longer inflates under Fine Rotation** — the manual crop rectangle is now measured in the same already-rotated view you draw it on, instead of being mapped through the tilt and re-bounded, which grew the cropped area as the tilt increased. @linkmodo
- Fix: **locked/disabled controls now are greyed-out** — sliders, buttons and fields under an active lock (e.g. Analysis Buffer under Roll Average, Flat-Field k1 with no reference profile) now show a grey disabled state instead of looking identical to normal ones.
- Fix: **Next/Prev buttons now follow the sorted/filtered order** shown in the filmstrip instead of raw load order — fixes them greying out mid-roll or staying stuck (but unresponsive) at the end of a roll not loaded alphabetically.
Expand Down
59 changes: 43 additions & 16 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ExportPresetOutputMode,
ExportResolutionMode,
WorkspaceConfig,
export_blocked,
flat_export_config,
flat_master_config,
preset_from_export_config,
Expand Down Expand Up @@ -99,7 +100,7 @@ class AppController(QObject):
loading_started = pyqtSignal()
load_failed = pyqtSignal()
export_progress = pyqtSignal(int, int, str)
export_finished = pyqtSignal(float)
export_finished = pyqtSignal(float, int)
render_requested = pyqtSignal(RenderTask)
preview_load_requested = pyqtSignal(PreviewLoadTask)
normalization_requested = pyqtSignal(NormalizationTask)
Expand Down Expand Up @@ -139,6 +140,7 @@ def __init__(self, session_manager: DesktopSessionManager):
self._thumb_config: Optional[WorkspaceConfig] = None
self._first_render_t0: Optional[float] = None
self._export_start_time = 0.0
self._export_failures = 0
self._discovery_running = False
self._auto_open_after_discovery = False
self._replace_after_discovery = False
Expand Down Expand Up @@ -266,6 +268,7 @@ def _connect_signals(self) -> None:
self.export_worker.finished.connect(self._on_export_finished)
self.export_worker.cancelled.connect(self._on_batch_cancelled)
self.export_worker.error.connect(self._on_render_error)
self.export_worker.error.connect(self._on_export_task_error)

self.thumbnail_requested.connect(self.thumb_worker.generate)
self.thumb_worker.progress.connect(self._on_thumbnail_progress)
Expand Down Expand Up @@ -1560,6 +1563,9 @@ def request_batch_export(self, override_settings: bool = False, files: list[dict
if files is None:
files = [self.state.uploaded_files[i] for i in self.session.asset_model.visible_actual_indices_ordered()]

if len(files) > 1 and not self._confirm_bulk_export(f"Export {len(files)} frames?"):
return

if self.state.config.export.export_sidecars_enabled:
self._write_edit_sidecars(files)

Expand Down Expand Up @@ -1655,7 +1661,17 @@ def _build_preset_export_tasks(self, files: list[dict], presets: List[ExportPres
)
return tasks

def _dispatch_preset_export(self, files: list[dict], *, confirm: bool) -> None:
def _confirm_bulk_export(self, text: str) -> bool:
reply = QMessageBox.question(
None,
"Export",
text,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
return reply == QMessageBox.StandardButton.Yes

def _dispatch_preset_export(self, files: list[dict]) -> None:
if not files:
return

Expand All @@ -1667,21 +1683,13 @@ def _dispatch_preset_export(self, files: list[dict], *, confirm: bool) -> None:
if not self._validate_preset_paths(presets):
return

if confirm:
if len(files) > 1:
n_frames = len(files)
n_presets = len(presets)
n_files = n_frames * n_presets
frame_word = "frame" if n_frames == 1 else "frames"
preset_word = "preset" if n_presets == 1 else "presets"
file_word = "file" if n_files == 1 else "files"
reply = QMessageBox.question(
None,
"Export All Presets",
f"Export {n_frames} {frame_word} through {n_presets} {preset_word} ({n_files} {file_word})?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Cancel,
)
if reply != QMessageBox.StandardButton.Yes:
if not self._confirm_bulk_export(f"Export {n_frames} frames through {n_presets} {preset_word} ({n_files} {file_word})?"):
return

if self.state.config.export.export_sidecars_enabled:
Expand All @@ -1701,12 +1709,12 @@ def request_preset_export(self) -> None:
"path": self.state.current_file_path,
"hash": self.state.current_file_hash,
}
self._dispatch_preset_export([file_info], confirm=False)
self._dispatch_preset_export([file_info])

def request_preset_export_selected(self) -> None:
"""Initiates preset export for every selected filmstrip frame."""
files = self._preset_export_files_for_selection()
self._dispatch_preset_export(files, confirm=False)
self._dispatch_preset_export(files)

def request_preset_batch_export(self) -> None:
"""Initiates batch export for all visible files using enabled presets."""
Expand All @@ -1715,7 +1723,7 @@ def request_preset_batch_export(self) -> None:
for i in self.session.asset_model.visible_actual_indices_ordered()
if not self.state.uploaded_files[i].get("excluded")
]
self._dispatch_preset_export(visible_files, confirm=True)
self._dispatch_preset_export(visible_files)

def _contact_sheet_output_dir(self, visible_files: list) -> Optional[str]:
"""Resolve the contact sheet output folder (custom path or export destination rules)."""
Expand All @@ -1736,6 +1744,9 @@ def request_contact_sheet(self) -> None:
if not out_dir:
return

if len(visible_files) > 1 and not self._confirm_bulk_export(f"Render a contact sheet from {len(visible_files)} frames?"):
return

tasks = []
for f in visible_files:
params = self._batch_params_for(f)
Expand All @@ -1751,6 +1762,7 @@ def request_contact_sheet(self) -> None:

cs = self.state.config.export
self._export_start_time = time.time()
self._export_failures = 0
self._begin_batch("Contact sheet", abortable=True)
QMetaObject.invokeMethod(
self.export_worker,
Expand Down Expand Up @@ -1786,7 +1798,19 @@ def export_edit_sidecars(self) -> None:
self.set_status(f"Wrote {written} edit sidecar(s)", 4000)

def _run_export_tasks(self, tasks: List[ExportTask]) -> None:
blocked = [t for t in tasks if export_blocked(t.export_settings.export_fmt, t.export_settings.export_color_space)]
if blocked:
names = ", ".join(sorted({t.file_info.get("name", "?") for t in blocked})[:5])
QMessageBox.warning(
None,
"Export",
f"JPEG XL can't tag the selected colour space ({names}).\n"
"Choose sRGB, P3 D65, Rec 2020 or Greyscale, or a different format.",
)
return

self._export_start_time = time.time()
self._export_failures = 0
self._begin_batch("Exporting", abortable=True)
QMetaObject.invokeMethod(
self.export_worker,
Expand Down Expand Up @@ -1881,10 +1905,13 @@ def _on_render_error(self, message: str) -> None:
self._is_rendering = True
self.render_requested.emit(task)

def _on_export_task_error(self, _message: str) -> None:
self._export_failures += 1

def _on_export_finished(self) -> None:
elapsed = time.time() - self._export_start_time
self._end_batch()
self.export_finished.emit(elapsed)
self.export_finished.emit(elapsed, self._export_failures)
self._update_thumbnail_from_state(force_readback=True)

def _update_thumbnail_from_state(self, force_readback: bool = False, persist: bool = True) -> None:
Expand Down
22 changes: 6 additions & 16 deletions negpy/desktop/view/canvas/toolbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ def _create_separator(self) -> QFrame:
def _init_ui(self) -> None:
main_layout = QHBoxLayout(self)
main_layout.setContentsMargins(0, 10, 0, 10)
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)

container = QFrame()
container.setObjectName("toolbar_container")
Expand Down Expand Up @@ -159,15 +158,10 @@ def _init_ui(self) -> None:
self.canvas_color_btns.append(btn)
self.canvas_color_btns[self.session.state.canvas_bg_index].setChecked(True)

# 5. Save / Export
self.btn_save = QPushButton(" Save")
# 5. Save
self.btn_save = QToolButton()
self.btn_save.setIcon(qta.icon("fa5s.save", color=icon_color))

self.btn_export = QPushButton(" Export")
self.btn_export.setObjectName("export_btn")
self.btn_export.setIcon(qta.icon("fa5s.check-circle", color=icon_color))
self.btn_export.setToolTip("Export Ctrl+E")
self.btn_export.setFixedHeight(36)
self.btn_save.setToolTip("Save Edits")

# 6. Overflow menu & responsive groups
self.btn_overflow = QToolButton()
Expand Down Expand Up @@ -258,10 +252,7 @@ def _init_ui(self) -> None:
btn.setFixedHeight(btn_height)
btn.setCursor(Qt.CursorShape.PointingHandCursor)

self.btn_export.setIconSize(icon_size)
self.btn_export.setCursor(Qt.CursorShape.PointingHandCursor)

# Single-row layout: toggle_left · prev · next · sep1 · zoom+label · hq · swatches · sep2 · rot_l · rot_r · flip_h · flip_v · sep3 · save · export · overflow · toggle_right
# Single-row layout: toggle_left · prev · next · sep1 · zoom+label · hq · swatches · sep2 · rot_l · rot_r · flip_h · flip_v · sep3 · save · overflow · toggle_right
row_layout.addWidget(self.btn_toggle_left)
row_layout.addWidget(self.btn_prev)
row_layout.addWidget(self.btn_next)
Expand All @@ -281,7 +272,6 @@ def _init_ui(self) -> None:
self._sep3 = self._create_separator()
row_layout.addWidget(self._sep3)
row_layout.addWidget(self.btn_save)
row_layout.addWidget(self.btn_export)
row_layout.addWidget(self.btn_compare)
row_layout.addWidget(self.btn_gpu)
row_layout.addWidget(self.btn_overflow)
Expand All @@ -292,7 +282,8 @@ def _init_ui(self) -> None:
self._ov_flip_rotate: list = [self.btn_rot_l, self.btn_rot_r, self.btn_flip_h, self.btn_flip_v, self._sep3]

v_layout.addLayout(row_layout)
main_layout.addWidget(container)
# Size the pill to its controls; don't stretch it across the canvas.
main_layout.addWidget(container, 0, Qt.AlignmentFlag.AlignCenter)

def _connect_signals(self) -> None:
self.btn_prev.clicked.connect(self.session.prev_file)
Expand All @@ -304,7 +295,6 @@ def _connect_signals(self) -> None:
self.btn_flip_v.clicked.connect(lambda: self.flip("vertical"))

self.btn_save.clicked.connect(self.controller.save_current_edits)
self.btn_export.clicked.connect(self.controller.request_export)

self.canvas_color_group.idToggled.connect(self._on_canvas_color_changed)

Expand Down
7 changes: 5 additions & 2 deletions negpy/desktop/view/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,12 @@ def _on_export_progress(self, current: int, total: int, filename: str) -> None:
self.top_status.file_pos_label.clear()
self.top_status.showMessage(f"Exporting {filename} ({current}/{total})...")

def _on_export_finished(self, elapsed: float) -> None:
def _on_export_finished(self, elapsed: float, failed: int) -> None:
self.top_status.progress.setVisible(False)
self.top_status.showMessage(f"export complete in {elapsed:.2f}s", timeout=3000)
msg = f"export complete in {elapsed:.2f}s"
if failed:
msg += f" — {failed} failed"
self.top_status.showMessage(msg, timeout=6000 if failed else 3000)

def resizeEvent(self, event) -> None:
super().resizeEvent(event)
Expand Down
Loading
Loading