Roll workflow, Gear/Metadata reorganization, half-frame detection - #1105
Open
PabloRuizCuevas wants to merge 167 commits into
Open
PabloRuizCuevas wants to merge 167 commits into
PabloRuizCuevas wants to merge 167 commits into
Conversation
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>
…/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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A session's worth of work across roll handling, gear/metadata organization, half-frame detection, geometry and film-strip UI.
Roll workflow
exportsubfolder (falling back tonegpy/<roll name>/exportwith 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.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.Exposure
Gear & Metadata
make/modelfields, which rode straight into exported EXIF; the placeholder now lives only in the UI-onlydisplay_name.Half-frame
Geometry
Film strip / UI
Housekeeping
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 unmodifiedmain). CI (Test and Lint, Preview load metrics) is green.🤖 Generated with Claude Code