diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py
index 3e69e46a..44db4f25 100644
--- a/negpy/desktop/controller.py
+++ b/negpy/desktop/controller.py
@@ -1527,73 +1527,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._batch_params_for(f)
bounds_override = None
@@ -1614,10 +1573,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)