From e3274e3b43c38e1be2406b124fcecd88ea7fb66e Mon Sep 17 00:00:00 2001 From: jboneng Date: Sat, 4 Jul 2026 21:09:25 +0200 Subject: [PATCH] Export Presets main button runs selected frames, preview frame when only one selected --- negpy/desktop/controller.py | 151 +++++++++++-------- negpy/desktop/view/sidebar/export.py | 6 +- negpy/desktop/view/widgets/tutorial_steps.py | 4 +- tests/test_controller.py | 91 +++++++++++ 4 files changed, 181 insertions(+), 71 deletions(-) diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 63d37576..a08b9973 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -1520,73 +1520,32 @@ def request_batch_export(self, override_settings: bool = False, files: list[dict if tasks: self._run_export_tasks(tasks) - def request_preset_export(self) -> None: - """Initiates high-resolution export for the current file using enabled presets.""" - if not self.state.current_file_path: - return - - presets = self._enabled_presets() - if not presets: - QMessageBox.information(None, "No presets enabled", "Enable at least one export preset in the Export panel.") - return - - if not self._validate_preset_paths(presets): - return - - source_exif = self.state.source_exif.get(self.state.current_file_hash or "") - file_info = { - "name": os.path.basename(self.state.current_file_path), - "path": self.state.current_file_path, - "hash": self.state.current_file_hash, - } - if self.state.config.export.export_sidecars_enabled: - self._write_edit_sidecars([file_info]) - tasks = self._tasks_for_file( - file_info, - self.state.config, - presets, - source_exif=source_exif, - metadata_config=self.state.config.metadata, - ) - if tasks: - self._run_export_tasks(tasks) - - def request_preset_batch_export(self) -> None: - """Initiates batch export for all visible files using enabled presets.""" - presets = self._enabled_presets() - if not presets: - QMessageBox.information(None, "No presets enabled", "Enable at least one export preset in the Export panel.") - return - - if not self._validate_preset_paths(presets): - return - + def _preset_export_files_for_selection(self) -> list[dict]: + """Selected filmstrip frames in display order; single selection exports the preview frame.""" + n = len(self.state.uploaded_files) + selected = [i for i in self.state.selected_indices if 0 <= i < n] + + if len(selected) <= 1: + if not self.state.current_file_path or not (0 <= self.state.selected_file_idx < n): + return [] + file_info = self.state.uploaded_files[self.state.selected_file_idx] + if file_info.get("excluded"): + return [] + return [file_info] + + selected_set = set(selected) + visible_order = self.session.asset_model.visible_actual_indices_ordered() + ordered = [i for i in visible_order if i in selected_set] + for i in sorted(selected_set): + if i not in ordered: + ordered.append(i) + files = [self.state.uploaded_files[i] for i in ordered] + return [f for f in files if not f.get("excluded")] + + def _build_preset_export_tasks(self, files: list[dict], presets: List[ExportPreset]) -> List[ExportTask]: sync_metadata = self.state.config.metadata.sync_to_batch - visible_files = [self.state.uploaded_files[i] for i in self.session.asset_model.visible_actual_indices_ordered()] - if not visible_files: - return - - n_frames = len(visible_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: - return - - if self.state.config.export.export_sidecars_enabled: - self._write_edit_sidecars(visible_files) - - tasks = [] - for f in visible_files: + tasks: List[ExportTask] = [] + for f in files: params = self.session.repo.load_file_settings(f["hash"]) or self.state.config bounds_override = None @@ -1607,10 +1566,70 @@ def request_preset_batch_export(self) -> None: metadata_config=metadata_config, ) ) + return tasks + + def _dispatch_preset_export(self, files: list[dict], *, confirm: bool) -> None: + if not files: + return + + presets = self._enabled_presets() + if not presets: + QMessageBox.information(None, "No presets enabled", "Enable at least one export preset in the Export panel.") + return + + if not self._validate_preset_paths(presets): + return + + if confirm: + 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: + return + + if self.state.config.export.export_sidecars_enabled: + self._write_edit_sidecars(files) + tasks = self._build_preset_export_tasks(files, presets) if tasks: self._run_export_tasks(tasks) + def request_preset_export(self) -> None: + """Initiates high-resolution export for the current file using enabled presets.""" + if not self.state.current_file_path: + return + + file_info = { + "name": os.path.basename(self.state.current_file_path), + "path": self.state.current_file_path, + "hash": self.state.current_file_hash, + } + self._dispatch_preset_export([file_info], confirm=False) + + 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) + + def request_preset_batch_export(self) -> None: + """Initiates batch export for all visible files using enabled presets.""" + visible_files = [ + self.state.uploaded_files[i] + 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) + def _contact_sheet_output_dir(self, visible_files: list) -> Optional[str]: """Resolve the contact sheet output folder (custom path or export destination rules).""" custom = self.state.config.export.contact_sheet_output_path.strip() diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index 102d3930..752c9ba3 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -77,7 +77,7 @@ def _connect_signals(self) -> None: self.controller.monitor_profile_changed.connect(self._refresh_display_info) self.manage_presets_btn.clicked.connect(self._open_presets_dialog) - self.export_presets_btn.clicked.connect(self.controller.request_preset_export) + self.export_presets_btn.clicked.connect(self.controller.request_preset_export_selected) self.export_presets_menu_btn.clicked.connect(self._show_export_presets_menu) self.intent_btn_group.idToggled.connect(self._on_flat_output_toggled) @@ -126,8 +126,8 @@ def _add_presets_section(self) -> None: self.manage_presets_btn.setIcon(qta.icon("fa5s.sliders-h", color=THEME.text_primary)) self.manage_presets_btn.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed) preset_scope_tooltip = ( - "Export the current file with every enabled preset. " - "Use the menu arrow for other scopes." + "Export the selected filmstrip frames with every enabled preset. " + "Use the menu arrow for current frame or all visible frames." ) self.export_presets_group = QWidget() export_presets_row = QHBoxLayout(self.export_presets_group) diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py index 867b5912..c7ce3bd5 100644 --- a/negpy/desktop/view/widgets/tutorial_steps.py +++ b/negpy/desktop/view/widgets/tutorial_steps.py @@ -359,8 +359,8 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]: "pick a colour space, and set resolution or print size. The ICC section adds " "monitor-profile display and soft-proofing.

