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
4 changes: 3 additions & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ 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 keeps its own edit. The scan comes back as one frame carrying both, 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. It carries the both-sides-filled split badge and exports as one file named `<name>-DIPTYCH`. A diptych's 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. The scan comes back as one frame carrying both, 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. It carries the both-sides-filled split badge and exports as one file named `<name>-DIPTYCH`. A diptych's 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.

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 either, even where 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.
* **Sheet filter** (funnel): show *All frames*, *Keepers only*, or *Hide rejected*.
* **Sort**: by Name or Date, ascending or descending.
Expand Down
32 changes: 28 additions & 4 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
preset_from_export_config,
resolve_preset_export,
)
from negpy.services.assets.half_frame import base_hash, diptych_configs, half_hash, half_of
from negpy.services.assets.half_frame import base_hash, diptych_configs, half_hash, half_of, is_composite
from negpy.services.export.templating import render_export_filename
from negpy.services.assets.sidecar import load_or_promote, write_sidecar
from negpy.features.exposure.analysis import (
Expand Down Expand Up @@ -361,6 +361,9 @@ def __init__(self, session_manager: DesktopSessionManager):
self.state: AppState = session_manager.state
self._thumb_config: Optional[WorkspaceConfig] = None
self._active_diptych_memo: tuple[str, Optional[tuple[dict, tuple[WorkspaceConfig, WorkspaceConfig]]]] = ("", None)
# Halves already known to hold a real edit; spares _may_persist_measured_bounds a
# repeat lookup per render. Only ever grows, since a row is never deleted mid-session.
self._measured_half_rows: set[str] = set()
self._first_render_t0: Optional[float] = None
self._export_start_time = 0.0
self._export_failures = 0
Expand Down Expand Up @@ -1173,7 +1176,10 @@ def _mark_diptychs(self, assets: List[Dict]) -> None:
One query for the whole roll, at discovery, so every later reader — the filmstrip
badge, the read-only panel, the exporter — finds the answer on the asset dict.
"""
whole = [a for a in assets if not a.get("half") and a.get("hash") and "#" not in a["hash"]]
for a in assets:
if is_composite(a):
a["diptych"] = False
whole = [a for a in assets if not a.get("half") and a.get("hash") and "#" not in a["hash"] and not is_composite(a)]
if not whole:
return
found = self.session.repo.load_file_settings_many([half_hash(a["hash"], n) for a in whole for n in (1, 2)])
Expand Down Expand Up @@ -1322,7 +1328,7 @@ def diptych_pair(self, file_info: dict) -> Optional[tuple[WorkspaceConfig, Works
Half-frame mode being off is implied: with it on the assets already *are* halves,
which `half` on the asset dict reports.
"""
if file_info.get("half") or file_info.get("diptych") is False:
if file_info.get("half") or file_info.get("diptych") is False or is_composite(file_info):
return None
return diptych_configs(self.session.repo, file_info.get("hash"))

Expand Down Expand Up @@ -4760,7 +4766,7 @@ def _on_metrics_updated(self, metrics: Dict[str, Any]) -> None:
new_process = replace(self.state.config.process, **changes)
self.session.update_config(
replace(self.state.config, process=new_process),
persist=True,
persist=self._may_persist_measured_bounds(),
render=False,
record_history=False,
)
Expand All @@ -4776,6 +4782,24 @@ def _on_metrics_updated(self, metrics: Dict[str, Any]) -> None:
self._last_render_identity[2],
)

def _may_persist_measured_bounds(self) -> bool:
"""Whether an auto-measured bounds write may reach the database.

A half must not be brought into existence by a measurement. Looking at one half of a
scan renders it, which meters it, which would file a settings row under `<hash>#1` —
and the mere existence of that row is what later says the scan is a diptych. Turning
Half Frame on and straight back off then leaves the frame stuck as one, having never
been edited. A half the user did edit already has a row, and its bounds keep tracking.
"""
file_hash = self.state.current_file_hash or ""
if half_of(file_hash) is None:
return True
if file_hash not in self._measured_half_rows:
if self.session.repo.load_file_settings(file_hash) is None:
return False
self._measured_half_rows.add(file_hash)
return True

