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
151 changes: 85 additions & 66 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions negpy/desktop/view/sidebar/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions negpy/desktop/view/widgets/tutorial_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,8 @@ def _cast_removal(w: "MainWindow") -> Optional[QWidget]:
"pick a colour space, and set resolution or print size. The <b>ICC</b> section adds "
"monitor-profile display and soft-proofing.<br><br>"
"<b>Export Presets</b> 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. "
"<b>Contact Sheet</b> renders all frames into one sheet. Export always runs at full "
"RAW resolution; <b>Export All</b> processes every loaded file."
),
Expand Down
91 changes: 91 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading