Skip to content

Roll workflow, Gear/Metadata reorganization, half-frame detection - #1105

Open
PabloRuizCuevas wants to merge 167 commits into
marcinz606:mainfrom
PabloRuizCuevas:feat/roll-workflow-and-gear-metadata
Open

PabloRuizCuevas wants to merge 167 commits into
marcinz606:mainfrom
PabloRuizCuevas:feat/roll-workflow-and-gear-metadata

Conversation

@PabloRuizCuevas

@PabloRuizCuevas PabloRuizCuevas commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

A session's worth of work across roll handling, gear/metadata organization, half-frame detection, geometry and film-strip UI.

Roll workflow

  • Rolls as a first-class library concept (folder rolls + hand-picked/search-built virtual rolls), with a Roll Settings dialog for tagging gear/process/capture metadata across a frame, a selection or a whole roll.
  • Export destination now defaults to the roll's own folder + an export subfolder (falling back to negpy/<roll name>/export with a warning for a virtual roll with no single folder). A one-time migration drops a still-untouched sticky destination from before this default existed, without touching anyone's deliberately chosen path.
  • Roll Analysis redesigned: the separate "BATCH"/"ROLL" sections merge into one, the roll picker is now searchable with a Current Roll default, and a single primary Apply button replaces the magnifying-glass "Batch Analysis" button that read as a search control. Now sits before Normalization in the Setup tab, since it feeds it a baseline rather than the other way around.
  • Roll Analysis pools each frame's own black/white points into an interquartile-trimmed baseline, then reports which frames were trimmed out of that pool as outliers — the roll average is, by construction, the worst match for exactly those frames, and previously nothing surfaced them. Lock Bounds now genuinely exempts a frame from Roll Analysis (it used to be silently re-enabled on every re-run), and its control moves onto the roll picker's own row, next to what it actually exempts a frame from. The picker now sits below the Analysis Buffer/Region controls that feed it, with a Reanalyze button beside it — the same Batch Analysis the Library's "Analyze Roll…" already offers, as a second, more discoverable entry point.
  • Camera, film stock, ISO and capture date can now be inferred from a roll's folder name (04_om1_Fuji400_Japon → Olympus OM-1 + Fuji 400; 06_scala_50_vietnam → ISO 50), gear matched only against your own library, ISO/date read directly from any plausible number or date in the name. Previously only camera/film stock were inferred, and only reachable indirectly through Roll Settings; now also a button on the Metadata tab.
  • Roll-wide defaults for Calibration, Demosaic, Normalization and Film Mode/Positive: set once on the roll, every member frame follows, with a per-frame Lock that breaks one frame away from the roll's value and releases again on its own once that frame's value matches the roll's. A split button (All Roll / Current Frame / Selected Frames) picks what the next write targets, mirroring Export's own scope control; roll-level vs. frame-level state is now visually unmistakable in the Roll tab.
  • An opt-in per-roll edit fork: a photo shared by more than one roll normally has one edit everywhere it's opened — "Edit Independently in This Roll" (right-click a frame in 2+ rolls) gives it an independent edit for one roll alone; "Use the Shared Edit Again" reverts it. Reuses the half-frame scan's hash-suffix identity trick, so undo history, thumbnails and the render cache pick it up for free.
  • Reset Roll to Defaults, on the Film Strip section's ⋮ menu next to New Roll: resets every frame in the roll the same way Reset Settings already resets one, with a confirm dialog naming the frame count.
  • Renaming a folder roll can now optionally rename the folder on disk too (opt-in, asked fresh every time, with a cloud-sync warning) — previously a rename only ever touched the library's own record.

Exposure

  • Auto Density and Auto Grade now work on a Positive frame (a scanned print, an export from other software, a scanner-positivized negative) too, instead of staying inert the way they used to on the whole transfer path, restated on the transfer curve instead of the paper's toe/shoulder one — but they default off rather than on for it, the same per-shot-neutral convention White/Black Point already follow, since a Positive frame's exposure is an already-settled decision, not a bracket to preserve. A one-time migration applies that default to every frame already marked Positive, and a scoped Reset now restores it correctly instead of the flat always-on default a negative uses.
  • White Point/Black Point now work on the transparency transfer path too, instead of being skipped outright: the offsets are additive and zero at their neutral default, so they deviate the fixed transfer window the same way they already deviate a measured one.

Gear & Metadata

  • Gear tab defaults to the gear you've declared as your own, with the full shipped catalog reachable via a Catalog toggle. Split into two subtabs, Items (cameras, lenses, film stocks, processes, scan setups) and Presets (saved metadata field sets) — these were previously commingled in one category list despite sharing almost nothing structurally.
  • The Metadata tab's own gear combos (camera/lens/film stock/process/scan setup) got the same personal-only-by-default treatment, with an Other… entry to reach the full catalog or add a custom one.
  • Fixed Add Custom leaving a literal "New Camera"/"New Lens" placeholder in the real make/model fields, which rode straight into exported EXIF; the placeholder now lives only in the UI-only display_name.
  • Protect Original Metadata moved from the Metadata tab to the Export tab, next to Sync To Batch — both are export-time behaviors over the Metadata tab's fields, not metadata content themselves.
  • Metadata Preview and Metadata Presets cards are now always expanded (no collapse chevron) — both are small, glance/quick-action content where collapsing cost more clicks than it saved space; the heavier per-category cards stay collapsible.

Half-frame

  • Auto-detect now finds the outer film crop from each edge inward, not just the center gutter, for tight camera-scan captures with no scanner bed.
  • The half-frame toggle auto-detects splits directly when turned on, no dialog.
  • Half-frame mode is now remembered per roll rather than globally.

Geometry

  • Crop by Default: a toggle in Alignment that crops out the wedge Fine Rotation, Tilt and Swing leave behind, so no edge shows replicated/extrapolated pixels. Applies live whenever no manual or detected crop is set; a drawn or detected crop still takes over from it. While a slider is being adjusted, the canvas briefly darkens the margin it would trim, fading out after it settles, the same as Fine Rotation's own alignment grid.

Film strip / UI

  • "New Roll" moved off the toolbar (where it was forcing a wrap to a second line at narrow widths) into a small always-visible actions menu on the Film Strip section's own header.
  • A stale-thumbnail indicator flags a frame whose thumbnail no longer matches its current edit.
  • The Negative Stats panel packs two stats per row instead of one.

Housekeeping

  • A sticky-settings master toggle in Preferences.
  • Normalize's row now travels correctly through Apply Settings (a pre-existing gap in the settings catalog).
  • Force Settings now sweeps stray per-frame locks across the whole roll, and the Roll tab's Apply button grays out when the current scope has nothing to write.

Testing

Every commit passed make all (lint + type + tests) individually; the full combined branch passes the same, plus a full local build + manual launch/smoke check. One pre-existing, unrelated failure throughout (tests/test_metadata_presets.py::TestPresetNames::test_load_tooltip_follows_a_rebinding, a macOS key-symbol rendering quirk that reproduces on unmodified main). CI (Test and Lint, Preview load metrics) is green.

🤖 Generated with Claude Code

PabloRuizCuevas and others added 30 commits September 14, 2026 17:13
Add a Roll Settings toolbar button next to Apply (clone) that opens a
dialog to tag gear, capture, place, process, scanning and roll metadata
across the current frame, a selection or the whole roll in one step.

Groups mirror the Metadata section of settings_catalog.CATALOG, so the
rows handed to session.apply_preset_fields are the catalog's own. Values
start filled from the active frame and can be typed or picked directly,
or filled from a saved metadata preset via a Load shortcut in the dialog.

Extract GranularSettingsDialog's scope-row builder into a shared
build_scope_row/ScopeRadios so both dialogs use one implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Split the single Files toolbar into two: the top OverflowBar keeps
Library, Add Files, Add Folder, Clear All, Hot Folder, Sheet filter and
Sort (folder actions and filters, usable with no roll loaded); a new
OverflowBar inside the Film Strip section, mirroring LibraryTree's own
header row, holds Trichrome Scan, Half Frame, its menu, Apply (clone)
and Roll Settings -- the actions that read or rewrite the loaded roll.

Roll Settings now defaults its scope to the whole roll instead of the
current frame, falling back to current only when nothing else is loaded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the fixed 40/60 QVBoxLayout stretch between the Library and
Film Strip sections with a QSplitter, like the right panel's own
Analysis/Tabs splitter, so the boundary can be dragged. The split
persists across sessions (session_sections_splitter_sizes).

Each section can still collapse independently: a collapsed pane is
fixed to exactly its header height and the other pane takes the rest,
restoring the last size when it reopens. A QSplitter's own resize
handling redistributes new space by stretch factor, and that factor is
0 for both panes whenever both are collapsed, so nothing then keeps
them pinned to their header across a later window resize -- files.py
now reasserts the split explicitly on every resize.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both act on the loaded frames (keep/reject marks, unloading), not the
folder tree, so they belong with Trichrome/Half Frame/Apply/Roll
Settings rather than in the top toolbar. The top toolbar keeps Library,
Add Files, Add Folder, Hot Folder and Sort -- the actions that make
sense with nothing loaded yet, or (Sort) that apply to the Library tree
as well as the Film Strip.

Hot Folder stays at the top for now: it does not persist per folder,
it watches wherever the last-loaded frame's path points, so it has no
real tie to a Library folder selection today. A later change can make
it a genuine per-folder watch and move it into Library at that point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…moves to Film Strip

The toolbar button no longer falls back to clearing the whole roll when
0-1 frames are selected -- that silently wiped everything on what looks
like a routine click, since a single selected frame (the common state:
clicking any thumbnail selects it) hit the same branch as no selection
at all. It now always calls the same path as the context menu's
Unload...: the active frame, or the full selection when more than one
is picked. Opening a different roll already replaces the film strip, so
a full Clear All has no real use mid-roll; it stays reachable from the
empty-space context menu, unchanged, for the rare "back to empty" case.

The thumbnail size slider moves from the shared search row into the
Film Strip section's own tally row, next to "N frames": it only ever
resizes that grid, so it has no reason to sit above the Library tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sections_splitter had a fixed layout stretch of 1, so when both Library
and Film Strip were collapsed it was still stretched to the layout's
full leftover height -- its own size bookkeeping showed only one
section's header height, and the real leftover space landed above the
splitter instead of below it. Capping the splitter's own maximum height
to the sum of both headers plus the handle, mirroring the per-pane cap
already in place, keeps it snug under the search row; a trailing
addStretch absorbs the true leftover, matching the pre-splitter layout.

Also moves Hot Folder into the Film Strip row: it has no real tie to a
folder selected in Library (see the prior commit), and belongs with the
loaded frames it feeds instead of the folder-browsing row above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… default

Both load pictures into the open session -- the Film Strip -- not the
Library tree, so they belong in its row with the other actions that
populate or edit it, next to Hot Folder rather than above both
sections. Their tooltips now say what they actually do ("Add pictures
to this session" / "Load every image in a folder into this session"),
distinct from Library's own + ("Add a library folder"), which does not
load anything.

The default split also now favors Film Strip more heavily (20/80
instead of 40/60): the tree is glanced at occasionally to find a roll,
while the sheet is where the work happens and wants the room. Still
just a starting point -- the splitter handle moves it either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two icons for two pickers doing the same job -- put pictures in this
session -- rather than two distinct actions. One Add button with an
instant-popup menu (Add Files.../Add Folder...) matches the existing
Sheet filter/Sort pattern. The empty-space context menu keeps them as
separate entries, since a menu has no icon-crowding problem to solve.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…brary's corner row

Every remaining top-level action now lives in the row of the section
it acts on. Library and Sort join LibraryTree's own +/refresh corner
row, resized to that row's 20x20 mini-button convention instead of the
taller toolbar-button one; LibraryTree takes them as leading_widgets so
the wiring (library_requested, the sort state shared with Film Strip)
stays where it already lived, in FileBrowser. The search row above both
sections is untouched -- it stays general, filtering the Film Strip and
searching the whole library alike.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… hand

Adds negpy/services/assets/rolls.py, mirroring composites.py: one
global-setting-backed dict, no new DB table. A folder becomes a
recognized ("green") roll the first time it is opened, replacing the
default amber; a roll that is not a folder (a library search's
results, a hand-picked selection) is named and kept via Save as Roll
in the Film Strip's row, showing up blue in a new Rolls list above the
folder tree in Library. Files added to the session while a roll is the
one loaded join its membership, so reopening it later still shows them.

Edits stay keyed purely by content hash, unchanged -- a roll is a
navigation layer, not an edit-identity change. The same photo in two
rolls is the same edit in both, exactly like opening it from two
different folders today. Per-roll edit variants are a distinct, later
decision, not part of this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t mechanism

The last pass added Rolls as a second list above the folder tree: folders
colored amber/green, virtual rolls in a separate plain QListWidget. Two lists
with two row styles for one concept read as broken, not rough (reported
against the real app with a screenshot).

The Library section now lists every roll -- folder and virtual alike -- in
one flat list, one row style (icon, name, live count). A folder is only ever
an import source: "Import Folder as a Roll..." recognizes and opens one;
"Import Subfolders as Rolls..." recognizes every immediate subfolder of a
chosen parent in one pass, without opening any of them. The same photo can
already belong to more than one roll -- a folder roll's membership and a
virtual roll's member_paths are independent lists, nothing to change there.

library_roots stays, invisibly, as cross-library search's scope, maintained
by importing instead of a separate "add root" UI. Retired: the tree's live
filesystem drill-down (lazy subfolder expansion, reveal, select_parent,
primary_root), the amber "not yet opened" state (nothing is ever half
imported now), and Alt+Up "go up one library folder" (no hierarchy to
ascend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…in the tally

Right-clicking a multi-selection in the Rolls list only deleted the row under
the cursor. The context menu now checks whether the clicked row is part of
the current selection: if more than one is selected, it offers a single
"Delete N Rolls..." action (confirm_delete_several, new) instead of Open/
Rename/Delete for just that row.

New Roll (folder-plus icon, leftmost in the Film Strip row): the same
confirm-and-clear as Clear All, exposed as a deliberate, visible action for
starting a roll entirely by drag-drop, distinct from Unload (selection-only).

The Film Strip's tally now leads with the active roll's name when the loaded
frames came from one, e.g. "Portra 400 -- 36 frames", so which roll is open
is visible without switching to the Library section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…port

Import Folder as a Roll now matches the folder's name against the user's own
gear library -- cameras and film stocks only, matched as shared words
("gold" against Kodak Gold 200) or squashed abbreviations with no delimiter
("penf" against Olympus Pen F, "trix" against Kodak Tri-X 400). Nothing is
ever invented from free text: a match has to land on gear already in the
library, and two candidates matching the same field is treated as no match.

Fires once, on first recognition of a folder (not on reopening a roll
already known), and only for the single-folder import -- bulk subfolder
import stays silent, since popping one dialog per newly recognized
subfolder would be modal spam and an unreviewed multi-roll guess is a real
risk. When something matches, Roll Settings opens pre-filled and ticked for
just those fields; Cancel discards the suggestion, Apply writes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Opening a file with no saved edit picks up a broad set of sticky settings
from the last-saved edit, including real look adjustments (Chroma, CLAHE,
Sharpening, Cast Removal, Auto Exposure, ...), not just rig/roll facts.
There was no quick way to turn that off without unticking every row in
Persistent Settings one at a time.

Add `sticky_settings_enabled` (default on, so existing behavior is
unchanged), a new AppState field persisted the same way as sticky_zoom.
It gates only the picker-driven overlay in _apply_sticky_settings's
only_global=False branch (load_sticky_rows / sticky_cfg /
apply_selected_fields, plus the description-fields carry that rides on
those rows) for a freshly opened, never-edited file. Off, every catalog
row - including the rig-ish ones like process mode, crop ratio and flips
- stays at WorkspaceConfig() defaults; the user's row picks in
STICKY_ROWS_KEY/STICKY_CONFIG_KEY are untouched, so switching back on
restores exactly what was ticked. Untouched either way: EXPORT_REMAINDER,
the flat-field profile overlay, the Kelvin roll-locks, ALWAYS_STICKY_PROCESS
and the only_global=True branch that syncs export/metadata onto an
already-edited file - none of those are a transformation to the picture.

The switch lives as a "Carry settings between frames" checkbox right next
to the Persistent Settings… button in Preferences → Session & Storage,
which it also disables when off. Also registered as an unbound shortcut
action (toggle_sticky_settings) alongside its sibling Sticky Zoom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The folder-name match only ever fired once, automatically, right when a
folder was first recognized as a roll -- reopening Roll Settings later (the
tag-icon button) never offered it, even if Gear was still untagged.

Both paths now share one detection step. The automatic popup still stays
silent when nothing matches (checked before the dialog is even built, so a
folder with nothing to suggest never pops one up); the manual button always
opens as before, and silently pre-fills a match too when Gear is not already
set. Matching prefers the active folder roll's own path, falling back to the
current frame's containing directory so it still works from a plain Add
Files/Add Folder load with no roll active at all. Never overwrites a camera
or film stock already tagged, from a prior suggestion or set by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An already-set camera (carried from elsewhere, tagged by hand) was blocking
a film-stock suggestion too, since the guard treated "something is already
tagged" as one all-or-nothing condition. A real case: 08_penf_gold_200_
marbella correctly has Kodak Gold 200 sitting right in the film-stock list,
but nothing offered it, because an unrelated camera already carried a value.
Now each field is checked on its own -- an existing camera no longer blocks
an otherwise-free film-stock match, or the reverse.

Also drops the Library corner-row book icon: it duplicates Ctrl+L and the
adjacent + import button, and is invisible in the one state (an empty or
collapsed section) its own "reveal" behavior would matter in. Ctrl+L and
the unload-last-frame auto-reveal are unaffected -- both call the panel
method directly, never through this button.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
detect_split_x took the midpoint of the whole band that deviated from a
local background -- correct for a clean gutter, but a smooth in-scene
gradient blending into one edge (an overexposed sky) widens that band on
one side only, dragging the midpoint toward it. The gutter's own edges are
now found directly, as the steepest slope on each side of the rough peak,
refined to sub-pixel precision by a parabola through each edge's neighbors,
and the true center is the midpoint between those two edges instead.

Renamed to detect_gutter and returns (split_x, gutter_thickness): the edges
it now finds also measure the band's own width, previously never detected
at all -- Auto-detect only ever moved the split line, leaving Cut thickness
wherever it last was. detect_split_x stays as a thin wrapper for callers
that only want the position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A divider between two tab rows in one panel doesn't read as "two different
things" strongly enough -- it's still one panel, one window, just visually
creased. Export, Metadata and Scan move out of Controls entirely into their
own dock ("Roll"), the same way Session and Controls already work: a
PinnableDockWidget that floats on drag and shows a pin button to re-dock,
with its own geometry, visibility and tab persisted like the other two.

RollPanel (negpy/desktop/view/sidebar/roll_panel.py) is everything the
bottom row used to build in RightPanel, lifted out whole: the three
sidebars, their own tab switcher and stack, the sync-debounce wiring, the
scan-activation hook. RightPanel goes back to a single switcher over the
7 tabs that actually change the render (Setup, Geometry, Exposure, Color,
Finish, Favorites, History); Analysis stays there too, since it describes
the current render, not roll bookkeeping.

Docked below Controls by default (splitDockWidget), with its own toolbar
toggle button, shortcut (toggle_roll_panel), visibility flag
(panel_roll_visible) and tab index (roll_panel_tab), folded into Reset
Panel Layout alongside the other two. Everything that pointed at
right_panel.export_sidebar/metadata_sidebar/show_tab_by_key("export"|
"metadata"|"scan") -- keyboard shortcuts, the tutorial steps -- now points
at roll_panel instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A bulk apply (roll bake, apply-to-roll, apply-to-selection) writes a new
config straight to a non-active file's history (push_external_history) --
no render runs for it, so its cached thumbnail keeps showing the old look
until something else regenerates it. Nothing on the sheet said so.

AppState.stale_thumbnails tracks which cached bitmaps are in that state,
set in push_external_history and cleared wherever a thumbnail actually gets
rebuilt or the session resets. The contact-sheet delegate marks a stale
thumbnail with a small amber dot, top-left -- the one badge corner not
already in use -- with a tooltip line explaining it and pointing at the
fix (open the frame).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… them

A vertical split left both panels too short to be useful -- Roll barely
had room to show one control, Controls lost real working height to a
second panel most sessions barely touch. Tabbed instead, with the tab
strip pinned to the column's own north edge (Qt's default tab position is
south, which buried the Controls/Roll switch below a full scroll of
sliders). Controls stays the one raised on startup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… as flat tabs

Two docks tabbed together read as more separation than the split actually
needed, and buried Export/Metadata/Scan one level deeper (dock tab, then
panel tab) than Controls. Back to one dock, with Controls renamed Edit and
folded in as one entry of its own tab row, alongside Export, Metadata,
Gear and Scan as plain sibling pages -- everything one click away.

RightPanel now owns a flat outer switcher (_group_buttons/_group_keys/
_switch_group) over five tabs; Edit wraps the pinned Analysis section and
the inner Setup/Geometry/Exposure/Color/Finish/Favorites/History switcher
verbatim, unchanged from before the Roll dock existed. Export, Metadata,
Gear and Scan carry no Analysis section of their own, matching how they
behaved as a separate dock. show_tab_by_key and reveal_section resolve
against both tab levels, so a shortcut or tutorial step naming an inner
key (e.g. "geometry") switches to Edit first. roll_panel.py, the second
dock, its toolbar button, shortcut and visibility flag are gone.

Gear: the metadata library ("Manage..." in Metadata, a modal dialog) is
now its own persistent tab (GearLibraryPanel, gear_library_panel.py,
renamed from GearLibraryDialog). It is built once at startup rather than
per click, so "save preset from the current frame" reads a live getter
(current_config_fn) instead of a snapshot taken at construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
e6_normalize had no SettingRow, so Apply Settings, Apply Preset and every
metadata/edit preset silently skipped it -- there was no way to batch it
onto a selection or a whole roll, only set it one frame at a time. Its
sibling Positive already has a row; Normalize gets the same one, sticky
like Mode and Positive since all three are the same capture-mode decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Turning Half Frame on used to pop the rectangle editor before splitting
anything -- now it just runs the same batch auto-detect Auto-detect All
Splits does, directly, no dialog. The editor stays reachable from the
tune-icon menu (Adjust Split...) for the odd frame the batch gets wrong.

The toggle was one global flag shared by every roll. Now each roll
remembers its own (half_frame_mode_by_roll, keyed by the real roll id from
rolls.py); an ad hoc session with no recognized roll keeps the single
sticky flag it always had. AppController.half_frame_mode_for_roll() is the
one place that resolves which applies, read both by request_asset_discovery
(so a roll always splits into what it was left as) and by the toggle's own
displayed state. A new roll saved from an ad hoc session
(create_roll_from_session) seeds its own entry from that session's current
value, so saving a roll never silently resets the toggle for it.

half_frame_mode_changed (mirroring the existing rgb_scan_mode_changed) fires
wherever the active roll changes -- open_roll, open_library_folders, a
library search landing on ad hoc results -- so the button follows without
re-toggling itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
NegativeStatsWidget laid out one stat per grid row (name|value), which
claimed more vertical height than the right sidebar can spare. Pair
consecutive StatRow entries two at a time (name|value|name|value)
across a 5-column grid, with a thin THEME-colored vertical divider
between the two halves.

Per-stat tooltips, warn/error value styling and the hide-rather-than-
blank behavior for unused slots stay independent per stat; the divider
for a pair hides too when its second slot is empty (an odd row count).
_names/_values/_ROWS keep their existing flat, row-ordered semantics,
so tests/test_repair_coverage.py's two NegativeStatsWidget tests pass
unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auto-detect All Splits (and the toggle, which runs it on turning on) only
ever found split_x -- the crop rect it saved was whatever was already
there, defaulting to the full uncropped scan. The scanner bed / holder
around the film was never trimmed by auto-detect at all, only the gutter
between the two frames, which is why the edges stayed loose after
detecting.

detect_split_and_crop_for_file (half_frame.py) decodes the file once and
runs two independent detectors on the same buffer: the existing 1-D
gutter heuristic for split_x, and detect_film_crop -- a thin wrapper
around get_autocrop_coords(mode=FILM, ratio=Free), the same single-frame
film-bounds detector the Geometry tab's own Auto Crop already uses.
FILM mode keeps the rebate/sprockets a two-up diptych's outer crop must
not cut into; a forced aspect ratio would be wrong for a two-up frame.

process_auto_detect_all_splits (render.py) and _on_splits_detected
(controller.py) now carry (split_x, crop_rect | None) pairs instead of a
bare float; a file whose crop detection fails keeps whatever crop it
already had, only the split moves for it. The per-frame rectangle
editor's own Auto-detect is unchanged (split-only) -- it already has a
crop the user drags by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ame-toggle-autodetect

# Conflicts:
#	docs/USER_GUIDE.md
#	tests/test_half_frame.py
feat/half-frame-auto-detect-accuracy gave the per-frame rectangle editor's
own Auto-detect button sub-pixel split_x and gutter thickness
(detect_gutter), but left the batch path (Auto-detect All Splits, and the
toggle that runs it) on the old split-only detect_split_x_for_file --
merged in here, so this branch had it, without the batch benefiting.

detect_split_and_crop_for_file now calls detect_gutter instead of
detect_split_x, returning (split_x, gutter_thickness, crop_rect) instead
of just (split_x, crop_rect); _on_splits_detected applies all three to
each file's override. Thickness comes free out of the same computation
that already finds split_x, so this costs nothing extra to detect --
only the plumbing to save it was missing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The panel kept its dialog-era three-pane layout (category list, item
list, detail form side by side) after the previous commit turned it into
a persistent ~300px sidebar tab, squeezing the detail form to a sliver
and truncating its field labels. Category picking is now a combo box
above a full-width item list and a stacked (label-above-field) detail
form, the same single-column idiom every other sidebar tab uses. The
add/duplicate/edit/delete row uses icon_button instead of bare
QPushButtons, and install_wheel_guards keeps an idle scroll from
dragging a combo's value along with it now that the panel lives in a
scroll area.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Its own Auto-detect button (HalfFrameDialog) only ever ran detect_gutter --
the rectangle stayed whatever it already was, same gap the batch path had
before the previous commit. A user pressing it expected the same result
the toggle gives, since both call themselves "Auto-detect".

_on_auto now also runs detect_film_crop and sets the rectangle from it.
Fixed alongside: split_x is relative to the cropped width (slice_half's own
convention), so the gutter search has to run inside the newly detected
crop, not the full uncropped scan -- detect_split_and_crop_for_file had
the same bug (detecting the gutter and the crop independently, on the same
uncropped buffer), fixed there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…xport

The Metadata tab had no header identifying it, its Preview scrolled away
with everything else instead of staying visible, and nothing said its
fields are per-frame. A "Metadata" title label and a scope hint now lead
the panel, matching how Export's Output Intent box identifies its own
group. The Preview is pinned above the per-frame cards, which now scroll
in their own QScrollArea below it -- the same pinned-section-over-scroll
pattern Edit uses for Analysis; right_panel.py exempts the Metadata tab
from its outer wrap_scroll() the same way it already exempts Edit.

Sync custom metadata to all files in batch export only takes effect
during a multi-frame export and never describes the current frame's own
metadata, so it moves to the Export tab, beside the Export button and
scope menu. The underlying MetadataConfig.sync_to_batch field is
unchanged; only the checkbox's home moves. It still disables under the
Metadata tab's Protect Original Metadata (protecting ignores gear/process
fields, so syncing them would do nothing) via a new protect_toggled
signal MetadataSidebar emits and RightPanel wires to ExportSidebar, since
a render=False config update doesn't reach the other panel through
config_updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PabloRuizCuevas and others added 28 commits September 17, 2026 21:06
…/Grade on Positive