def _on_render_error(self, message: str) -> None:
self.state.is_processing = self._is_rendering = False
self._busy_toast = False # the failure message below replaces the toast
Expand Down
4 changes: 2 additions & 2 deletions negpy/desktop/workers/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -576,10 +576,10 @@ def _expand_half_frames(self, assets: list, profile: dict | None = None) -> list
"""
import os

from negpy.services.assets.half_frame import detect_split_x_for_file, half_hash, half_name
from negpy.services.assets.half_frame import detect_split_x_for_file, half_hash, half_name, is_composite

def _splittable(a: dict) -> bool:
return not (a.get("green_path") or a.get("stitch_paths") or a.get("hdr_paths"))
return not is_composite(a)

if profile is None:
paths = [a["path"] for a in assets if _splittable(a)]
Expand Down
12 changes: 12 additions & 0 deletions negpy/services/assets/half_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
_GAP_FILL = 0.0


def is_composite(file_info: Dict[str, Any]) -> bool:
"""Whether an asset is assembled from more than one file (triplet, stitch, HDR).

Half frame does not apply to these: a composite carries its primary's plain hash, so
without this test a stale ``#1``/``#2`` edit left on that primary by an earlier
half-frame session makes the assembled frame render as a diptych. Single source of
truth for the rule — discovery refuses to split them and the diptych readers refuse
to claim them, so the two cannot drift.
"""
return bool(file_info.get("green_path") or file_info.get("stitch_paths") or file_info.get("hdr_paths"))


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

Expand Down
42 changes: 42 additions & 0 deletions tests/test_half_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,48 @@ def test_mark_diptychs_flags_only_scans_with_half_edits(self):
assert assets[1]["diptych"] is False
assert "diptych" not in assets[2] # a half is never its own diptych

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

# An RGB triplet is {**red, green_path, blue_path}, so it carries the red
# exposure's plain hash. Half edits left on that file by an earlier half-frame
# session must not make the assembled frame render as a diptych.
ctrl = self._controller({"ha#1": WorkspaceConfig(), "ha#2": WorkspaceConfig()})
triplet = {"path": "/p/a_r.cr3", "hash": "ha", "green_path": "/p/a_g.cr3", "blue_path": "/p/a_b.cr3"}
assets = [triplet, {"path": "/p/b.tif", "hash": "ha"}]

AppController._mark_diptychs(ctrl, assets)
assert triplet["diptych"] is False
assert assets[1]["diptych"] is True # same hash, but a plain scan

assert AppController.diptych_pair(ctrl, {"path": "/p/a_r.cr3", "hash": "ha", "green_path": "/p/a_g.cr3"}) is None
assert AppController.diptych_pair(ctrl, {"path": "/p/a.tif", "hash": "ha", "stitch_paths": ["/p/x.tif"]}) is None
assert AppController.diptych_pair(ctrl, {"path": "/p/a.tif", "hash": "ha", "hdr_paths": ["/p/x.tif"]}) is None

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

# Looking at a half renders it, which meters it. That measurement must not file a
# settings row of its own: the row is what later says the scan is a diptych, so a
# Half Frame toggle on and straight off would leave the frame stuck as one.
ctrl = self._controller({})
ctrl.state = MagicMock()
ctrl._measured_half_rows = set()

ctrl.state.current_file_hash = "ha" # whole scan: always persists
assert AppController._may_persist_measured_bounds(ctrl) is True

ctrl.session.repo.load_file_settings.return_value = None
ctrl.state.current_file_hash = "ha#1" # unedited half
assert AppController._may_persist_measured_bounds(ctrl) is False

ctrl.session.repo.load_file_settings.return_value = WorkspaceConfig()
ctrl.state.current_file_hash = "ha#2" # a half the user did edit
assert AppController._may_persist_measured_bounds(ctrl) is True
ctrl.session.repo.load_file_settings.reset_mock()
assert AppController._may_persist_measured_bounds(ctrl) is True
ctrl.session.repo.load_file_settings.assert_not_called() # memoized

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

Expand Down
Loading