" "Export Presets save named configurations for one-click batch output — " - "the main button exports the current frame through every enabled preset; " - "the menu arrow exports all visible frames the same way. " + "the main button exports the selected filmstrip frames through every enabled preset; " + "the menu arrow offers the current frame or all visible frames. " "Contact Sheet renders all frames into one sheet. Export always runs at full " "RAW resolution; Export All processes every loaded file." ), diff --git a/tests/test_controller.py b/tests/test_controller.py index 9360f927..56251fdb 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -504,6 +504,97 @@ def test_preset_batch_export_confirmation_message(self, mock_question): self.assertIn("6 files", message) +class TestPresetExportSelected(unittest.TestCase): + def setUp(self): + self.mock_session_manager = MagicMock(spec=DesktopSessionManager) + self.mock_session_manager.state = AppState() + self.mock_session_manager.repo = MagicMock() + self.mock_session_manager.repo.load_file_settings.return_value = None + self.mock_session_manager.state.current_file_path = "/tmp/IMG_0002.cr2" + self.mock_session_manager.state.current_file_hash = "h2" + + self.mock_session_manager.state.uploaded_files = [ + {"name": "IMG_0001.cr2", "path": "/tmp/IMG_0001.cr2", "hash": "h1"}, + {"name": "IMG_0002.cr2", "path": "/tmp/IMG_0002.cr2", "hash": "h2"}, + {"name": "scan.tif", "path": "/tmp/scan.tif", "hash": "h3"}, + ] + self.mock_session_manager.state.export_presets = [ + ExportPreset(name="JPEG", enabled=True, export_fmt=ExportFormat.JPEG), + ExportPreset(name="TIFF", enabled=True, export_fmt=ExportFormat.TIFF), + ] + self.mock_session_manager.state.selected_indices = [2, 0] + + self.visible_indices = [0, 1, 2] + self.mock_session_manager.asset_model = MagicMock() + self.mock_session_manager.asset_model.visible_actual_indices_ordered.side_effect = ( + lambda: list(self.visible_indices) + ) + + with ( + patch("negpy.desktop.controller.RenderWorker") as mock_rw_class, + patch("negpy.desktop.controller.PreviewManager") as mock_pm_class, + ): + mock_rw_class.return_value = MagicMock() + mock_pm_class.return_value = MagicMock(spec=PreviewManager) + mock_pm_class.return_value.load_linear_preview.return_value = (None, (0, 0), {}) + self.controller = AppController(self.mock_session_manager) + + self.controller._validate_preset_paths = MagicMock(return_value=True) + self.controller._run_export_tasks = MagicMock() + + def tearDown(self): + import gc + + for thread in [ + self.controller.render_thread, + self.controller.export_thread, + self.controller.thumb_thread, + self.controller.norm_thread, + self.controller.discovery_thread, + self.controller.preview_load_thread, + self.controller.scan_thread, + ]: + if thread is not None and thread.isRunning(): + thread.quit() + thread.wait() + del self.controller + gc.collect() + + def test_preset_export_selected_uses_display_order_without_confirmation(self): + self.mock_session_manager.state.selected_indices = [2, 0] + with patch("negpy.desktop.controller.QMessageBox.question") as mock_question: + self.controller.request_preset_export_selected() + mock_question.assert_not_called() + + tasks = self.controller._run_export_tasks.call_args.args[0] + self.assertEqual(len(tasks), 4) + self.assertEqual([t.file_info["name"] for t in tasks], ["IMG_0001.cr2"] * 2 + ["scan.tif"] * 2) + + def test_preset_export_single_selection_uses_preview_frame(self): + self.mock_session_manager.state.selected_indices = [0] + self.mock_session_manager.state.selected_file_idx = 2 + self.mock_session_manager.state.current_file_path = "/tmp/scan.tif" + self.controller.request_preset_export_selected() + tasks = self.controller._run_export_tasks.call_args.args[0] + self.assertEqual(len(tasks), 2) + self.assertEqual({t.file_info["name"] for t in tasks}, {"scan.tif"}) + + def test_preset_export_selected_skips_excluded(self): + self.mock_session_manager.state.uploaded_files[0]["excluded"] = True + with patch("negpy.desktop.controller.QMessageBox.question") as mock_question: + self.controller.request_preset_export_selected() + mock_question.assert_not_called() + + tasks = self.controller._run_export_tasks.call_args.args[0] + self.assertEqual([t.file_info["name"] for t in tasks], ["scan.tif"] * 2) + + def test_preset_export_current_frame_menu_unchanged(self): + self.controller.request_preset_export() + tasks = self.controller._run_export_tasks.call_args.args[0] + self.assertEqual(len(tasks), 2) + self.assertEqual({t.file_info["name"] for t in tasks}, {"IMG_0002.cr2"}) + + class TestSessionRestore(unittest.TestCase): def setUp(self): self.mock_session_manager = MagicMock(spec=DesktopSessionManager)