Wires "film" (Film Mode, Positive) into ControlsPanel as a fourth,
always-expanded Roll-tab section beside Calibration/Demosaic/
Normalization, with the same lock badge and Apply wiring the other
three already have.

Also fixes a real bug this surfaced: Tone's scoped reset restored
Auto Density/Auto Grade to ExposureConfig's own flat default (on)
regardless of Positive, so resetting a Positive frame turned them
back on instead of to the value auto_meter_for_positive_source would
give it -- the same mismatch the modified-count dot had, flagging a
Positive frame's own correct (off) state as "modified" from a default
that was never the applicable one. _default_exposure_field is the
single place both now ask.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…alue

_lock_roll_card always locked on any edit and never unlocked, so a
value changed away and then back to what the roll already says stayed
marked This Frame Only forever, with Apply staying clickable for a
card that no longer actually differs from anything. It now compares
the active frame's current value against the roll's own stored
default for every field in the card: locks when they differ, unlocks
when they match, and does nothing if the lock state already agrees --
same tail for set_roll_default and set_process_mode/set_positive_source.

A field the roll has never set at all cannot "match" by this rule --
there is nothing yet to differ from, so treating that as a match would
hide a card's first-ever edit from Apply until every one of its fields
happened to get a roll default from somewhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Type a plain-language description in the Files sidebar and rank the
currently loaded frames by how well they match, instead of field:value
terms. Off by default; turning it on in Preferences downloads a small
quantized ONNX CLIP model (Xenova/clip-vit-base-patch32) on first use.

