diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 3070faeb..3aec6da0 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -3641,7 +3641,17 @@ def request_linear_output_export(self, files: list[dict] | None = None) -> None: if not is_linear_output_supported(file_path): self.set_status("Linear Output is not supported for this file type", 4000) return - files = [{"path": file_path, "name": os.path.basename(file_path), "hash": self.state.current_file_hash}] + # Reuse the asset dict from uploaded_files so RGB-scan triplet (green_path/ + # blue_path) and stitch fields reach _batch_params_for — a bare {path, name, + # hash} dict makes resolve_asset_rgbscan/resolve_asset_stitch reset those + # configs, silently exporting only the primary narrowband exposure. + file_info = next( + (f for f in self.state.uploaded_files if f.get("hash") == self.state.current_file_hash), + None, + ) + if file_info is None: + file_info = {"path": file_path, "name": os.path.basename(file_path), "hash": self.state.current_file_hash} + files = [file_info] supported = [f for f in files if is_linear_output_supported(f["path"])] if not supported: @@ -3919,11 +3929,20 @@ def request_preset_export(self) -> None: 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, - } + # Reuse the asset dict from uploaded_files so RGB-scan triplet (green_path/ + # blue_path) and stitch fields reach _batch_params_for — a bare {path, name, + # hash} dict makes resolve_asset_rgbscan/resolve_asset_stitch reset those + # configs, silently preset-exporting only the primary un-merged exposure. + file_info = next( + (f for f in self.state.uploaded_files if f.get("hash") == self.state.current_file_hash), + None, + ) + if file_info is None: + 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]) def request_preset_export_selected(self) -> None: diff --git a/tests/test_controller.py b/tests/test_controller.py index 5443c840..3ed72238 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1073,6 +1073,148 @@ def test_batch_export_ignores_stale_per_file_export_settings(self): self.assertEqual(t.params.export.export_path, "/tmp/out") +class TestLinearOutputExportCurrentFile(unittest.TestCase): + """Regression: exporting Linear Output for the *current* file (files=None) must + reuse its full asset dict, not a bare {path, name, hash} — otherwise + resolve_asset_rgbscan sees no green_path/blue_path and silently strips the RGB-scan + triplet, so only the primary (red) narrowband exposure gets exported.""" + + 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.uploaded_files = [ + { + "name": "IMG_0001_R.cr2", + "path": "/tmp/IMG_0001_R.cr2", + "hash": "h1", + "green_path": "/tmp/IMG_0001_G.cr2", + "blue_path": "/tmp/IMG_0001_B.cr2", + } + ] + self.mock_session_manager.state.current_file_path = "/tmp/IMG_0001_R.cr2" + self.mock_session_manager.state.current_file_hash = "h1" + + 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.state.current_file_path = "/tmp/IMG_0001_R.cr2" + self.controller.state.current_file_hash = "h1" + self.controller._ensure_valid_export_path = MagicMock(return_value="/tmp/out") + + 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_current_file_triplet_survives_linear_export(self): + with ( + patch("negpy.services.export.linear_output.is_linear_output_supported", return_value=True), + patch("negpy.services.export.linear_output.export_linear_output") as mock_export, + ): + self.controller.request_linear_output_export() + + mock_export.assert_called_once() + rgbscan = mock_export.call_args.kwargs["rgbscan"] + self.assertTrue(rgbscan.enabled) + self.assertEqual(rgbscan.green_path, "/tmp/IMG_0001_G.cr2") + self.assertEqual(rgbscan.blue_path, "/tmp/IMG_0001_B.cr2") + + +class TestPresetExportCurrentFileTriplet(unittest.TestCase): + """Regression: request_preset_export() (the "Export Presets" button's current-file + scope) built a bare {path, name, hash} dict for every call, unconditionally — never + looking up uploaded_files at all. Same failure mode as the Linear Output current-file + bug: resolve_asset_rgbscan/resolve_asset_stitch see no green_path/blue_path and reset + the triplet, so a preset export of the current file silently used only the primary + (red) narrowband exposure instead of the merged RGB.""" + + 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.uploaded_files = [ + { + "name": "IMG_0001_R.cr2", + "path": "/tmp/IMG_0001_R.cr2", + "hash": "h1", + "green_path": "/tmp/IMG_0001_G.cr2", + "blue_path": "/tmp/IMG_0001_B.cr2", + } + ] + self.mock_session_manager.state.current_file_path = "/tmp/IMG_0001_R.cr2" + self.mock_session_manager.state.current_file_hash = "h1" + self.mock_session_manager.state.export_presets = [ + ExportPreset(name="JPEG", enabled=True, export_fmt=ExportFormat.JPEG), + ] + + 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.state.current_file_path = "/tmp/IMG_0001_R.cr2" + self.controller.state.current_file_hash = "h1" + 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_current_file_triplet_survives_preset_export(self): + self.controller.request_preset_export() + + self.controller._run_export_tasks.assert_called_once() + tasks = self.controller._run_export_tasks.call_args.args[0] + self.assertEqual(len(tasks), 1) + rgbscan = tasks[0].params.rgbscan + self.assertTrue(rgbscan.enabled) + self.assertEqual(rgbscan.green_path, "/tmp/IMG_0001_G.cr2") + self.assertEqual(rgbscan.blue_path, "/tmp/IMG_0001_B.cr2") + + class TestPresetBatchExport(unittest.TestCase): def setUp(self): self.mock_session_manager = MagicMock(spec=DesktopSessionManager)