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
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Toolbar buttons, left to right:
* **RGB Scan**: treats the folder as red/green/blue exposure triplets and assembles each frame from three shots, for narrowband trichrome scanning. Right-click a frame → **Edit RGB Triplet…** to assign the three files by hand. An assembled frame carries the three-dot badge described under [Triage](#triage-culling-the-roll).
* **Half Frame**: splits each scan into two frames, for half-frame cameras. Each half is edited and metered separately and badged with which half it is. Enabling it opens a rectangle editor on the current scan: drag the green box to crop (everything outside is discarded), drag the orange line to set the split, and use **Cut thickness** to discard a band centred on the split, which is the physical black separator between the two exposures. The setting is saved and applied to every half-frame split from then on, whatever the scans were acquired with (SANE scanner, camera copy-stand, or folder import). **Adjust Half Frame**, beside Half Frame, re-opens the editor. Auto-detection of the gutter still seeds the initial split position.

Turning Half Frame off does not lose the work: each half you edited keeps its own edit. A half you only looked at is not work, so turning Half Frame on and back off again leaves the scan as it was. A scan you did split comes back as one frame carrying both halves, a **diptych**: half 1 rendered with its own edit, half 2 with its own, joined side by side at the original spacing, with the cut band as a black gap. A scan whose halves hold edits from another folder or an earlier session is not a diptych; it stays one plain frame until you split it again. A diptych carries the both-sides-filled split badge and exports as one file named `<name>-DIPTYCH`. Its controls panel is disabled, because the edits belong to the halves: turn Half Frame back on to change either one. A scan where only one half was worked on uses that half's edit for both sides. The filmstrip thumbnail and contact-sheet tile still show the plain whole scan, so a diptych's thumbnail does not match what it exports. An export size set as a long edge applies to each half, so a diptych comes out about twice that wide.
Turning Half Frame off does not lose the work: each half you edited keeps its own edit. A half you only looked at is not work, so turning Half Frame on and back off again leaves the scan as it was. A scan you did split comes back as one frame carrying both halves, a **diptych**: half 1 rendered with its own edit, half 2 with its own, joined side by side at the original spacing, with the cut band as a black gap. A scan whose halves hold edits from another folder or an earlier session is not a diptych; it stays one plain frame until you split it again. A diptych carries the both-sides-filled split badge and exports as one file named `<name>-DIPTYCH`. Its controls panel is disabled, because the edits belong to the halves: turn Half Frame back on to change either one. A scan where only one half was worked on uses that half's edit for both sides. The filmstrip thumbnail and contact-sheet tile still show the plain whole scan, so a diptych's thumbnail does not match what it exports. An export size set as a long edge applies to each half, so a diptych comes out about twice that wide. To get a plain frame back, right-click the diptych and select **Unsplit diptych**: it renders and exports whole again, and both halves' edits are deleted, so a later split starts from defaults.

Half Frame does not apply to a frame assembled from more than one file: an RGB Scan triplet, a stitch, or an HDR merge. Those are never split, and they never come back as a diptych, even when the file they are built around was worked on as two halves earlier.
* **Apply (clone)**: copy the current frame's settings to selected frames or the whole roll. You choose which aspects in a dialog; crop and rotation are always per-image.
Expand Down
26 changes: 26 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
from negpy.services.assets.half_frame import (
base_hash,
diptych_configs,
forget_split_scan,
half_hash,
half_of,
is_composite,
Expand Down Expand Up @@ -3467,6 +3468,31 @@ def request_unmerge_hdr(self) -> None:
self._pending_scanned_file = paths[0]
self.request_asset_discovery(paths)

def request_undiptych(self) -> None:
"""Turn the active diptych back into one plain scan, deleting both halves' edits.

The scan leaves the split-scan set, so it stays a plain frame until it is split
again. Exported ``.negpy`` half sidecars are left alone.
"""
idx = self.state.selected_file_idx
if not (0 <= idx < len(self.state.uploaded_files)):
return
asset = self.state.uploaded_files[idx]
file_hash = asset.get("hash") or ""
if not asset.get("diptych") or not file_hash:
return
forget_split_scan(self.session.repo, file_hash)
for n in (1, 2):
half = half_hash(file_hash, n)
self.session.repo.delete_file_settings(half)
self._measured_half_rows.discard(half)
asset["diptych"] = False
self._active_diptych_memo = ("", None)
self.session.asset_model.refresh()
if file_hash == self.state.current_file_hash and asset.get("path"):
self.load_file(asset["path"])
self.set_status("Diptych unsplit — the halves' edits are deleted", 4000)

def _select_file_by_path(self, path: str) -> bool:
"""Find a file by path in uploaded_files and select it."""
for i, f_info in enumerate(self.session.state.uploaded_files):
Expand Down
2 changes: 2 additions & 0 deletions negpy/desktop/view/keyboard_shortcuts.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ def _build_actions(self) -> dict[str, Callable[[], None]]:
"toggle_keep": lambda: controller.session.toggle_mark("keeper"),
"hdr_merge": controller.request_hdr_merge_selected,
"hdr_unmerge": controller.request_unmerge_hdr,
# The view method, not the controller's: it carries the confirm the deletion needs.
"half_frame_undiptych": self.window.session_panel.file_browser.prompt_undiptych,
"toggle_reject": lambda: controller.session.toggle_mark("excluded"),
"toggle_compare": controller.toggle_compare,
"rotate_ccw": lambda: toolbar.rotate(1),
Expand Down
1 change: 1 addition & 0 deletions negpy/desktop/view/shortcut_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class ShortcutEntry:
# fire activatedAmbiguously and kills both actions.
"hdr_merge": ShortcutEntry("", "Merge selected exposures into one HDR frame", "Triage"),
"hdr_unmerge": ShortcutEntry("", "Unmerge an HDR frame back into its exposures", "Triage"),
"half_frame_undiptych": ShortcutEntry("", "Unsplit a diptych back into one plain frame", "Triage"),
"toggle_compare": ShortcutEntry("\\", "Before/after split (auto baseline)", "Tools"),
"rotate_cw": ShortcutEntry("]", "Rotate 90° CW", "Geometry"),
"rotate_ccw": ShortcutEntry("[", "Rotate 90° CCW", "Geometry"),
Expand Down
15 changes: 15 additions & 0 deletions negpy/desktop/view/sidebar/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -1139,11 +1139,26 @@ def _build_context_menu(self) -> QMenu:
if active.get("hdr_paths"):
self._add_hdr_anchor_menu(menu, active)
menu.addAction("Unmerge exposures").triggered.connect(lambda: self.controller.request_unmerge_hdr())
if active.get("diptych"):
menu.addAction("Unsplit diptych").triggered.connect(self.prompt_undiptych)
menu.addSeparator()
unload_label = "Unload Selected" if multi else "Unload"
menu.addAction(unload_label).triggered.connect(self._on_remove_from_menu)
return menu

def prompt_undiptych(self) -> None:
"""Confirm before the halves' edits go, then hand the frame back as one plain scan."""
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning)
box.setWindowTitle("Unsplit diptych")
box.setText("Turn this diptych back into one plain frame?")
box.setInformativeText("Both halves' edits are deleted. Splitting the scan again starts from defaults.")
unsplit = box.addButton("Unsplit", QMessageBox.ButtonRole.AcceptRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
box.exec()
if box.clickedButton() is unsplit:
self.controller.request_undiptych()

def _add_hdr_merge_action(self, menu, state) -> None:
"""Merging is for transparencies, so the action follows the film process.

Expand Down
2 changes: 2 additions & 0 deletions negpy/domain/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ def load_file_settings_by_path(self, file_path: str) -> Optional[tuple[str, "Wor

def rehome_file_settings(self, old_hash: str, new_hash: str, file_path: str) -> None: ...

def delete_file_settings(self, file_hash: str) -> None: ...

def save_global_setting(self, key: str, value: Any) -> None: ...
def save_global_settings(self, settings: dict[str, Any]) -> None: ...
def get_global_setting(self, key: str, default: Any = None) -> Any: ...
Expand Down
9 changes: 9 additions & 0 deletions negpy/infrastructure/storage/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ def load_file_settings(self, file_hash: str) -> Optional[WorkspaceConfig]:
return WorkspaceConfig.from_flat_dict(data)
return None

def delete_file_settings(self, file_hash: str) -> None:
"""Delete this hash's saved edit, its undo history and its work prints.

The triage mark stays: a keep/reject is a judgement on the frame, not an edit.
"""
with self._connect(self.edits_db_path) as conn:
for table in ("file_settings", "edit_history", "work_prints"):
conn.execute(f"DELETE FROM {table} WHERE file_hash = ?", (file_hash,))

def load_file_settings_many(self, hashes: List[str]) -> dict[str, WorkspaceConfig]:
"""Saved edits for many hashes in one connection — the search facts for a whole
roll cost one round trip, not one per frame. Hashes with no saved edit are absent
Expand Down
7 changes: 7 additions & 0 deletions negpy/services/assets/half_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ def remember_split_scans(repo: Any, hashes: Any) -> None:
repo.save_global_setting(SPLIT_SCANS_KEY, sorted(new))


def forget_split_scan(repo: Any, file_hash: Optional[str]) -> None:
"""Drop a base hash from the split-scan set, so the scan is no longer a diptych."""
known = split_scans(repo)
if file_hash in known:
repo.save_global_setting(SPLIT_SCANS_KEY, sorted(known - {file_hash}))


def half_hash(file_hash: str, half: int) -> str:
return f"{file_hash}{_SEP}{half}"

Expand Down
15 changes: 15 additions & 0 deletions tests/test_file_browser_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ def test_context_menu_single_selection_items(browser, session):
assert "Apply settings…" in labels


def test_context_menu_offers_unsplit_only_for_a_diptych(browser, session):
session.state.selected_indices = [0]
session.state.selected_file_idx = 0
assert "Unsplit diptych" not in _action_labels(browser._build_context_menu())

session.state.uploaded_files[0]["diptych"] = True
assert "Unsplit diptych" in _action_labels(browser._build_context_menu())


def test_unsplit_diptych_needs_the_confirm(browser):
with patch("negpy.desktop.view.sidebar.files.QMessageBox.exec"):
browser.prompt_undiptych() # no button clicked: rejected
browser.controller.request_undiptych.assert_not_called()


def test_context_menu_multi_selection_uses_export_selected(browser, session):
session.state.selected_indices = [0, 1]
session.state.selected_file_idx = 0
Expand Down
45 changes: 45 additions & 0 deletions tests/test_half_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
detect_split_x,
SPLIT_SCANS_KEY,
diptych_configs,
forget_split_scan,
gap_px,
half_hash,
half_name,
Expand Down Expand Up @@ -306,6 +307,14 @@ def test_remember_split_scans_unions_and_skips_a_known_write(self):
remember_split_scans(repo, {"h"})
repo.save_global_setting.assert_not_called()

def test_forget_split_scan_drops_one_hash_and_skips_an_unknown_write(self):
repo = self._repo({"h#1": WorkspaceConfig()}, split=("g", "h"))
forget_split_scan(repo, "h")
repo.save_global_setting.assert_called_once_with(SPLIT_SCANS_KEY, ["g"])
repo.save_global_setting.reset_mock()
forget_split_scan(repo, "z")
repo.save_global_setting.assert_not_called()

def test_export_filename_is_not_a_half(self):
name = render_export_filename("/x/IMG420.tif", ExportConfig(), composite="DIPTYCH")
assert name.endswith("IMG420-DIPTYCH") and "IMG420_1" not in name
Expand Down Expand Up @@ -461,6 +470,42 @@ def test_a_flagged_negative_skips_the_lookup(self):
assert pair is None and info["hash"] == "ha"
ctrl.session.repo.load_file_settings_many.assert_not_called()

def test_undiptych_forgets_the_split_and_deletes_both_halves(self):
from negpy.desktop.controller import AppController

ctrl = self._controller({"ha#1": WorkspaceConfig(), "ha#2": WorkspaceConfig()})
asset = {"path": "/p/a.tif", "hash": "ha", "diptych": True}
ctrl.state = MagicMock()
ctrl.state.selected_file_idx = 0
ctrl.state.uploaded_files = [asset]
ctrl.state.current_file_hash = "ha"
ctrl._measured_half_rows = {"ha#1"}
ctrl.set_status = MagicMock()
ctrl.load_file = MagicMock()

AppController.request_undiptych(ctrl)

ctrl.session.repo.save_global_setting.assert_called_once_with(SPLIT_SCANS_KEY, [])
assert [c.args[0] for c in ctrl.session.repo.delete_file_settings.call_args_list] == ["ha#1", "ha#2"]
assert asset["diptych"] is False
assert ctrl._measured_half_rows == set() # or a re-meter files a row again
assert ctrl._active_diptych_memo == ("", None)
ctrl.load_file.assert_called_once_with("/p/a.tif")

def test_undiptych_leaves_a_plain_frame_alone(self):
from negpy.desktop.controller import AppController

ctrl = self._controller({})
ctrl.state = MagicMock()
ctrl.state.selected_file_idx = 0
ctrl.state.uploaded_files = [{"path": "/p/a.tif", "hash": "ha"}]
ctrl.set_status = MagicMock()

AppController.request_undiptych(ctrl)

ctrl.session.repo.delete_file_settings.assert_not_called()
ctrl.session.repo.save_global_setting.assert_not_called()

def test_composite_kind_and_summary(self):
from negpy.desktop.session import composite_kind, composite_summary

Expand Down
18 changes: 18 additions & 0 deletions tests/test_storage_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ def test_load_settings_by_path_skips_rows_without_a_path(tmp_path):
assert by_path["/a/1.nef"].metadata.film == "Portra"


def test_delete_file_settings_takes_the_edit_history_and_work_prints(tmp_path):
repo = _repo(tmp_path)
for h in ("h1", "h2"):
repo.save_file_settings(h, _config("Portra"), file_path="/a/1.nef")
repo.save_history_step(h, 0, _config("Portra"))
repo.save_work_print(h, "print", _config("Portra"))
repo.save_file_mark("h1", "keeper", file_path="/a/1.nef")

repo.delete_file_settings("h1")

assert repo.load_file_settings("h1") is None
assert repo.load_history_step("h1", 0) is None
assert repo.list_work_prints("h1") == []
assert repo.load_file_marks() == {"h1": "keeper"} # a triage mark is not an edit
assert repo.load_file_settings("h2") is not None
assert repo.list_work_prints("h2") == ["print"]


def test_file_marks_are_resolvable_by_path(tmp_path):
repo = _repo(tmp_path)
repo.save_file_mark("h1", "keeper", file_path="/a/1.nef")
Expand Down
4 changes: 3 additions & 1 deletion tests/test_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ def test_the_windows_script_switches_cmd_to_utf8_before_the_paths():
"""cmd parses a batch file in the OEM codepage, not the UTF-8 it is written in. The
staging path carries the profile name, so a non-ASCII user name garbles every path
unless the script switches the codepage first."""
script = nsis_script(Path("C:\\Users\\José\\Temp\\Setup.exe"), Path(r"C:\Program Files\NegPy"), Path(r"C:\Program Files\NegPy\NegPy.exe"), 3)
script = nsis_script(
Path("C:\\Users\\José\\Temp\\Setup.exe"), Path(r"C:\Program Files\NegPy"), Path(r"C:\Program Files\NegPy\NegPy.exe"), 3
)

assert "chcp 65001 >nul" in script
assert script.index("chcp 65001") < script.index("José")
Expand Down
Loading