- negpy/services/assets/semantic_model.py: model download (resumable,
  atomic per file), ClipModel (lazy ONNX sessions, embed_image/embed_text).
- negpy/services/assets/clip_tokenizer.py: CLIP's BPE tokenizer, with the
  vocabulary derived deterministically from merges.txt alone.
- negpy/infrastructure/storage/repository.py: image_embeddings table,
  keyed by file_hash and model_version so a model swap can't score a
  frame against a vector from the retired model.
- negpy/services/assets/embeddings.py + desktop/workers/embedding.py:
  EmbeddingWorker, mirroring ThumbnailWorker's shape. Shares the
  thumbnail thread and runs right after a thumbnail batch finishes, so
  each embedding reuses that batch's own cached preview instead of a
  second RAW decode.
- desktop/controller.py: generate_missing_embeddings (DB-cache-checked,
  batch-lane gated) and embed_search_query for the live search box.
- desktop/session.py: AssetListModel.set_semantic_query ranks by cosine
  similarity, excluding frames with no cached embedding yet rather than
  scoring them zero; AppState.semantic_search_enabled / embeddings.
- desktop/view/sidebar/files.py: a fourth search-row toggle, visible
  only when the feature is on, mutually exclusive with regex mode.
- desktop/view/widgets/preferences_dialog.py +
  semantic_download_dialog.py: the opt-in checkbox and a first-run
  download dialog built on update_dialog.py's DownloadWorker idiom.
- pyproject.toml / build.py: bundle onnxruntime (small; the ~150MB of
  model weights stay download-on-demand, not bundled).

Verified against the real model: downloaded the actual ONNX files,
confirmed the vision/text tower input names, ran real inference on
real photos and text queries, and ran the whole pipeline (thumbnail
batch -> embedding batch -> search ranking) through the real
AppController on real QThreads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…86_64 wheel

1.24.0 onward dropped the macOS x86_64 build entirely (confirmed against
PyPI's own file listing for every release through 1.30.0), so make run
fails outright for an Intel Mac -- not just for anyone touching search by
meaning, since dependency resolution runs regardless of whether the
feature is ever turned on. 1.23.2 still covers macOS (arm64 + x86_64),
Linux (x86_64 + aarch64) and Windows; re-verified real CLIP inference
(image and text embeddings, correct shape and norm) against the already-
downloaded model on this version.

Reported by the per-roll-edit-fork session from a user's Intel Mac.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Search by meaning previously only ranked the currently loaded roll. This
extends it to every folder registered in library_roots, matching the
existing keyword whole-library search's own scope.

- image_embeddings gets a file_path column (repository.py), so a stored
  vector can be opened without ever having been -- the in-session case
  already had the hash-to-path mapping in memory, a library-wide one
  doesn't.
- LibrarySearchWorker.scan_for_indexing (workers/library.py): walks
  library_roots (sharing the walk cache a keyword search already pays
  for) and hashes every file with the same bounded, no-RAW-decode
  fingerprint AssetDiscoveryWorker uses.
- EmbeddingWorker.cancel() + is_cancelled threaded through
  generate_batch_embeddings: the in-session batch never needed a cancel
  path, but a whole-library pass can run long enough to want one.
- AppController.index_library(): an explicit, cancellable, resumable
  action (never automatic) -- decoding and embedding a whole library is
  real work, so nothing runs unless asked, and indexing again after a
  cancel or a later import only processes what's still missing.
  request_library_semantic_search() mirrors the keyword library search's
  own hand-off to request_asset_discovery. The shared EmbeddingWorker is
  now driven by two callers, so the batch-lane bookkeeping (_apply_embeddings,
  _on_embeddings_finished/_on_embedding_batch_error, abort_active_batch)
  tracks which one owns the running batch.
- semantic_model.rank_by_similarity: the cosine-similarity-plus-threshold
  rule AssetListModel's in-session ranking already had, factored out so
  the whole-library ranking can't drift from it.
- library_tree.py: an Index Library button beside the refresh button,
  visible once the feature is on and the model is downloaded.

Verified against the real model and real files: a two-folder test
library, indexed end to end through the actual AppController on real
QThreads (not mocked), confirmed each embedding batch ends under the
correct batch owner, a re-run finds nothing missing and skips the
embedding pass entirely, and a semantic query with nothing open this
session still finds and opens the right files from both folders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MIN_SIMILARITY (a fixed 0.2) let through 80-99% of a real library
regardless of query -- verified against a 4009-embedding library,
"boat"/"car"/"dog" all cleared it for the vast majority of frames.
CLIP cosine similarities cluster tightly around a baseline that
shifts with the query and the library, so no fixed constant separates
a real match from noise across all of them.

rank_by_similarity now keeps only candidates scoring at least
SIMILARITY_Z_SCORE standard deviations above the query's own mean
score among the candidates given, so the cutoff adapts to each
query's own score spread instead of an absolute number. Verified
against the same real library: "boat" drops from ~3400/4009 results
to 50, in the same range as several other test queries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-off

request_library_semantic_search discovers files that already have a
cached embedding by construction (that's how it found them), so
generate_missing_embeddings's missing list comes back empty and it
returned without ever refreshing the model. An in-session semantic
filter left active from before the hand-off excludes any file absent
from state.embeddings (by design, not zero-scored), so the film strip
stayed stuck at 0 of N frames -- the newly cached embeddings existed,
nothing ever told the view to look at them again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iling

The previous fix (b19492a) only helped once _on_thumbnails_finished
actually fired. It doesn't when generate_missing_thumbnails claims no
batch -- exactly the case for a whole-library search's own matches,
which already have their thumbnails cached from the indexing pass
that embedded them. No batch means no _on_thumbnails_finished, which
is the only other caller of generate_missing_embeddings, so the film
strip stayed stuck at 0 of N with a stale in-session semantic filter
never seeing the (already-DB-cached) embeddings for the new files.

_on_discovery_finished now calls generate_missing_embeddings directly
in both places it already detects "no batch claimed" for the
hot-folder flag, the same signal meaning no _on_thumbnails_finished
is coming either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… hand-off

Reproduced end to end against a real copy of a user's database (not
mocks): after a whole-library search's discovery hand-off finished --
all 41 matches loaded, all 41 embeddings correctly cached -- the film
strip still showed 0 of 41. The prior two fixes (b19492a, 5ea6804)
were both real but insufficient; this is the actual cause.

rank_by_similarity already picked these 41 out as the standouts
against the whole ~4000-file library. The search box's own in-session
toggle (set_semantic_query, from the sidebar's live filter) was still
active from typing the query moments earlier, and re-ran the same
z-score outlier check again once discovery refreshed the model -- this
time against just the 41 already-selected, now mutually similar
matches. An outlier check has no background left to separate a match
from in a set that's already nothing but matches, so it can (and did)
exclude most or all of them.

request_library_semantic_search now clears the in-session query before
handing off, since its own ranking already is the filtered result.
Verified against the fix by replaying restore_session() +
request_library_semantic_search() end to end on a copy of a real
edits.db/settings.db through a live Qt event loop, not just unit mocks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nd-off

The search box's live in-session filter debounces 200ms after the last
keystroke (filter_timer). Pressing Enter or clicking the library-search
icon fires immediately and doesn't cancel it, so a keystroke just
before either one leaves the debounce running. If the hand-off (often
sub-second, since a whole-library search's matches are already
indexed) finishes before that 200ms elapses, the stale debounced
_apply_filter fires afterward and reapplies the same in-session query
db84678 already clears -- right back onto the fresh results, excluding
most or all of them again. search_library now stops the timer first,
the same guard _clear_frame_filters already uses for the same reason.

Also raises SIMILARITY_Z_SCORE from 2.5 to 3.0: measured against a
real library across a spread of plain-word queries, 2.5 still read as
barely narrowed at all; 3.0 cuts every one of them to a small,
non-empty set without any going to zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tive

A library-wide search's matches (or a restored session with no single
shared roll) leave active_roll_id unset for the whole session, so the
roll-defaults overlay was a pure no-op -- every fresh file fell back
to sticky settings' "last mode used anywhere," which has nothing to do
with this specific frame. Mixing a slide into a batch just after
working on negatives renders it inverted; the actual cause was never
the roll-wide-defaults mechanism, which was correctly inert.

_overlay_roll_defaults now falls back to whichever real roll this
one file's path belongs to when nothing is active for the session, so
it still gets its own roll's already-correct film process instead of
a global guess. A folder roll wins over a virtual one for the same
path, since it is the file's actual physical home.

Exposed a latent test-double mismatch in test_composite_inheritance.py:
several get_global_setting mocks used `d=None` where the real call
site passes `default=` as a keyword, silently working only because
nothing had called through that path in those tests before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Half Frame was all-or-nothing at the discovery-task level: one boolean
decided whether every file in the batch got auto-detected and split.
For a real, single roll that is the right fact (one film type, split
or not). For a batch with no single active roll -- a library-wide
search's mixed results, a restored session with no shared roll -- that
boolean fell back to the one sticky flag an ad hoc session always had,
which has nothing to do with any specific file in it: a "cat" search
matching one half of a diptych could bring its unrelated sibling half
along too, or a plain full-frame photo could get split for no reason
if the sticky flag happened to be on from browsing a half-frame roll
moments earlier.

_expand_half_frames gains only_hashes: restricts splitting to files
already confirmed diptychs (half_frame.split_scans), each auto-detecting
its own gutter or using its own saved override -- never a roll-wide
toggle, never a blind guess on a file never split before.
request_asset_discovery switches to this path whenever no roll is
active, instead of the sticky flag.

The Film Strip's Half Frame toggle is a roll-wide fact with nothing to
apply to a batch that is not one roll, so it disables itself there
(already-confirmed diptychs in it still show split); open the roll
itself to change it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tych set

split_scans() is written by remember_split_scans() any time a roll's Half
Frame toggle was on at discovery, including a blind default center-cut on
ordinary full-frame photos -- not a verified per-file fact. Trusting it to
decide splitting for a batch with no single active roll (a library-wide
search's mixed results) center-cut files that were never diptychs. Nothing
splits there now; the toggle stays correctly disabled and splitting stays a
roll-wide fact applied only when a roll is actually open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The whole-library indexer walks every physical file with no half-frame
awareness, so a diptych keeps its whole-scan embedding (unrotated, both
subjects at once) even after it is split and its two halves get their own,
better embeddings. About 30% of the real library's embedding pool was this
kind of stale duplicate, crowding out or replacing the clean per-half match
a query should have surfaced. Excluded at ranking time whenever a hash's own
half-hash companions are actually embedded too -- a real per-file fact,
unlike split_scans() which only reflects whatever roll's toggle was on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Verified against real, previously-misidentified images from the user's own
library: patch16 widens the mean true-match vs. false-positive score gap by
about 65% over patch32 (0.031 -> 0.051), same 512-dim embedding, same
tokenizer and preprocessing, same ~150MB download -- only cost is slower
per-image inference (still a few milliseconds). The model cache directory is
now named by MODEL_VERSION so a retired model's download can never be
mistaken for the current one under the same generic filenames.

Existing embeddings are keyed by model version, so this invalidates none of
them but also matches none -- the library needs indexing again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hand-off

AssetListModel's plain text/structured filter and its semantic query are
two independent fields that only clear each other in one direction: turning
on a plain filter already clears a stale semantic query, but turning on
semantic search (or a whole-library hand-off clearing its own semantic
query) never touched a stale plain filter left over from an earlier,
unrelated search. It stayed dormant while semantic ranking short-circuited
past it, then resurfaced the moment that query cleared -- exactly what both
whole-library hand-offs do right before loading their own already-filtered
results, silently zeroing them since the filter text is never going to
match real filenames.

AssetListModel.clear_filters() clears both in one rebuild, used at both the
semantic and plain whole-library search hand-offs -- the plain one had the
same gap in reverse (a stale semantic query, not a stale text filter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	docs/PIPELINE.md
#	docs/USER_GUIDE.md
#	negpy/desktop/controller.py
#	negpy/desktop/main.py
#	negpy/desktop/session.py
#	negpy/desktop/view/keyboard_shortcuts.py
#	negpy/desktop/view/sidebar/controls_panel.py
#	negpy/desktop/view/sidebar/files.py
#	negpy/desktop/view/sidebar/process.py
#	negpy/domain/models.py
#	negpy/features/exposure/processor.py
#	negpy/features/exposure/transfer.py
#	tests/test_controller.py
#	tests/test_file_browser_toolbar.py
#	tests/test_file_browser_widget.py
#	tests/test_process_sidebar.py
#	tests/test_tone_sidebar.py
The tally's prefix slot holds the active roll's name, so frames that are not
one roll -- a library search's results, several folders at once, a batch added
by hand -- showed nothing there and read exactly like a roll. What that hides
is that an edit is an edit of the photo itself: it also shows in the roll each
frame came from. Named "Collection", with the consequence in the tally's
tooltip.

Also drops the Half Frame tooltip's claim that already-split scans still show
split in such a batch, true until nothing in one split any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Film Mode, Calibration, Normalization and Tone's Tonal Range block all edit
ProcessConfig, but the modified count still summed the whole config onto
Normalization: switching Film Mode read as "Normalization · 1" with an undo
arrow that cleared nothing, and editing Tonal Range lit the wrong card.

Each card now owns one tuple that is both its count and its reset scope. Film
Mode gains the reset it lacked, routed through set_positive_source and
set_process_mode so the auto-meter and Cast Removal defaults still follow.
The normalization_rolls migration ran at startup, matched each row to a
Library roll by name, dropped whatever matched nothing and then dropped the
table. Rolls are recognized on import, so at the first launch after the
upgrade there are none to match and every saved baseline went with it.

An unmatched row now stays where it is and the migration stays pending, so a
later import can still claim it. The done flag is no longer set after a
failure, which had left a failed run unable to retry.
… metric

normalization.wgsl re-derived is_transfer_path from mode and normalize alone,
so a Positive frame in Color or B&W took the print branch on the GPU and the
transfer branch on the CPU. It now reads the decision as a uniform, which is
the only way the two engines cannot drift as that rule grows terms.

process_to_texture also published final_bounds from the measured window even
where the render used the fixed one, so the metrics panels read a window no
pixel was normalized with.

compute_geometry_crop_rect is memoized: it solves the same nested search on
every render, from four arguments that rarely change.
…flow

The Roll, Library and Gear work arrived as ten new surfaces, and several of
them re-invented controls, wording and confirmations the app already has.

Wording, one name per thing: Batch Analysis is the action and Roll Baseline
the field it fills, in the Roll tab, the Library menu and the Export button
alike; the apply scope reads Whole Roll / Selected Frames, matching the
shared scope row; a batch that is not one roll is "No roll" everywhere; the
model size, the disabled-Reanalyze tooltip and the nothing-to-apply message
are each stated once.

Controls: renaming a roll opens the same dialog whichever kind it is; the
duplicate reset-roll confirmation is gone, and the unsplit, unfork and
invalid-name boxes come from confirm.py; Export's two metadata booleans are
toggles like their neighbour; the Roll Settings dialog takes its map picker,
Load button and hint label from the factories, and its footer opts every
body button out of Enter.

The Library section is always there, empty or not: it is where rolls arrive,
so hiding it until one exists hid the only route to a first one. Its buttons
now sit in the same toolbar the Film Strip uses, left aligned and the same
size, with Sort last. A button switched off by its owner survives a relayout,
which an opt-in button in that bar did not.

Tokens: roll kinds tell apart by icon shape, so the duplicated coral token
and the borrowed C-41 amber are gone; one token carries the section-header
button size and two more the section toolbars.

Also: every new toggle and action has a registry entry, so a user can bind
it; the dialogs' controls have tooltips, wrapped; comment and doc prose is
cut back to the state it describes, and the bug-hunting notes go with it.
Two affordances answered the same question in two idioms: Roll-tab cards
carried a labelled amber "This Frame Only" badge, with the push back out
living in an Apply split button above all of them, while frame-level cards
had nothing at all and spreading a Dye Separation across a roll meant
leaving the panel for the Film Strip's clone icon and a picker over every
setting in the app.

Every settings section's header now carries the same pair: Frame in amber,
Roll in red, one of them lit. The lit half says where that card's values
live; clicking the other moves them there, and a click on the lit half is
swallowed so the pair can never read a third state. The header takes the lit
colour as a stripe, but only on a card holding something other than its
defaults; the bar down the card body is gone, which is what made the Roll tab
read as a different kind of sidebar from the rest.

A Roll-tab card's pair drives the roll defaults it already had, one card at a
time, so the Apply split button and Force Settings are gone with the
machinery only they reached. A frame-level card's Roll opens the picker that
already exists, limited to that card's own catalog rows and ticked to
whatever differs from default; applying to the whole roll files what it
pushed, and the card reads Roll until the frame drifts off it again. The
record never overlays onto another frame on open: a look stays a copy.

Auto Crop, Lens Correction and Flat Field move to the roll along with it. The
film edge, the rebate width, the format, the scanning lens and the light are
the rig's and the roll's, not one frame's; the rectangle auto crop resolves,
the rotation and the easel movements stay per-frame on Geometry. Roll
defaults had to learn which config section each card writes to get there, so
ROLL_DEFAULT_FIELDS now pairs a section with its fields and the whole
WorkspaceConfig resolves, not ProcessConfig alone.

The Metadata cards follow, defaulting to Roll: one camera, one stock, one
development, one scanning rig, so a frame opened into the roll inherits them.
The frame number is the exception and never travels.

Header buttons grow to 24px on a 40px header, from three tokens rather than
five literals.

Fixes found on the way: pushing a crop setting to other frames now drops
their cached bounds, which the roll-apply path never did although the crop
feeds the meter; changing the flat-field profile re-decodes the source, which
a plain config write could not; and the Auto Crop mode combo read blank on
any frame whose config had never been edited, since findData does not match a
StrEnum against the string its items store.
…licate

The poll decided what was new by path, while add_files turns a file away when
a loaded frame already holds its content. A byte-identical copy under another
name is therefore never in the file list the poll compares against, so every
round hashed it, rejected it and offered it again -- the status bar stuck on
"Hashing 1/1" at the 2s poll interval for as long as the folder was watched.

The rejected paths are recorded on AppState for the poll to count as seen, and
forgotten with the frames whose content they clashed with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both were icon buttons in the Film Strip's row, beside Add and Unload, which
made two roll-wide facts look like list actions: whether a folder groups into
red/green/blue triplets, and whether each scan is two photographs. Both now
have their own card on the Roll tab, between Film Mode and Calibration --
how the files become frames is upstream of what rig read them.

Trichrome holds the mode and Edit Triplet…, which was right-click-only until
now, plus a line naming the two exposures the open frame is assembled from.
Half Frame holds the mode and Adjust… / Detect All / Unsplit in one row,
where a tune-icon menu on the toolbar used to hide them.

Turning either mode on re-reads and re-thumbnails the whole roll, so both ask
first. Cancelling leaves the toggle where it was. With nothing loaded there
is nothing to re-discover, and nothing to confirm.

Neither card carries a Frame/Roll scope pair: neither owns a WorkspaceConfig
field a user edits, the same reason Presets and the Scan sections have none.
Half Frame is the roll's, remembered per roll as it already was, and stays
disabled for a batch that is not one roll. Trichrome is the rig's, one flag
for every roll, since a copy stand either shoots three exposures per frame or
it does not -- the card says so, since it is the one asymmetry on that tab.

The mode buttons are named Trichrome Mode and Half Frame Mode, so every
status line, prompt and doc that names them agrees.

The triplet editor moves to view/widgets/rgb_triplet_dialog.py, where the
card and the film strip's right-click menu open the same dialog -- which also
brings it under the dialog-footer walk, so it pins its Enter target now.
current_base_file, selected_base_hashes and reload_after_half_frame_change
move to AppController, which is what both panels reach for them.
A tab holding more than one card now carries one bar above them all: a
state line ("2 of 3 cards edited"), a reset that asks before putting every
card back, the roll button that offers the whole tab in one apply picker,
and a double chevron that collapses or expands the cards. A card the mode
has retired is not counted, reset or carried. Geometry holds one card, so
it has no bar.

Also in this pass:

- Gear's Items/Presets subtabs become My Gear and Presets sections, with
  their own guide markers; make_section takes repo=None for a panel built
  without one.
- Lens Correction's two metadata toggles sit side by side under an
  EMBEDDED PROFILE subheader, renamed from "Metadata" to match the
  embedded-lens vocabulary the rest of the code uses.
Cuts the long rationale blocks back to the constraint they exist to state, and
drops the editorializing that came with them: knowledge-cutoff asides ("as of
this writing"), meta-notes about what is or is not re-litigated here, and
endings that restate the point instead of ending on it. Behaviour is unchanged;
every touched file parses to the same tree with docstrings stripped.
@marcinz606

Copy link
Copy Markdown
Owner

@PabloRuizCuevas i would merge it as it is now unless you want to change something before that?

They had moved to Tone's Tonal Range block, away from the bounds they move and
from the settings catalog, which files both rows under Process. They sit under
Normalization's own TONAL RANGE subheader again, with their own Global/R/G/B
selector for the per-layer trims, and count and reset with that card. They stay
out of the roll defaults, so editing one does not take the card off Roll.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants