diff --git a/.github/workflows/build-executables.yml b/.github/workflows/build-executables.yml new file mode 100644 index 0000000..2e51d29 --- /dev/null +++ b/.github/workflows/build-executables.yml @@ -0,0 +1,77 @@ +name: Build Executables + +on: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.artifact }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-2025 + python_architecture: x64 + artifact: windows-x64 + - os: macos-15-intel + python_architecture: x64 + artifact: macos-x64 + - os: macos-15 + python_architecture: arm64 + artifact: macos-arm64 + + steps: + - name: Check out repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + architecture: ${{ matrix.python_architecture }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[bundle]" ruff + + - name: Run Ruff + run: python -m ruff check faststack/ + + - name: Build executable + run: python -m PyInstaller packaging/faststack.spec --noconfirm --clean + + - name: Ad-hoc sign macOS app + if: runner.os == 'macOS' + run: codesign --force --deep --sign - dist/FastStack.app + + - name: Package Windows artifact + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts + Compress-Archive -Path dist\FastStack -DestinationPath artifacts\FastStack-${{ matrix.artifact }}.zip -Force + + - name: Package macOS artifact + if: runner.os == 'macOS' + run: | + mkdir -p artifacts + ditto -c -k --sequesterRsrc --keepParent dist/FastStack.app artifacts/FastStack-${{ matrix.artifact }}.zip + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: FastStack-${{ matrix.artifact }} + path: artifacts/*.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index d912df8..4dda48c 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ htmlcov/ build/ dist/ *.spec +!packaging/*.spec +artifacts/ *.egg-info/ # ---------------------------- diff --git a/ChangeLog.md b/ChangeLog.md index 05302f6..90ab421 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,60 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. +## Unreleased + +- Auto-levels and auto white balance produce noticeably better output: + - Auto-levels no longer gives up on an end of the histogram just because a tiny number of pixels is already clipped there (e.g. one small specular highlight used to disable the entire white-point stretch). The clip threshold now acts as a true budget: the stretch is chosen so the total clipped fraction — pre-existing plus new — stays within the threshold. + - Auto-levels endpoints are now luminance-driven with a per-channel clip budget (new "Channel Clip Budget" setting, default 3x the threshold), so a single saturated channel (blue sky, red flowers) no longer vetoes the whole stretch. Analysis histograms use 1024 bins instead of 256 for finer, more stable endpoint placement. + - New midtone correction (new "Midtone Correction" + "Midtone Target" settings, on by default, target 0.38): auto-levels also nudges brightness when a full-range image is under- or over-exposed. Images already within a dead band of the target are left alone, and corrected images recover only partway toward the band edge — so well-exposed photos and intentional low-key/high-key shots are preserved rather than normalized to one brightness. Full-range but underexposed images, which previously got "no change (already full range)", are now corrected. The nudge is conservative (-15%/+30% max) and is only auto-set when the brightness slider is untouched. + - The blacks/whites levels ramp rolls off smoothly near pure white and black instead of hard-clipping each channel independently (new "Highlight Soft Rolloff" setting, on by default). Stretched highlights keep tonal separation and hue — sunset clouds no longer color-shift when one channel clips before the others — and crushed shadows keep a hint of separation. + - Strong level stretches no longer band smooth gradients (skies) on export: a deterministic, imperceptible triangular-noise dither is applied during 8-bit quantization when the stretch gain reaches 1.2x (new "Export Dithering" setting, on by default). The levels uint8 fast-path save defers to the float pipeline in that case so the dither can be applied. + - Auto white balance now analyzes only the cropped area, so borders and backgrounds you have cropped away can no longer skew the color estimate (auto-levels already worked this way). + - Auto white balance scales its correction by confidence: few usable neutral pixels, or disagreement between its two estimators, fades the correction toward neutral instead of over-correcting scenes that lack true grays. + - Auto white balance blends a second, independent estimator (Shades-of-Gray, Minkowski p=6) with the neutral-pixel estimate; agreement between the two raises confidence and blending tempers each one's biases. + - Magenta/green (tint) corrections are damped relative to blue/yellow (new "Tint Correction" setting, default 60%): real light sources vary mostly along the blue/yellow axis, so a large tint component is usually subject color (foliage) rather than a cast — this prevents the classic "green forest turns magenta" failure. + - White-balance gains are now normalized to preserve luminance, so warming or cooling an image no longer slightly brightens or darkens it (applies to slider WB and AWB alike, including the uint8 fast-path save). + - Auto vibrance is now hue-aware: it backs off when a small subject is already vivid (90th-percentile saturation guard) and halves the boost when a meaningful share of pixels falls in the skin-tone envelope. + - The compact editor's Light section now includes a Brightness slider (the full editor already had one), so the midtone correction applied by auto-levels is visible and adjustable there. The soft highlight rolloff and luminance-preserving white balance apply to manual Blacks/Whites and Temp/Tint slider edits in both editors too, since they live in the shared render pipeline. +- Applying a crop (and full-resolution renders generally) no longer copies the entire full-resolution float master before slicing out the crop — at 20MP that was a ~240MB copy on the UI thread per crop apply. The render now shares the master and copies only the cropped region, and skips even that when the display downscale already produced fresh memory. Preview-sized renders during crop drags get the same treatment. Output is bit-identical. +- Rotate mode (crop straightening) now overlays a grid of horizontal and vertical reference lines. The lines stay screen-aligned while the image rotates underneath, so horizons and verticals can be lined up without guessing; the center lines are drawn brighter. The grid is only visible while rotate mode is active. +- Image editing is much faster. Quick auto-adjust keys (`l`, `L`, `-`, `=`, `+`, `_`) and editor sliders now show results several times sooner: + - sRGB↔linear conversions in the edit pipeline use lookup tables (~3x faster, error under 0.03 of one 8-bit step). + - Preview renders skip the linear round-trip entirely when only sRGB-space edits (levels, brightness, contrast, saturation, vibrance, vignette) are active; the live clipping indicators are still computed from a downsampled view. + - Final display conversion uses a fused OpenCV pass (~5x faster). + - Quick auto-adjust loads the image preview-only, removing a ~400ms UI freeze on the first keypress; the full-resolution master is materialized in the background. + - Full-resolution live previews (after crop/rotate) now publish a fast preview-sized frame immediately, then replace it with the high-resolution frame, and the high-resolution render is capped at display size when not zoomed (it was processing 20MP for pixels the screen cannot show). Crop apply/cancel renders are capped the same way. + - The editor now decodes JPEG pixels with TurboJPEG (Pillow fallback) instead of Pillow, roughly halving image-load time on the first edit keypress; EXIF/ICC metadata is preserved via a lazy header read. Full editor loads of JPEGs also no longer decode the file a second time through OpenCV's 16-bit probe. + - More debug timing logs: `[RENDER_DECODED]` breaks down apply/u8/color time per preview render. +- The batch-clear shortcut now uses `|` instead of plain `\`, so Shift must be held down. +- Fixed re-editing a previously cropped (or otherwise edited) image showing a double-applied edit (e.g. crop-on-crop) in preview-sized renders: the editor seeded its preview buffer from the already-saved pixels while also replaying the saved edits on top. The preview is now rebuilt from the backup source pixels whenever edits are replayed. +- EXIF orientation is now applied with OpenCV rotate/flip (bit-identical, ~5x faster): rotated camera images (orientation 3/6/8) were paying a ~244ms numpy transpose copy at 20MP on every editor load and prefetch decode. +- A save no longer invalidates and re-decodes the displayed image two or three times: the save-completion handler and the watcher events it triggers now recognize (via an mtime+size fingerprint) that the file state was already invalidated. Single-file metadata changes (timestamp/backup flag after a save) also no longer cancel the whole prefetch generation, which was silently re-decoding the ±8 window in the background after every save. +- Editor preview buffers are built with cv2.resize instead of a PIL thumbnail round-trip when the JPEG fast path is active (~4x faster at 20MP). +- Fixed display-only oversaturation when editing a previously saved image in ICC color mode: editor preview buffers built from source pixels (backup re-edit loads, restored in-flight sessions, compare previews) were sRGB but displayed as monitor-space. They are now color-corrected to display space at build time, exactly like the prefetcher's cached previews. Saved files were never affected. +- Navigating away from a just-edited image and back no longer flashes the original pixels while the background save is in flight: the last live preview is seeded into the decode cache when the session save is queued, then replaced by the real decode when the save completes (and dropped if the save fails). +- Fixed a duplicate watcher-debounce fire (with an already-drained change set) falling back to a global cache invalidation, which re-decoded the whole prefetch window once after some saves. +- Saving is much cheaper end to end: + - Adding an image to a batch (including the automatic add-on-first-edit) no longer rebuilds the entire grid model — batch badges are computed live, so the grid is just told to repaint them. This removes a ~1 second UI-thread stall after every save. + - Sidecar metadata lookups from grid refreshes no longer run the legacy-key migration scan (O(entries) filesystem checks per missing entry); measured 1184ms → 28ms for a 341-image folder with 300 sidecar entries. Migration still runs on user-action lookups, so old entries are still upgraded when an image is viewed or modified. + - The vibrance pipeline (active on every quick auto-adjust save and preview) avoids a hidden float64 promotion in the gray-blend math and uses OpenCV for channel max/min and luma (~3x faster at full resolution), and the save path's float→uint8 conversion is a fused OpenCV pass. +- Navigation metadata lookups (filename/uploaded/stack/batch status shown per image) no longer run the sidecar legacy-key migration scan, which was doing hundreds of filesystem checks per navigated image on the GUI thread during fast arrow-key scrolling. +- Saving an image no longer invalidates the entire decode cache. Saves, watcher-detected file changes, and edit-revert now invalidate only the files that actually changed, so navigation right after a save no longer hits a blocking re-decode and the prefetch window stays warm (previously every save re-decoded ~9 neighboring images and bumped the global cache generation several times). A per-path invalidation epoch makes this safe: decode results that started reading a file before it was replaced are discarded instead of re-inserting stale pixels. The global invalidation still applies for resize/zoom/color-mode changes and unattributable directory events. + +## 1.6.4 (2026-06-06) + +- Image editor exposure and whites slider changes now apply twice as much per slider unit without changing auto levels or other exposure logic. +- Fixed crop geometry when rotating an already-cropped image in the editor, so preview and save now preserve the intended crop after 90-degree rotation. +- Compact image editor now includes a contrast slider. +- Right-click crop entry in loupe view is now reliable after panning and with trackpads that report mixed button state. +- Loupe status bar EXIF details now show the distance in meters from the previous photo when both images have GPS coordinates. +- Quick auto levels/auto color (`l` and `L`) now also raise the vibrance if FastStack thinks it should be raised. There is a setting to enable/disable this, enabled by default. +- Holding Space in loupe view now shows the original source image with the current crop still applied, hiding other live edits until Space is released. +- Crop mode now works while the compact image editor is open - it is still disabled if the expanded editor is open. +- Highlights slider now recovers a broad range of bright tones with a smooth falloff, instead of only affecting near-white pixels, while leaving midtones unchanged. +- Compact image editor keyboard handling reworked: Left/Right now navigate to the previous/next image even when the editor is focused, Up/Down raise/lower the highlighted slider, and clicking a slider's label highlights it as the Up/Down target. Other shortcuts (B to batch, F, D, I, G, etc.) now also work while the editor is focused. README gained a full Image Editor section. +- Added Actions -> Duplicate Image, which copies the current visible image to a `_duplicate` filename and inserts it as the next image without copying backups or RAW pairs. + ## 1.6.3 (2026-04-16) - Reworked quick auto-adjust and crop into one shared live edit session for the current image instead of saving on every keypress. @@ -334,7 +388,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation ### Major Features - **Batch Selection System:** New batch selection mode for drag-and-drop operations - - `{` to begin batch, `}` to end batch, `\` to clear all batches + - `{` to begin batch, `}` to end batch, `|` to clear all batches - `X` or `S` keys remove individual images from batches/stacks (shrinks or splits ranges) - Batches automatically cleared after successful drag operation - Multiple files can now be dragged to browsers and external applications simultaneously diff --git a/README.md b/README.md index cf551e2..9a9fb6b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # FastStack -# Version 1.6.3 - April 16, 2026 +# Version 1.6.4 - June 7, 2026 + # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. @@ -9,7 +10,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive ## Features -- **Crop:** Added the ability to crop and rotate images via the cr(O)p hotkey (or right mouse click). It can be a freeform crop, or constrained to several popular aspect ratios. +- **Crop:** Added the ability to crop and rotate images via the cr(O)p hotkey (or right mouse click). It can be a freeform crop, or constrained to several popular aspect ratios. - **Zoom & Pan:** Smooth zooming and panning. - **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). - **Spark Line**: In grid view, a spark line is visible on each folder, so you can see how far you have gotten in uploading photos in each directory. @@ -17,11 +18,11 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. - **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) - **Background Darkening:** Mask-based background darkening tool (K key) with smart edge detection, subject protection, and multiple modes. Paint rough background hints and the tool refines them into natural-looking dark backgrounds. -- **Quick Auto Adjust:** Press `l` for quick auto-levels, `L` for auto white balance + auto-levels together, `A` for auto white balance, `-`/`_` to keep adjusting the highlight/white side in 14-point steps, and `=`/`+` to adjust the shadow/black side in 7-point steps. These update the live in-memory edit session immediately and save once when you navigate away, start a drag, or explicitly save. +- **Quick Auto Adjust:** Press `l` for quick auto-levels, `L` for auto white balance + auto-levels together, `A` for auto white balance, `-`/`_` to keep adjusting the highlight/white side in 14-point steps, and `=`/`+` to adjust the shadow/black side in 7-point steps. These update the live in-memory edit session immediately and save once when you navigate away, start a drag, or explicitly save. Auto-levels treats its clip threshold as a budget (a few already-clipped specular pixels won't disable the stretch), brightens underexposed midtones toward a configurable target, and rolls off stretched highlights smoothly instead of hard-clipping them. Auto white balance analyzes only the cropped area, fades its correction when the scene lacks reliable neutrals, and damps magenta/green corrections so foliage isn't mistaken for a color cast. The midtone target, channel clip budget, highlight rolloff, export dithering, and tint correction strength are all adjustable in Settings. - **Photoshop / Gimp Integration:** Edit current image in Photoshop or Gimp (P key) - always uses RAW files when available. - **Clipboard Support:** Copy image path to clipboard (Ctrl+C) - **Image Filtering:** Filter images by filename -- **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. +- **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. - **Theme Support:** Toggle between light and dark themes - **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) - **Has Memory:** Starts where you left off, tells you which images have been edited, stacked and uploaded. @@ -34,26 +35,31 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive ## Installation ### macOS (Recommended) + FastStack performs best on Python 3.12 due to PySide6 compatibility. 1. **Install Python 3.12 (via Homebrew):** + ```bash brew install python@3.12 ``` 2. **Create and Activate a Virtual Environment:** + ```bash python3.12 -m venv venv source venv/bin/activate ``` 3. **Install FastStack:** + ```bash # From source directory python -m pip install -U pip python -m pip install . ``` - *Note: If you encounter issues with `opencv-python` or `PySide6` on newer Python versions (3.13+), please stick to Python 3.12.* + + _Note: If you encounter issues with `opencv-python` or `PySide6` on newer Python versions (3.13+), please stick to Python 3.12._ 4. **Run:** ```bash @@ -62,6 +68,7 @@ FastStack performs best on Python 3.12 due to PySide6 compatibility. ``` ### Windows / Linux + ```bash python -m venv venv # Activate venv (Windows: venv\Scripts\activate, Linux: source venv/bin/activate) @@ -95,6 +102,7 @@ python -m faststack.app [options] [image_dir] `--debug`. ### Windows Performance Note + On Windows, `PyTurboJPEG` also needs the native `libjpeg-turbo` library (`turbojpeg.dll`). - If `turbojpeg.dll` is installed, FastStack uses it automatically for faster JPEG decode and thumbnail generation. @@ -117,6 +125,7 @@ faststack "C:\path\to\photos" ``` ### Troubleshooting on Windows + If startup logs mention: ```text @@ -146,16 +155,17 @@ If you do nothing, FastStack will still run, but JPEG decoding and thumbnail gen - `G`: Jump to Image Number - `I`: Show EXIF Data - `F11`: Toggle Fullscreen (Loupe View) +- `Space` (hold in loupe view): Show the original with the current crop, hiding other edits until released - `S`: Toggle current image in/out of stack - `X`: Remove current image from batch/stack - `B`: Toggle current image in/out of batch - `D`: Toggle todo flag - shows up red on the sparkline so you can see if you have flagged images to work on later -- `[`: Begin new stack group +- `[`: Begin new stack group - `]`: End current stack group - `C`: Clear all stacks - `{`: Begin new drag & drop batch - `}`: End current drag & drop batch -- `\`: Clear drag & drop batch +- `|` (Shift+`\`): Clear drag & drop batch - `U`: Toggle uploaded flag - `Ctrl+E`: Toggle edited flag - `Ctrl+S`: Toggle stacked flag @@ -166,8 +176,8 @@ If you do nothing, FastStack will still run, but JPEG decoding and thumbnail gen - `Ctrl+Z`: Undo last saved action (delete or saved edit) - `A`: Quick auto white balance (live session; saved on navigation, drag, or Ctrl+S) - `Ctrl+Shift+B`: Quick auto white balance (alternate) -- `l`: Quick auto levels (live session; saved on navigation, drag, or Ctrl+S) -- `L`: Quick auto white balance + auto levels (live session; saved on navigation, drag, or Ctrl+S) +- `l`: Quick auto levels + vibrance (live session; saved on navigation, drag, or Ctrl+S) +- `L`: Quick auto white balance + auto levels + vibrance (live session; saved on navigation, drag, or Ctrl+S) - `-`: Darken the current auto-adjust highlights/whites by 14 points in the live session - `_`: Raise the current auto-adjust whites by 14 points in the live session - `+`: Raise the current auto-adjust shadows/blacks by 7 points in the live session @@ -181,3 +191,129 @@ If you do nothing, FastStack will still run, but JPEG decoding and thumbnail gen - `Ctrl+2`: Zoom to 200% - `Ctrl+3`: Zoom to 300% - `Ctrl+4`: Zoom to 400% + +## Image Editor + +Press `E` to toggle the image editor. It opens as a small floating panel (the +**compact editor**) docked to the right edge of the main window. Click the +expand button (⤢) in its header to switch to the **full editor**, a larger +dialog with the complete set of adjustments. Both edit the image currently +shown in the loupe. + +### What it does + +The editor applies a _live, non-destructive_ session on top of the current +image: nothing is written to disk until you save. The panel shows a live +histogram (overlay or per-channel R/G/B) and grouped adjustments: + +- **Light** — Exposure, Contrast, Whites, Shadows, Blacks (the full editor + also adds Brightness and Highlights). +- **Color** — Temp (Blue/Yellow), Tint (Green/Magenta), and Vibrance (the full + editor adds Saturation, Clarity, Texture, Sharpness, Vignette, and more). +- **Auto** buttons next to each group apply auto-levels or auto white balance. + +Drag a slider to adjust it. **Double-click a slider** (or click its numeric +value) to reset that one control to 0. **Reset** clears every adjustment back +to the original. + +### Using the keyboard in the compact editor + +While the compact editor has focus, the arrow keys are split so you can both +browse and adjust without reaching for the mouse: + +- `Left` / `Right` — go to the previous / next image. (Any unsaved edits on the + current image are committed first — see _Saving_ below.) +- `Up` / `Down` — raise / lower the **highlighted** slider. The highlighted row + is tinted and outlined; Exposure is highlighted by default. +- **Click a slider's label** (or its value) to make it the highlighted slider + that `Up`/`Down` will affect. +- `S` (or `Ctrl+S`) — save the current edits. +- `E` or `Esc` — close the editor (you'll be prompted if there are unsaved + edits). +- `O` — toggle crop mode. + +Other shortcuts work the same as they do in the main view even while the editor +is focused, including `B` (add to batch), `F` (favorite), `D` (todo), `I` +(EXIF), and `G` (jump to image). In short, the compact editor never traps the +keyboard — only the editor-specific keys above behave differently. + +### Cropping + +Press `O` (or right-click the image) to enter crop mode. Drag the crop +rectangle, optionally press `1`/`2`/`3`/`4` to lock a 1:1, 4:3, 3:2, or 16:9 +aspect ratio, then press `Enter` to apply the crop to the live session or `Esc` +to cancel. You must apply or cancel a crop before you can save. + +### Saving + +Saving writes the edited result back to the JPG on disk. Before overwriting, +FastStack creates a `-backup` copy of the original file, so the unedited image +is never lost. You can save explicitly with `S`/`Ctrl+S` or the **Save** +button. + +Edits are also committed automatically when you **navigate to another image** +or **drag the image out** of FastStack — so pressing `Left`/`Right` in the +editor saves the current image's pending edits before moving on. Closing the +editor with unsaved edits prompts you to discard or keep them. + +### Undo and caveats + +- `Ctrl+Z` undoes the last saved edit, restoring the image from its `-backup`. +- Because the editor operates on the visual JPG, edits stack on top of the + current file; for the most flexibility do your heavy adjustments before + exporting elsewhere. +- The histogram, clip indicators, and live preview update as you adjust, which + makes it easy to watch for blown highlights or crushed blacks (the clip + counters turn hot when channels clip). + +## Status Bar + +The bar across the bottom of the window summarizes everything FastStack knows +about the current image. Items only appear when they are relevant, so you will +rarely see all of them at once. + +### Left side — image identity + +- **Image: N / M**: The position of the current image (N) within the folder, and + the total number of images (M) after any active filter is applied. +- **Filename**: The file name of the image currently displayed. +- **EXIF brief**: A compact capture summary pulled from the image's EXIF data, + shown as `ISO 800 | f/2.8 | 1/500s | 14:30:25` (ISO, aperture, shutter speed, + and capture time). Any value the camera did not record is simply omitted. +- **Distance (`123 m`)**: When both the current and previous images contain GPS + coordinates, this shows the straight-line distance, in meters, between where + the two photos were taken. Hover over it for a reminder of what it means. +- **Directory path**: The folder currently being browsed (greyed out and + shortened in the middle if long). Hover to see the full path. + +### Center / right — status flags and badges + +These appear only when the corresponding flag or condition is set on the image: + +- **Stacked: \** (green): The image has been marked as stacked, with the date. +- **Uploaded on \** (green): The image has been flagged as uploaded. +- **Todo since \** (blue): The image is flagged as a todo for later work. +- **Edited on \** (green): The image has been flagged as edited. +- **Restacked on \** (cyan): The image has been flagged as restacked. +- **Favorite** (gold): The image is marked as a favorite. +- **Filter: "..."** (yellow): A search/filter string is active; only matching + images are shown and counted. +- **Preloading bar**: A progress bar shown while images are being decoded and + cached in the background. +- **Stack: ...** (orange badge): The stack group this image belongs to. +- **Batch: ...** (green badge): The drag-and-drop batch this image belongs to. +- **Variant badges**: When an image has multiple variants (e.g. JPG and a stacked + result), clickable badges let you switch which variant is displayed. An italic + hint describes what saving will do. + +### Far right — modes, messages, and grid controls + +- **Cache stats** (cyan, monospace): Live cache telemetry, shown only when started + with `--debugcache`. +- **Saturation slider**: Adjusts display saturation; visible only in saturation + color mode. +- **Status message**: Transient feedback such as save progress, crop prompts, and + error notices. Turns green and bold while a save is in progress. +- **Grid controls** (grid view only): Shows the number of selected images and + provides **Clear Selection**, **← Back**, **Refresh**, and **Single View** + buttons. diff --git a/faststack/app.py b/faststack/app.py index bd95813..060b4e8 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -75,16 +75,18 @@ ) from faststack.imaging.prefetch import ( Prefetcher, + apply_loupe_color_correction, clear_icc_caches, get_icc_profile_description, get_icc_profile_details, get_monitor_profile, ) from faststack.ui.keystrokes import Keybinder -from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS +from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS, _safe_replace from faststack.imaging.mask import DarkenSettings, MaskData, MaskStroke from faststack.imaging.mask_engine import inverse_transform from faststack.imaging.metadata import get_exif_data +from faststack.resources import faststack_qml_dir, pyside_qml_dir from faststack.thumbnail_view import ( DEFAULT_THUMBNAIL_CACHE_BYTES, ThumbnailModel, @@ -119,6 +121,17 @@ _AWB_LABEL_EPS = 0.002 _AUTO_ADJUST_HIGHLIGHT_STEP = 0.14 _AUTO_ADJUST_BLACK_STEP = 0.07 +_AUTO_VIBRANCE_EPS = 0.001 +_AUTO_BRIGHTNESS_EPS = 0.001 +# Midtone correction dead band and recovery. Images whose projected +# post-stretch median luma already sits within +/- deadband of the target are +# left alone — a reasonable exposure should not be normalized toward one +# canonical brightness (that made "most images too bright"). Images outside +# the band are pulled only partway back to the nearest band *edge* (never to +# the center), which keeps the correction continuous at the band boundary and +# preserves intentional low-key / high-key character. +_AUTO_MIDTONE_DEADBAND = 0.10 +_AUTO_MIDTONE_RECOVERY = 0.7 def _awb_direction(value: float, pos_label: str, neg_label: str) -> str: @@ -136,6 +149,10 @@ class ActiveAutoAdjustState: base_whites: float p_low: float p_high: float + base_vibrance: float + auto_vibrance_delta: float = 0.0 + base_brightness: float = 0.0 + auto_brightness_delta: float = 0.0 extra_highlight_steps: int = 0 extra_black_steps: int = 0 @@ -186,6 +203,7 @@ class AppController(QObject): is_zoomed_changed = Signal(bool) # Signal for zoom state changes histogramReady = Signal(object) # Signal for off-thread histogram result previewReady = Signal(object) # Signal for off-thread preview result + originalCompareReady = Signal(object) # Signal for held-space compare result dialogStateChanged = Signal(bool) # Signal for dialog open/close state # Thread-safe signal for thumbnail ready (emitted from worker thread, received on GUI thread) _thumbnailReadySignal = Signal(str) @@ -204,13 +222,14 @@ class ProgressReporter(QObject): finished = Signal() editSourceModeChanged = Signal(str) # Notify when JPEG/RAW mode changes + rawDevelopmentStateChanged = Signal() # Notify when RAW development starts/stops _saveFinished = Signal( object ) # Signal for save completion (result or error from background) _deleteFinished = Signal( object ) # Signal for async delete completion (result dict from worker) - _exifBriefReady = Signal(str, str) # (path_str, brief) from background thread + _exifBriefReady = Signal(object, str) # (cache_key, brief) from background thread def __init__( self, @@ -243,6 +262,7 @@ def __init__( self._hist_lock = threading.Lock() self.histogramReady.connect(self._apply_histogram_result) self.previewReady.connect(self._apply_preview_result) + self.originalCompareReady.connect(self._apply_original_compare_result) # Save Offloading Setup (runs save_image in background thread) # ⚠️ NON-DAEMON: We must ensure saving finishes to avoid data loss on exit. @@ -268,6 +288,9 @@ def __init__( self._exif_executor = create_daemon_threadpool_executor( max_workers=2, thread_name_prefix="EXIF" ) + self._editor_prewarm_executor = create_daemon_threadpool_executor( + max_workers=1, thread_name_prefix="EditPrewarm" + ) self._preview_inflight = False self._preview_pending = False self._preview_token = 0 @@ -276,6 +299,17 @@ def __init__( self._last_rendered_preview_session_key: Optional[tuple[str, Optional[str]]] = ( None ) + self._original_compare_preview = None + self._original_compare_session_key: Optional[tuple[str, Optional[str], int]] = ( + None + ) + self._original_compare_index: int = -1 + self._original_compare_gen: int = -1 + self._original_compare_active: bool = False + self._original_compare_inflight: bool = False + self._original_compare_token: int = 0 + self._editor_prewarm_future: Optional[concurrent.futures.Future] = None + self._editor_prewarm_lock = threading.Lock() self._shutting_down = False # Flag to gate async callbacks during shutdown self._refresh_scheduled = False # Coalesce guard for deferred disk refresh self._opencv_warning_shown = False # Only show OpenCV warning once per session @@ -297,6 +331,7 @@ def __init__( # flushed synchronously on shutdown so unsaved edits are not lost. self._pending_save_recovery: Dict[str, dict] = {} self._latest_save_tokens: Dict[str, Any] = {} + self._pending_edit_save_requests: Dict[str, dict] = {} self._last_save_prepare_error: Optional[str] = None self._shutdown_flush_prepared = False @@ -352,11 +387,24 @@ def __init__( # Edit Source Mode State # "jpeg" (default) or "raw" self.current_edit_source_mode: str = "jpeg" + self._raw_developing_keys: Set[str] = set() + self._raw_develop_lock = threading.Lock() # -- Backend Components -- self.watcher = Watcher(self.image_dir, self._request_watcher_refresh) self._suppressed_paths: Dict[str, float] = {} # key -> monotonic expiry time self._suppressed_paths_lock = threading.Lock() # guards cross-thread access + # Paths reported by the watcher since the last debounced refresh, so + # _on_watcher_refresh can invalidate decodes per-path instead of + # busting the entire cache via a display-generation bump. + self._watcher_changed_paths: set = set() + self._watcher_changed_lock = threading.Lock() + # Per-path decode invalidation epochs (path key -> monotonic time). + # _prefetch_cache_put rejects decode results that STARTED before the + # path's epoch: their pixels were read from a file that has since + # been replaced (e.g. by a save). + self._decode_invalidation_epochs: Dict[str, float] = {} + self._decode_invalidation_lock = threading.Lock() self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) self.image_editor = ImageEditor() # Initialize the editor self._dialog_open_count = 0 # Track nested dialogs @@ -377,7 +425,7 @@ def __init__( self.image_cache.misses = 0 # Initialize cache miss counter self.prefetcher = Prefetcher( image_files=self.image_files, - cache_put=self.image_cache.__setitem__, + cache_put=self._prefetch_cache_put, prefetch_radius=config.getint("core", "prefetch_radius", 12), get_display_info=self.get_display_info, debug=_debug_mode, @@ -481,10 +529,10 @@ def __init__( self._metadata_cache = {} self._metadata_cache_index = (-1, -1) - self._exif_brief_cache: dict = {} # normalized path key → formatted EXIF string + self._exif_brief_cache: dict = {} # EXIF context key → formatted EXIF string self._native_image_size_cache: Dict[str, tuple[float, int, int]] = {} - self._exif_pending_path: Optional[str] = ( - None # path currently awaiting EXIF read + self._exif_pending_path: Optional[tuple[str, str]] = ( + None # current/previous source keys awaiting EXIF read ) with self._last_image_lock: self.last_displayed_image = None @@ -514,6 +562,15 @@ def __init__( self.histogram_timer.setInterval(50) # 50ms throttle (max 20fps) self.histogram_timer.timeout.connect(self._kick_histogram_worker) + # High-quality preview refinement: every preview-resolution render + # (slider drag, adjust keys, auto-adjust) re-arms this debounce; once + # input goes idle the same frame is re-rendered at display + # resolution, so drags stay cheap and the settled image is sharp. + self._hq_preview_timer = QTimer(self) + self._hq_preview_timer.setSingleShot(True) + self._hq_preview_timer.setInterval(350) + self._hq_preview_timer.timeout.connect(self._refine_preview_resolution) + # Preview refresh uses a gate pattern instead of a timer: # - _kick_preview_worker() runs immediately if not inflight # - If inflight, it sets _preview_pending and returns @@ -572,6 +629,25 @@ def __init__( self.auto_level_strength_auto = config.getboolean( "core", "auto_level_strength_auto", False ) + self.auto_vibrance_enabled = config.getboolean( + "core", "auto_vibrance_enabled", True + ) + self.auto_level_midtone = config.getboolean("core", "auto_level_midtone", True) + self.auto_level_midtone_target = config.getfloat( + "core", "auto_level_midtone_target", 0.38 + ) + self.auto_level_channel_budget = config.getfloat( + "core", "auto_level_channel_budget", 3.0 + ) + self.levels_soft_knee = config.getboolean("core", "levels_soft_knee", True) + self.export_dither = config.getboolean("core", "export_dither", True) + # Rendering preferences live on the editor so the imaging layer stays + # config-free. + self.image_editor.levels_soft_knee = self.levels_soft_knee + self.image_editor.export_dither = self.export_dither + self.ui_state.autoAddEditedToBatch = config.getboolean( + "core", "auto_add_edited_to_batch", True + ) # Connect editor open/close signal for memory cleanup self.ui_state.is_editor_open_changed.connect(self._on_editor_open_changed) @@ -774,7 +850,7 @@ def get_sort_mode(self): @Slot(str) def set_sort_mode(self, mode: str): - if mode not in ("default", "filename", "date"): + if mode not in ("default", "filename", "date", "date_reverse"): return if self.sort_mode == mode: return @@ -906,6 +982,9 @@ def _filtered_sorted_copy(self, mode: str) -> list: # Use the timestamp captured at scan time (ImageFile.timestamp) # so we avoid live filesystem calls during sort. Tiebreak on # lowercase filename for determinism when mtimes are equal. + result.sort(key=lambda img: (img.timestamp, img.path.name.lower())) + elif mode == "date_reverse": + # Reverse chronological order (newest first) result.sort(key=lambda img: (-img.timestamp, img.path.name.lower())) return result @@ -967,6 +1046,105 @@ def _bump_display_generation(self): with self._display_lock: self.display_generation += 1 + def _prefetch_cache_put(self, cache_key, decoded, path=None, decode_started=None): + """Cache insert for prefetch decode results, with staleness guard. + + A decode that STARTED before its path's invalidation epoch read pixels + from a file that has since been replaced (saved); inserting it would + resurrect stale pixels right after the targeted pop_path. Reject it — + the next update_prefetch/blocking decode re-reads the new file. + """ + if path is not None and decode_started is not None: + try: + epoch_key = self._key(Path(path)) + except (OSError, TypeError, ValueError): + epoch_key = str(path) + with self._decode_invalidation_lock: + entry = self._decode_invalidation_epochs.get(epoch_key) + if entry is not None: + epoch_time, fingerprint = entry + if fingerprint is None: + log.debug( + "Discarding prefetch decode for %s because optimistic " + "live-preview seed is active", + path, + ) + return + if decode_started < epoch_time: + log.debug( + "Discarding stale prefetch decode for %s " + "(file replaced while decode was in flight)", + path, + ) + return + self.image_cache[cache_key] = decoded + + @staticmethod + def _file_state_fingerprint(p: Path) -> Optional[tuple]: + """(mtime, size) identity of the file's current on-disk state.""" + try: + st = p.stat() + return (st.st_mtime, st.st_size) + except OSError: + return None + + def _invalidate_decoded_path(self, path, *, force: bool = False) -> None: + """Targeted decode-cache invalidation for one changed file. + + Replaces the old display-generation bump (which stale-keyed every + cached decode and forced the whole prefetch window to re-decode after + each save). Records the invalidation epoch BEFORE popping cache + entries so in-flight decodes of the old file contents are rejected by + _prefetch_cache_put when they land, then cancels/unschedules any + pending prefetch for the path so it gets re-submitted fresh. + + A save is invalidated twice (save-completion handler + the watcher + events it triggers, in either order). The (mtime, size) fingerprint + dedupes watcher echoes only: if the file's on-disk state is the one + we already invalidated, cached entries were decoded after that epoch + and are coherent, so the duplicate watcher invalidation is skipped + instead of forcing another blocking re-decode of the displayed image. + """ + if path is None: + return + p = Path(path) + try: + epoch_key = self._key(p) + except (OSError, TypeError, ValueError): + epoch_key = str(p) + now = time.monotonic() + fingerprint = self._file_state_fingerprint(p) + with self._decode_invalidation_lock: + entry = self._decode_invalidation_epochs.get(epoch_key) + if ( + not force + and entry is not None + and fingerprint is not None + and entry[1] == fingerprint + ): + return + # Opportunistic pruning: no decode stays in flight for minutes, + # so old epochs can never reject anything and may be dropped. + if len(self._decode_invalidation_epochs) > 64: + cutoff = now - 120.0 + self._decode_invalidation_epochs = { + k: v + for k, v in self._decode_invalidation_epochs.items() + if v[0] >= cutoff + } + self._decode_invalidation_epochs[epoch_key] = (now, fingerprint) + self.image_cache.pop_path(p) + self.prefetcher.invalidate_path(p) + + def _mark_optimistic_decode_seed_epoch(self, path: Path) -> None: + """Record that the decode cache currently contains optimistic pixels.""" + try: + epoch_key = self._key(path) + except (OSError, TypeError, ValueError): + epoch_key = str(path) + with self._decode_invalidation_lock: + self._decode_invalidation_epochs[epoch_key] = (time.monotonic(), None) + def on_display_size_changed(self, width: int, height: int): """Debounces display size change events to prevent spamming resizes.""" log.debug( @@ -1031,6 +1209,11 @@ def set_zoomed(self, zoomed: bool): # mirroring what _handle_resize() does. self.prefetcher.cancel_all() if self._loupe_decode_allowed(): + self.prefetcher.submit_task( + self.current_index, + self.prefetcher.generation, + priority=True, + ) self.prefetcher.update_prefetch(self.current_index) # Force QML to reload the image at the new resolution @@ -1064,6 +1247,11 @@ def zoom_400(self): # naturally unreachable and LRU will evict them. This lets us instantly # reuse cached images if user toggles zoom on/off repeatedly. self.prefetcher.cancel_all() # Cancel stale tasks to avoid wasted work + self.prefetcher.submit_task( + self.current_index, + self.prefetcher.generation, + priority=True, + ) self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() self.ui_state.isZoomedChanged.emit() @@ -1089,11 +1277,39 @@ def handle_key_from_compact_editor(self, key: int, modifiers: int, text: str): self.eventFilter(self.main_window, event) def eventFilter(self, watched, event) -> bool: + if watched == self.main_window and event.type() == QEvent.Type.KeyRelease: + if event.key() == Qt.Key_Space and not event.isAutoRepeat(): + was_active = self._original_compare_active + self.stop_original_compare_preview() + return was_active + # Don't handle key events when a dialog is open if self._dialog_open: return False + # While cropping, claim Esc at the ShortcutOverride stage so the QML + # Shortcut system never matches it (an app-wide "Escape" Shortcut that + # matches but fails to run its handler — e.g. an ambiguous match — + # consumes the key and the KeyPress below never arrives). Accepting + # the override re-delivers Esc as a normal KeyPress, which the crop + # branch below handles deterministically. Rotate mode is excluded: + # there Esc must reach the loupe's QML key handler to exit rotation. + if ( + watched == self.main_window + and event.type() == QEvent.Type.ShortcutOverride + and event.key() == Qt.Key_Escape + and getattr(self.ui_state, "isCropping", False) + and not getattr(self.ui_state, "isCropRotating", False) + ): + event.accept() + return True + if watched == self.main_window and event.type() == QEvent.Type.KeyPress: + if event.key() == Qt.Key_Space and self._can_show_original_compare(event): + if not event.isAutoRepeat(): + self.start_original_compare_preview() + return True + # QML handles Crop Enter/Esc keys now. # We defer to QML to avoid double-triggering or focus conflicts. # handled = self.keybinder.handle_key_press(event) ... @@ -1288,6 +1504,13 @@ def _request_watcher_refresh(self, path=None): return # Cleanup expired entry del self._suppressed_paths[key] + with self._watcher_changed_lock: + self._watcher_changed_paths.add(p) + else: + # Caller didn't say what changed; the debounced refresh must fall + # back to a global cache invalidation. None marks "unknown". + with self._watcher_changed_lock: + self._watcher_changed_paths.add(None) try: QMetaObject.invokeMethod( @@ -1296,6 +1519,20 @@ def _request_watcher_refresh(self, path=None): except RuntimeError: pass # QObject already deleted during shutdown + def _suppress_watcher_paths(self, *paths: Optional[Path], ttl: float = 3.0) -> None: + """Temporarily ignore watcher refreshes for specific paths.""" + now = time.monotonic() + with self._suppressed_paths_lock: + for path in paths: + if path is None: + continue + try: + key = self._key(path) + except (OSError, TypeError, ValueError): + continue + if key: + self._suppressed_paths[key] = now + ttl + @Slot() def _start_watcher_debounce_timer(self) -> None: """Non-overloaded slot to restart the watcher debounce timer. @@ -1344,10 +1581,26 @@ def _on_watcher_refresh(self): if self.image_files and 0 <= self.current_index < len(self.image_files): preserved_path = self.image_files[self.current_index].path + with self._watcher_changed_lock: + changed_paths = self._watcher_changed_paths + self._watcher_changed_paths = set() + self.refresh_image_list() - # Bust decode cache so modified-on-disk files are re-decoded - self._bump_display_generation() + # Bust decode caches so modified-on-disk files are re-decoded. + # Invalidate only the files the watcher reported (a save produces a + # handful of events; the other ~hundreds of cached decodes are still + # valid). Fall back to the global display-generation bump when the + # change set is unattributable (None sentinel) or implausibly large. + # An EMPTY drain means a coalesced/duplicate debounce fire whose + # changes were already drained by an earlier fire — invalidate + # nothing, or every duplicate fire would re-decode the whole window. + if changed_paths: + if None not in changed_paths and len(changed_paths) <= 100: + for p in changed_paths: + self._invalidate_decoded_path(p) + else: + self._bump_display_generation() # Re-select the same image if it still exists, otherwise clamp if self.image_files and preserved_path: @@ -1426,6 +1679,38 @@ def _rebuild_ranges_from_paths(self, groups: List[List[Path]]) -> List[List[int] ranges.sort() return ranges + @staticmethod + def _shift_ranges_after_insert( + ranges: List[List[int]], insert_index: int + ) -> List[List[int]]: + """Shift index ranges after inserting one unselected image.""" + shifted: List[List[int]] = [] + for start, end in ranges: + if end < insert_index: + shifted.append([start, end]) + elif start >= insert_index: + shifted.append([start + 1, end + 1]) + else: + shifted.append([start, insert_index - 1]) + shifted.append([insert_index + 1, end + 1]) + return [r for r in shifted if r[0] <= r[1]] + + @staticmethod + def _duplicate_path_for(source: Path) -> Path: + """Return an unused duplicate filename next to source.""" + first = source.with_name(f"{source.stem}_duplicate{source.suffix}") + if not first.exists(): + return first + + counter = 2 + while True: + candidate = source.with_name( + f"{source.stem}_duplicate{counter}{source.suffix}" + ) + if not candidate.exists(): + return candidate + counter += 1 + def _reindex_after_save(self, saved_path: str) -> bool: """Re-derive current_index to point at *saved_path* after a save. @@ -1808,6 +2093,13 @@ def sync_ui_state(self): == self._get_current_live_preview_session_key() ): self._last_rendered_preview_gen = self.ui_refresh_generation + if ( + self._original_compare_preview is not None + and self._original_compare_index == self.current_index + and self._original_compare_session_key + == self._get_current_original_compare_session_key() + ): + self._original_compare_gen = self.ui_refresh_generation # Essential signals - emit immediately for responsive image display self.ui_state.currentIndexChanged.emit() @@ -1987,6 +2279,16 @@ def _get_current_live_preview_session_key( active_path_key, session_id, _revision = info return (active_path_key, session_id) + def _get_current_original_compare_session_key( + self, + ) -> Optional[tuple[str, Optional[str], int]]: + """Return the path/session/revision identity for held-space compare.""" + info = self._get_current_live_edit_session_info() + if info is None: + return None + active_path_key, session_id, revision = info + return (active_path_key, session_id, revision) + def _publish_last_rendered_preview_locked( self, decoded: DecodedImage, @@ -2142,6 +2444,548 @@ def _current_live_session_has_geometry_edits(self) -> bool: return False + @classmethod + def _json_safe_edit_value(cls, value: Any) -> Any: + if isinstance(value, np.generic): + return value.item() + if isinstance(value, tuple): + return [cls._json_safe_edit_value(v) for v in value] + if isinstance(value, list): + return [cls._json_safe_edit_value(v) for v in value] + if isinstance(value, dict): + return {str(k): cls._json_safe_edit_value(v) for k, v in value.items()} + return value + + @classmethod + def _serialize_editor_edits(cls, edits: dict[str, Any]) -> dict[str, Any]: + """Convert editor parameters into JSON-safe pending edit metadata.""" + serialized: dict[str, Any] = {} + for key, value in edits.items(): + if key == "darken_settings": + serialized[key] = ( + value.to_dict() if isinstance(value, DarkenSettings) else value + ) + else: + serialized[key] = cls._json_safe_edit_value(value) + return serialized + + def _deserialize_editor_edits(self, serialized: Any) -> dict[str, Any]: + edits = self.image_editor._initial_edits() + if not isinstance(serialized, dict): + return edits + + numeric_keys = { + "brightness", + "contrast", + "saturation", + "white_balance_by", + "white_balance_mg", + "sharpness", + "exposure", + "highlights", + "shadows", + "vibrance", + "vignette", + "blacks", + "whites", + "clarity", + "texture", + "straighten_angle", + } + for key in edits: + if key not in serialized: + continue + value = serialized[key] + try: + if key == "crop_box": + edits[key] = ( + self._normalize_crop_box_tuple(value) + if value is not None + else None + ) + elif key == "darken_settings": + edits[key] = ( + DarkenSettings.from_dict(value) + if isinstance(value, dict) + else None + ) + elif key == "rotation": + edits[key] = int(value) % 360 + elif key in numeric_keys: + edits[key] = float(value) + else: + edits[key] = value + except (TypeError, ValueError): + log.warning( + "Ignoring invalid pending edit value for %s: %r", key, value + ) + return edits + + @classmethod + def _serialize_mask_assets(cls, masks: Any) -> dict[str, Any]: + if not isinstance(masks, dict): + return {} + serialized: dict[str, Any] = {} + for mask_id, mask_data in masks.items(): + if isinstance(mask_data, MaskData): + serialized[str(mask_id)] = mask_data.to_dict() + elif isinstance(mask_data, dict): + serialized[str(mask_id)] = cls._json_safe_edit_value(mask_data) + return serialized + + @staticmethod + def _deserialize_mask_assets(serialized: Any) -> dict[str, MaskData]: + if not isinstance(serialized, dict): + return {} + masks: dict[str, MaskData] = {} + for mask_id, mask_data in serialized.items(): + if not isinstance(mask_data, dict): + continue + try: + masks[str(mask_id)] = MaskData.from_dict(mask_data) + except (TypeError, ValueError, KeyError): + log.warning("Ignoring invalid pending mask data for %s", mask_id) + return masks + + @staticmethod + def _serialize_auto_adjust_state( + state: Optional[ActiveAutoAdjustState], + ) -> Optional[dict[str, Any]]: + if state is None: + return None + return { + "base_blacks": float(state.base_blacks), + "base_whites": float(state.base_whites), + "p_low": float(state.p_low), + "p_high": float(state.p_high), + "base_vibrance": float(state.base_vibrance), + "auto_vibrance_delta": float(state.auto_vibrance_delta), + "base_brightness": float(state.base_brightness), + "auto_brightness_delta": float(state.auto_brightness_delta), + "extra_highlight_steps": int(state.extra_highlight_steps), + "extra_black_steps": int(state.extra_black_steps), + } + + def _serialize_current_auto_adjust_state(self) -> Optional[dict[str, Any]]: + if not self._has_valid_active_auto_adjust_state(): + return None + return self._serialize_auto_adjust_state(self._active_auto_adjust_state) + + def _deserialize_auto_adjust_state( + self, + serialized: Any, + *, + active_path: Path, + ) -> Optional[ActiveAutoAdjustState]: + if not isinstance(serialized, dict): + return None + try: + return ActiveAutoAdjustState( + active_path_key=self._key(active_path), + session_id=getattr(self.image_editor, "session_id", None), + base_blacks=float(serialized["base_blacks"]), + base_whites=float(serialized["base_whites"]), + p_low=float(serialized.get("p_low", 0.0)), + p_high=float(serialized.get("p_high", 255.0)), + base_vibrance=float(serialized.get("base_vibrance", 0.0)), + auto_vibrance_delta=float(serialized.get("auto_vibrance_delta", 0.0)), + base_brightness=float(serialized.get("base_brightness", 0.0)), + auto_brightness_delta=float( + serialized.get("auto_brightness_delta", 0.0) + ), + extra_highlight_steps=int(serialized.get("extra_highlight_steps", 0)), + extra_black_steps=int(serialized.get("extra_black_steps", 0)), + ) + except (KeyError, TypeError, ValueError): + log.warning("Ignoring invalid saved auto-adjust state: %r", serialized) + return None + + def _build_pending_edit_state(self, request: dict[str, Any]) -> dict[str, Any]: + snapshot = request.get("snapshot", {}) + context = request.get("context", {}) + session_token = context.get("session_token") + if isinstance(session_token, tuple): + session_token = list(session_token) + source_path = ( + context.get("edit_source_path") + or snapshot.get("source_filepath") + or snapshot.get("filepath_snapshot") + or "" + ) + return { + "version": 1, + "status": "pending_save", + "request_id": context.get("save_request_id"), + "revision": context.get("save_revision"), + "session_token": session_token, + "source_path": str(source_path), + "target_path": str(context.get("target") or ""), + "created_at": datetime.now().isoformat(timespec="seconds"), + "edits": self._serialize_editor_edits(snapshot.get("edits") or {}), + "masks": self._serialize_mask_assets(snapshot.get("mask_assets") or {}), + "auto_adjust": context.get("auto_adjust_state"), + } + + def _build_saved_edit_state_for_result( + self, + save_result: dict[str, Any], + *, + saved_path: Path, + backup_path: Optional[Path], + ) -> Optional[dict[str, Any]]: + request = save_result.get("request") or {} + state = self._build_pending_edit_state(request) + context = request.get("context", {}) + source_path = context.get("edit_source_path") or state.get("source_path") + if backup_path is not None: + try: + source_matches_saved = source_path is None or self._key( + Path(source_path) + ) == self._key(saved_path) + except (OSError, TypeError, ValueError): + source_matches_saved = False + if source_matches_saved: + source_path = str(backup_path) + + state["status"] = "saved" + state["source_path"] = str(source_path or backup_path or saved_path) + state["saved_at"] = datetime.now().isoformat(timespec="seconds") + if not self._edit_state_has_replayable_source(state, saved_path): + log.debug( + "Not storing saved edit state for %s; source is not replayable", + saved_path, + ) + return None + return state + + def _build_saved_edit_state_from_editor( + self, + *, + saved_path: Path, + backup_path: Optional[Path], + ) -> Optional[dict[str, Any]]: + with self.image_editor._lock: + edits = dict(self.image_editor.current_edits) + masks = dict(self.image_editor._mask_assets) + source_path = self.image_editor.source_filepath or saved_path + + if backup_path is not None: + try: + if self._key(source_path) == self._key(saved_path): + source_path = backup_path + except (OSError, TypeError, ValueError): + pass + + state = { + "version": 1, + "status": "saved", + "request_id": None, + "revision": int(getattr(self.image_editor, "_edits_rev", 0)), + "session_token": None, + "source_path": str(source_path), + "target_path": str(saved_path), + "saved_at": datetime.now().isoformat(timespec="seconds"), + "edits": self._serialize_editor_edits(edits), + "masks": self._serialize_mask_assets(masks), + "auto_adjust": self._serialize_current_auto_adjust_state(), + } + if not self._edit_state_has_replayable_source(state, saved_path): + log.debug( + "Not storing saved edit state for %s; source is not replayable", + saved_path, + ) + return None + return state + + def _edit_state_has_replayable_source( + self, edit_state: dict[str, Any], target_fallback: Optional[Path] + ) -> bool: + """Return true when edit parameters can be safely replayed from source.""" + source_raw = edit_state.get("source_path") + if not source_raw: + return False + + target_raw = edit_state.get("target_path") or target_fallback + if not target_raw: + return False + + source_path = Path(source_raw) + target_path = Path(target_raw) + if not source_path.is_absolute(): + source_path = self.image_dir / source_path + if not target_path.is_absolute(): + target_path = self.image_dir / target_path + + try: + if not source_path.exists(): + return False + source_key = self._key(source_path.resolve(strict=True)) + target_key = self._key(target_path.resolve(strict=False)) + return source_key != target_key + except (OSError, TypeError, ValueError): + return False + + def _write_pending_edit_state_for_request(self, request: dict[str, Any]) -> None: + context = request.get("context", {}) + sidecar = context.get("save_sidecar") or self.sidecar + metadata_path = context.get("save_metadata_path") + if sidecar is None or not metadata_path: + return + edit_state = self._build_pending_edit_state(request) + if not self._edit_state_has_replayable_source(edit_state, Path(metadata_path)): + log.debug( + "Skipping persisted pending edit state for %s; source is not a " + "durable path distinct from the save target", + metadata_path, + ) + return + try: + sidecar.update_metadata( + Path(metadata_path), + {"edit_state": edit_state}, + ) + except Exception: + log.exception("Failed to write pending edit state for %s", metadata_path) + + def _remember_pending_edit_save_request(self, request: dict[str, Any]) -> None: + save_image_key = request.get("context", {}).get("save_image_key") + if save_image_key: + self._pending_edit_save_requests[save_image_key] = request + + def _forget_pending_edit_save_request( + self, save_image_key: Optional[str], request_id: Optional[str] + ) -> None: + if not save_image_key or not request_id: + return + request = self._pending_edit_save_requests.get(save_image_key) + if request is None: + return + if request.get("context", {}).get("save_request_id") == request_id: + self._pending_edit_save_requests.pop(save_image_key, None) + + def _pending_edit_metadata_path(self, fallback: Optional[Path]) -> Optional[Path]: + if 0 <= self.current_index < len(self.image_files): + return self.image_files[self.current_index].path + return fallback + + def _get_sidecar_edit_state(self, image_path: Optional[Path]) -> Optional[dict]: + if image_path is None: + return None + meta = self.sidecar.get_metadata(image_path, create=False) + edit_state = getattr(meta, "edit_state", None) if meta is not None else None + return edit_state if isinstance(edit_state, dict) else None + + def _edit_source_path_from_state( + self, edit_state: Optional[dict], fallback: Path + ) -> Path: + if isinstance(edit_state, dict): + source_path = edit_state.get("source_path") + if source_path: + candidate = Path(source_path) + if not candidate.is_absolute(): + candidate = self.image_dir / candidate + try: + if candidate.exists(): + return candidate + except OSError: + pass + return fallback + + def _active_edit_load_path( + self, active_path: Path + ) -> tuple[Path, Optional[dict[str, Any]]]: + edit_state = self._get_pending_edit_state_for_loaded_path(active_path) + return self._edit_source_path_from_state(edit_state, active_path), edit_state + + def _cached_preview_for_load( + self, load_path: Path, active_path: Path + ) -> Optional[DecodedImage]: + """Display-cache buffer for seeding the editor's float_preview, or None. + + The decode cache holds the SAVED file's pixels. When the editor loads + from a distinct source (the backup) to replay persisted edits, seeding + float_preview from the saved pixels would apply the restored edits a + second time in every preview-sized render (e.g. crop-on-crop). In that + case load_image must rebuild the preview from the pixels it actually + loads, so return None. + """ + try: + same = self._key(load_path) == self._key(active_path) + except (OSError, TypeError, ValueError): + same = str(load_path) == str(active_path) + if not same: + return None + return self.get_decoded_image(self.current_index) + + def _bind_loaded_editor_output_path(self, active_path: Path) -> None: + try: + active_mtime = active_path.stat().st_mtime + except OSError: + active_mtime = 0.0 + with self.image_editor._lock: + self.image_editor.current_filepath = active_path + self.image_editor.current_mtime = active_mtime + + @staticmethod + def _float_source_to_pil(source_arr: np.ndarray) -> Image.Image: + arr_u8 = (np.clip(source_arr, 0.0, 1.0) * 255).astype(np.uint8) + return Image.fromarray(arr_u8, mode="RGB") + + def _restore_source_buffer_from_pending_request( + self, request: dict[str, Any], active_path: Path + ) -> bool: + snapshot = request.get("snapshot", {}) + source_arr = snapshot.get("source_arr") + if not isinstance(source_arr, np.ndarray): + return False + + restored_source = np.array(source_arr, dtype=np.float32, copy=True) + restored_original = self._float_source_to_pil(restored_source) + main_exif = snapshot.get("main_exif") + if main_exif: + restored_original.info["exif"] = main_exif + source_icc_bytes = snapshot.get("source_icc_bytes") + if source_icc_bytes: + restored_original.info["icc_profile"] = source_icc_bytes + + thumb = restored_original.copy() + thumb.thumbnail((1920, 1080)) + # float_preview is display-space by contract; cook the rebuilt preview + # like the prefetcher cooks cached previews (sRGB assumed when the + # snapshot carries no ICC profile). + preview_u8 = apply_loupe_color_correction( + np.asarray(thumb.convert("RGB"), dtype=np.uint8), + icc_bytes=restored_original.info.get("icc_profile"), + ) + restored_preview = preview_u8.astype(np.float32) + restored_preview *= np.float32(1.0 / 255.0) + + with self.image_editor._lock: + self.image_editor.current_filepath = Path( + snapshot.get("filepath_snapshot") or active_path + ) + self.image_editor.source_filepath = Path( + snapshot.get("source_filepath") + or snapshot.get("filepath_snapshot") + or active_path + ) + self.image_editor.original_image = restored_original + self.image_editor.float_image = restored_source + self.image_editor.float_preview = restored_preview + self.image_editor.bit_depth = int( + snapshot.get("bit_depth") or self.image_editor.bit_depth + ) + self.image_editor.current_mtime = float( + snapshot.get("current_mtime") or self.image_editor.current_mtime + ) + self.image_editor._source_exif_bytes = snapshot.get("source_exif") + self.image_editor._cached_preview = None + self.image_editor._cached_rev = -1 + return True + + def _get_pending_edit_state_for_loaded_path( + self, active_path: Path + ) -> Optional[dict[str, Any]]: + metadata_path = self._pending_edit_metadata_path(active_path) + if metadata_path is None: + return None + + try: + metadata_key = self._key(metadata_path) + except (OSError, TypeError, ValueError): + metadata_key = None + request = ( + self._pending_edit_save_requests.get(metadata_key) + if metadata_key is not None + else None + ) + if request is not None: + snapshot_path = request.get("snapshot", {}).get("filepath_snapshot") + if snapshot_path: + try: + if self._key(Path(snapshot_path)) != self._key(active_path): + request = None + except (OSError, TypeError, ValueError): + request = None + if request is not None: + return self._build_pending_edit_state(request) + + meta = self.sidecar.get_metadata(metadata_path, create=False) + edit_state = getattr(meta, "edit_state", None) if meta is not None else None + if not isinstance(edit_state, dict): + return None + if edit_state.get("status") not in ("pending_save", "saved"): + return None + + target_path = edit_state.get("target_path") + if target_path: + try: + if self._key(Path(target_path)) != self._key(active_path): + return None + except (OSError, TypeError, ValueError): + return None + if not self._edit_state_has_replayable_source(edit_state, active_path): + log.info( + "Ignoring edit state with non-replayable source for %s", + active_path, + ) + return None + return edit_state + + def _restore_pending_edit_state_for_loaded_path(self, active_path: Path) -> bool: + metadata_path = self._pending_edit_metadata_path(active_path) + request = None + if metadata_path is not None: + try: + metadata_key = self._key(metadata_path) + except (OSError, TypeError, ValueError): + metadata_key = None + if metadata_key is not None: + request = self._pending_edit_save_requests.get(metadata_key) + if request is not None: + snapshot_path = request.get("snapshot", {}).get("filepath_snapshot") + if snapshot_path: + try: + if self._key(Path(snapshot_path)) != self._key(active_path): + request = None + except (OSError, TypeError, ValueError): + request = None + + if request is not None: + self._restore_source_buffer_from_pending_request(request, active_path) + + edit_state = self._get_pending_edit_state_for_loaded_path(active_path) + if edit_state is None: + return False + + edits = self._deserialize_editor_edits(edit_state.get("edits")) + masks = self._deserialize_mask_assets(edit_state.get("masks")) + with self.image_editor._lock: + self.image_editor.current_edits = edits + self.image_editor._mask_assets.clear() + self.image_editor._mask_assets.update(masks) + self.image_editor._mask_raster_cache.clear() + self.image_editor._edits_rev += 1 + self.image_editor._cached_preview = None + self.image_editor._cached_rev = -1 + self.image_editor._cached_highlight_analysis = None + self.image_editor._cached_detail_bands = None + self.image_editor._cached_u8_lut = None + self.image_editor._cached_u8_wb_lut = None + + self._active_auto_adjust_state = self._deserialize_auto_adjust_state( + edit_state.get("auto_adjust"), + active_path=active_path, + ) + + log.debug( + "Restored %s edit state for %s (request %s)", + edit_state.get("status", "saved"), + active_path, + edit_state.get("request_id"), + ) + return True + def _should_render_live_preview_full_resolution(self) -> bool: """Use full-res live previews after committed geometry edits.""" if self.ui_state is None: @@ -2373,9 +3217,12 @@ def _ensure_active_image_loaded_for_auto_adjust( except (OSError, ValueError): paths_match = str(editor_path) == str(active_path) - has_buffers = ( - self.image_editor.original_image is not None - and self.image_editor.float_image is not None + # Auto-adjust sessions load preview_only, so accept a session that has + # only the float preview; the full float master is materialized lazily + # by _ensure_float_image() (pre-warmed in the background below). + has_buffers = self.image_editor.original_image is not None and ( + self.image_editor.float_image is not None + or self.image_editor.float_preview is not None ) if paths_match and has_buffers: if self._has_valid_active_auto_adjust_state(): @@ -2396,23 +3243,79 @@ def _ensure_active_image_loaded_for_auto_adjust( self._ensure_live_edit_session_state() return active_path - cached_preview = self.get_decoded_image(self.current_index) + load_path, _edit_state = self._active_edit_load_path(active_path) + cached_preview = self._cached_preview_for_load(load_path, active_path) + # preview_only skips the ~400ms full-resolution float conversion that + # would otherwise block the UI thread on the first auto-adjust + # keypress; analysis only needs the preview-sized buffer. if self.image_editor.load_image( - str(active_path), + str(load_path), cached_preview=cached_preview, source_exif=self._capture_source_exif_for_active_image(), + preview_only=True, ): + self._bind_loaded_editor_output_path(active_path) + self._restore_pending_edit_state_for_loaded_path(active_path) self._ensure_live_edit_session_state(force_reset=True) + self._prewarm_editor_float_image() return active_path self.update_status_message("Failed to load image") return None + def _prewarm_editor_float_image(self) -> None: + """Materialize the editor's float master off the UI thread. + + After a preview_only load, the first consumer of float_image would + otherwise pay the full-resolution conversion inline — and + snapshot_for_export runs on the main thread. _ensure_float_image is + thread-safe and no-ops if the master already exists or the image + changed underneath it. + """ + editor = self.image_editor + if editor is None: + return + + with editor._lock: + session_id = editor.session_id + current_filepath = editor.current_filepath + + with self._editor_prewarm_lock: + future = self._editor_prewarm_future + if future is not None and not future.done(): + return + + def _job() -> None: + try: + with editor._lock: + if ( + editor.session_id != session_id + or editor.current_filepath != current_filepath + ): + return + editor._ensure_float_image() + except Exception: + pass # editor cleared/reloaded before the worker ran + + def _clear_future(_future: concurrent.futures.Future) -> None: + with self._editor_prewarm_lock: + if self._editor_prewarm_future is _future: + self._editor_prewarm_future = None + + try: + future = self._editor_prewarm_executor.submit(_job) + except RuntimeError: + return # shutting down + + self._editor_prewarm_future = future + future.add_done_callback(_clear_future) + def _compute_auto_levels_recommendation(self) -> dict[str, Any]: """Compute the current baseline auto-level recommendation.""" blacks, whites, p_low, p_high = self.image_editor.analyze_auto_levels( self.auto_level_threshold, reset_levels=True, + channel_budget=self.auto_level_channel_budget, ) dynamic_range = p_high - p_low @@ -2447,9 +3350,49 @@ def _compute_auto_levels_recommendation(self) -> dict[str, Any]: elif p_low <= 0.0 and p_high >= 255.0: noop_reason = "already full range" + # Midtone correction: an endpoint stretch leaves a full-range but + # underexposed (or overexposed) image untouched. When the projected + # post-stretch median luma falls outside the dead band around the + # target, nudge brightness partway back toward the nearest band edge. + # A well-exposed image inside the band is left alone (normalizing every + # photo to one brightness made "most images too bright"); images that + # are corrected only recover partway, preserving intentional low-key / + # high-key character. The factor is capped so the auto result stays + # conservative; the levels soft knee protects highlights from the lift. + base_brightness = 0.0 + if self.auto_level_midtone and dynamic_range >= 1.0: + stats = getattr(self.image_editor, "last_auto_levels_stats", {}) + median_luma = float(stats.get("median_luma", 0.0)) + if median_luma > 0.02: + bp = -base_blacks * 0.15 + wp = 1.0 - base_whites * 0.15 + span = max(wp - bp, 1e-4) + target = max(0.2, min(0.6, self.auto_level_midtone_target)) + # Median luma projected through the levels stretch. + projected = (median_luma - bp) / span + band_low = target - _AUTO_MIDTONE_DEADBAND + band_high = target + _AUTO_MIDTONE_DEADBAND + desired = None + if 0.02 < projected < band_low: + desired = projected + _AUTO_MIDTONE_RECOVERY * ( + band_low - projected + ) + elif projected > band_high: + desired = projected + _AUTO_MIDTONE_RECOVERY * ( + band_high - projected + ) + if desired is not None: + # Brightness multiplies before the levels ramp, so solve + # (median * factor - bp) / span = desired for factor. + factor = (desired * span + bp) / median_luma + factor = max(0.85, min(1.3, factor)) + if abs(factor - 1.0) >= 0.02: + base_brightness = factor - 1.0 + return { "base_blacks": base_blacks, "base_whites": base_whites, + "base_brightness": base_brightness, "p_low": p_low, "p_high": p_high, "dynamic_range": dynamic_range, @@ -2477,6 +3420,8 @@ def _format_auto_levels_detail( whites: float, extra_highlight_steps: int = 0, extra_black_steps: int = 0, + auto_vibrance_delta: float = 0.0, + auto_brightness_delta: float = 0.0, ) -> str: """Build a human-readable status line for the current levels state.""" if abs(blacks) <= 0.001 and abs(whites) <= 0.001: @@ -2512,6 +3457,10 @@ def _format_auto_levels_detail( suffixes.append(f"blacks -{extra_black_steps * black_step_points}pt") elif extra_black_steps < 0: suffixes.append(f"blacks +{-extra_black_steps * black_step_points}pt") + if auto_vibrance_delta > _AUTO_VIBRANCE_EPS: + suffixes.append(f"vibrance +{auto_vibrance_delta:.2f}") + if abs(auto_brightness_delta) > _AUTO_BRIGHTNESS_EPS: + suffixes.append(f"midtone {auto_brightness_delta:+.2f}") if suffixes: msg = f"{msg}; {', '.join(suffixes)}" return msg @@ -2537,9 +3486,31 @@ def _apply_levels_to_editor( self.update_histogram() return changed + def _apply_vibrance_to_editor( + self, + *, + vibrance: float, + ) -> bool: + """Apply derived vibrance to the editor and synchronize the UI state.""" + changed = self.image_editor.set_edit_param("vibrance", vibrance) + self.ui_state.vibrance = vibrance + return changed + + def _apply_brightness_to_editor( + self, + *, + brightness: float, + ) -> bool: + """Apply derived brightness to the editor and synchronize the UI state.""" + changed = self.image_editor.set_edit_param("brightness", brightness) + self.ui_state.brightness = brightness + return changed + def _build_active_auto_adjust_state( self, recommendation: dict[str, Any], + *, + include_auto_vibrance: bool = False, ) -> ActiveAutoAdjustState: """Create transient auto-adjust state for the current loaded session.""" active_path = self._get_current_auto_adjust_path() @@ -2548,6 +3519,36 @@ def _build_active_auto_adjust_state( if active_path is not None else self._key(self.image_editor.current_filepath) ) + try: + current_vibrance = float(self.image_editor.get_edit_value("vibrance", 0.0)) + except (TypeError, ValueError): + current_vibrance = 0.0 + auto_vibrance_delta = 0.0 + if include_auto_vibrance and self.auto_vibrance_enabled: + auto_vibrance_delta = self.image_editor.analyze_auto_vibrance( + blacks=float(recommendation["base_blacks"]), + whites=float(recommendation["base_whites"]), + ) + + base_vibrance = max( + -1.0, + min(1.0, current_vibrance + auto_vibrance_delta), + ) + auto_vibrance_delta = base_vibrance - current_vibrance + + # Midtone brightness: only auto-set when the user has not touched the + # brightness slider themselves, mirroring the vibrance behavior. + try: + current_brightness = float( + self.image_editor.get_edit_value("brightness", 0.0) + ) + except (TypeError, ValueError): + current_brightness = 0.0 + base_brightness = current_brightness + if abs(current_brightness) <= 0.001: + base_brightness = float(recommendation.get("base_brightness", 0.0)) + auto_brightness_delta = base_brightness - current_brightness + return ActiveAutoAdjustState( active_path_key=active_path_key, session_id=getattr(self.image_editor, "session_id", None), @@ -2555,6 +3556,10 @@ def _build_active_auto_adjust_state( base_whites=float(recommendation["base_whites"]), p_low=float(recommendation["p_low"]), p_high=float(recommendation["p_high"]), + base_vibrance=base_vibrance, + auto_vibrance_delta=auto_vibrance_delta, + base_brightness=base_brightness, + auto_brightness_delta=auto_brightness_delta, ) def _save_current_auto_adjust( @@ -2602,6 +3607,10 @@ def _save_current_auto_adjust( metadata_before = self._mark_image_edited_in_sidecar( self.sidecar, metadata_path, + saved_edit_state=self._build_saved_edit_state_from_editor( + saved_path=saved_path, + backup_path=backup_path, + ), ) self.undo_history.append( ( @@ -2627,9 +3636,9 @@ def _save_current_auto_adjust( self.refresh_image_list() self._reindex_after_save(saved_path) - self._bump_display_generation() - self.image_cache.pop_path(saved_path) - self.prefetcher.cancel_all() + # Only the saved file's pixels changed; per-path invalidation keeps + # the rest of the decode cache and the in-flight prefetch window warm. + self._invalidate_decoded_path(saved_path, force=True) self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() @@ -2678,10 +3687,25 @@ def _prepare_current_session_save_request( ) -> Optional[dict[str, Any]]: """Capture an immutable save request for the current live editor session.""" self._last_save_prepare_error = None - if self._crop_mode_has_saved_geometry or ( - self.ui_state and getattr(self.ui_state, "isCropping", False) is True - ): - self._cancel_crop_transaction_for_session_boundary() + if self.ui_state and getattr(self.ui_state, "isCropping", False) is True: + self._last_save_prepare_error = "Apply or cancel the crop before saving" + self.update_status_message(self._last_save_prepare_error) + return None + if self._crop_mode_has_saved_geometry: + try: + self._restore_crop_mode_geometry() + except Exception: + log.exception("Failed to restore stale crop transaction before save") + self._last_save_prepare_error = ( + "Failed to restore crop state before saving" + ) + self.update_status_message( + self._last_save_prepare_error, + timeout=5000, + ) + return None + finally: + self._clear_crop_mode_snapshot() if not self.image_editor.original_image: return None @@ -2762,7 +3786,14 @@ def _prepare_current_session_save_request( "save_action_type": "save_edit", "save_image_key": save_image_key, "save_revision": save_revision, + "save_request_id": uuid.uuid4().hex, "session_token": session_token, + "edit_source_path": str( + export_snapshot.get("source_filepath") + or export_snapshot.get("filepath_snapshot") + or "" + ), + "auto_adjust_state": self._serialize_current_auto_adjust_state(), "save_directory_key": self._key(self.image_dir), "save_metadata_path": ( str(save_metadata_path) if save_metadata_path else None @@ -2793,6 +3824,7 @@ def _submit_save_request_async( ): return False + self._suppress_watcher_paths(target) self._increment_save_tracking(target=target, save_image_key=save_image_key) self._mark_current_live_edit_session_submitted(context["save_revision"]) self.ui_state.isSaving = True @@ -2843,6 +3875,9 @@ def on_done(future): self.update_status_message(f"Failed to start background save: {e}") return False + self._remember_pending_edit_save_request(request) + self._write_pending_edit_state_for_request(request) + try: future.add_done_callback(on_done) except Exception as e: @@ -2892,6 +3927,7 @@ def _run_save_request_sync( ) return False + self._suppress_watcher_paths(target) self._increment_save_tracking(target=target, save_image_key=save_image_key) self._mark_current_live_edit_session_submitted(context["save_revision"]) self.ui_state.isSaving = True @@ -2925,6 +3961,34 @@ def _run_save_request_sync( self._on_save_finished(save_result) return bool(save_result.get("success")) + def _seed_decode_cache_from_live_preview(self, path) -> None: + """Keep the just-edited pixels visible while their save is in flight. + + Navigation clears the live edit session, so the provider falls back to + the decode cache — which still holds the PRE-EDIT pixels until the + background save completes and the path is invalidated. Seeding the + cache with the last rendered live preview means navigating back shows + the edited image immediately instead of flashing the original; the + save-completion invalidation then replaces it with the real decode. + """ + if path is None or self.is_zoomed: + return + path = Path(path) + with self._preview_lock: + decoded = self._last_rendered_preview + session_key = self._last_rendered_preview_session_key + if decoded is None or session_key is None: + return + try: + if session_key[0] != self._key(path): + return + except (OSError, TypeError, ValueError): + return + self._mark_optimistic_decode_seed_epoch(path) + self.prefetcher.invalidate_path(path) + _, _, display_gen = self.get_display_info() + self.image_cache[build_cache_key(path, display_gen)] = decoded + def _flush_current_live_edit_session_for_navigation(self) -> bool: """Queue a background save for the current dirty session before switching images.""" request = self._prepare_current_session_save_request( @@ -2933,6 +3997,9 @@ def _flush_current_live_edit_session_for_navigation(self) -> bool: ) if request is None: return self._last_save_prepare_error is None + self._seed_decode_cache_from_live_preview( + request.get("context", {}).get("target") + ) return self._submit_save_request_async(request) def _flush_current_live_edit_session_for_drag( @@ -3085,16 +4152,21 @@ def _on_save_finished(self, save_result: dict): ) else: self.update_status_message(f"Save failed: {error_msg}", timeout=5000) + # Drop the optimistic live-preview cache seed (if any) so the + # display falls back to the file's actual pixels. + if target: + self._invalidate_decoded_path(target, force=True) return - # Success — drop any prior recovery snapshot for this target. - target = save_result.get("target") - if target and target in self._pending_save_recovery: - self._pending_save_recovery.pop(target, None) - result = save_result.get("result") if isinstance(result, tuple) and len(result) == 2: saved_path, backup_path = result + # Success — drop any prior recovery snapshot for this target and + # keep the watcher quiet for the save-generated file churn. + target = save_result.get("target") + if target and target in self._pending_save_recovery: + self._pending_save_recovery.pop(target, None) + self._suppress_watcher_paths(target, saved_path) # --- Post-Save Cleanup --- @@ -3135,8 +4207,16 @@ def _on_save_finished(self, save_result: dict): if save_result.get("save_metadata_path") else saved_path ) + saved_edit_state = self._build_saved_edit_state_for_result( + save_result, + saved_path=saved_path, + backup_path=backup_path, + ) metadata_before = self._mark_image_edited_in_sidecar( - save_sidecar, metadata_path + save_sidecar, + metadata_path, + completed_edit_state_request_id=save_result.get("save_request_id"), + saved_edit_state=saved_edit_state, ) save_directory_key = save_result.get("save_directory_key") current_directory_key = self._key(self.image_dir) @@ -3171,10 +4251,11 @@ def _on_save_finished(self, save_result: dict): self.refresh_image_list() if still_on_same_session: - # Still viewing the saved image — pin index and force sync + # Still viewing the saved image — pin index and force sync. + # Only the saved file's pixels changed; invalidate it alone + # instead of clearing the whole decode cache. self._reindex_after_save(saved_path) - self.image_cache.clear() - self.prefetcher.cancel_all() + self._invalidate_decoded_path(saved_path, force=True) self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() else: @@ -3182,11 +4263,24 @@ def _on_save_finished(self, save_result: dict): if preserved_path: self._reindex_after_save(preserved_path) if saved_path: - self.image_cache.pop_path(saved_path) + self._invalidate_decoded_path(saved_path, force=True) + + # Auto-add to batch if enabled + if saved_path: + auto_add_path = ( + Path(save_result["save_metadata_path"]) + if save_result.get("save_metadata_path") + else Path(saved_path) + ) + self._auto_add_edited_to_batch_if_enabled(auto_add_path) if self.ui_state: self.ui_state.variantBadgesChanged.emit() + self._forget_pending_edit_save_request( + save_key, save_result.get("save_request_id") + ) + success_message = save_result.get("success_message") if success_message: self.update_status_message(success_message) @@ -3239,6 +4333,7 @@ def _set_current_index( if self.current_edit_source_mode != new_mode: self.current_edit_source_mode = new_mode self.editSourceModeChanged.emit(new_mode) + self.rawDevelopmentStateChanged.emit() if self.debug_cache: _t_prefetch = time.perf_counter() @@ -3576,6 +4671,110 @@ def delete_current_image(self): # 2. Otherwise default to single image deletion self._delete_indices([self.current_index], "loupe") + @Slot() + def duplicate_current_image(self): + """Copy the current visible image and insert the copy after it.""" + if not self.image_files: + self.update_status_message("No image to duplicate.") + return + + if not (0 <= self.current_index < len(self.image_files)): + self.update_status_message("No image to duplicate.") + return + + if self._filter_enabled: + self.update_status_message("Clear filters before duplicating.") + return + + if self.ui_state and getattr(self.ui_state, "isCropping", False) is True: + self.update_status_message( + "Apply/save or discard edits before duplicating." + ) + return + + if self.has_unsaved_edits(): + self.update_status_message( + "Apply/save or discard edits before duplicating." + ) + return + + source_image = self.image_files[self.current_index] + source_path = ( + Path(self.view_override_path) + if self.view_override_path + else source_image.path + ) + if self._block_if_saving(source_path): + return + + if not source_path.exists(): + self.update_status_message("Current image file is missing.") + return + + duplicate_path = self._duplicate_path_for(source_path) + + try: + shutil.copy2(source_path, duplicate_path) + stat = duplicate_path.stat() + except OSError as exc: + log.exception("Failed to duplicate image %s", source_path) + self.update_status_message(f"Duplicate failed: {exc}") + return + + duplicate_image = ImageFile( + path=duplicate_path, + raw_pair=None, + timestamp=stat.st_mtime, + ) + insert_index = self.current_index + 1 + self.image_files.insert(insert_index, duplicate_image) + + all_insert_index = len(self._all_images) + for idx, img in enumerate(self._all_images): + if self._key(img.path) == self._key(source_image.path): + all_insert_index = idx + 1 + break + self._all_images.insert(all_insert_index, duplicate_image) + + if self.batches: + self.batches = self._shift_ranges_after_insert(self.batches, insert_index) + self._invalidate_batch_cache() + if ( + self.batch_start_index is not None + and self.batch_start_index >= insert_index + ): + self.batch_start_index += 1 + + stacks_changed = False + if self.stacks: + old_stacks = [s[:] for s in self.stacks] + self.stacks = self._shift_ranges_after_insert(self.stacks, insert_index) + stacks_changed = self.stacks != old_stacks + if ( + self.stack_start_index is not None + and self.stack_start_index >= insert_index + ): + self.stack_start_index += 1 + if stacks_changed: + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + + self.prefetcher.set_image_files(self.image_files) + self._rebuild_path_to_index() + clear_raw_count_cache() + self._grid_model_dirty = True + + if self._thumbnail_model and self._is_grid_view_active: + self._refresh_thumbnail_model_from_controller() + + self.dataChanged.emit() + if stacks_changed: + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + self.update_status_message(f"Duplicated image as {duplicate_path.name}") + log.info("Duplicated image %s -> %s", source_path, duplicate_path) + @Slot(int) def grid_delete_at_cursor(self, cursor_index: int): """Unified grid deletion entry point. @@ -3640,7 +4839,9 @@ def _on_thumbnail_ready_gui(self, thumbnail_id: str): def _get_metadata_dict(self, image_path: Path | str) -> dict: """Get metadata for an image path as a dict for thumbnail model.""" try: - meta = self.sidecar.get_metadata(image_path, create=False) + # migrate=False: this runs per image on grid refresh; the legacy + # migration scan costs O(entries) filesystem stats per miss. + meta = self.sidecar.get_metadata(image_path, create=False, migrate=False) if meta is None: return { "stacked": False, @@ -3684,7 +4885,9 @@ def _get_bulk_metadata_map(self, images=None) -> Dict[str, dict]: bulk_map = {} for img in (images if images is not None else self.image_files): try: - meta = self.sidecar.get_metadata(img.path, create=False) + # migrate=False: see _get_metadata_dict — most images have no + # entry, and the migration scan is O(entries) stats per miss. + meta = self.sidecar.get_metadata(img.path, create=False, migrate=False) if meta is None: continue bulk_map[str(img.path)] = { @@ -3718,6 +4921,7 @@ def _normalize_batches(self) -> None: """ if not self.batches: return + self.batches = [[int(start), int(end)] for start, end in self.batches] self.batches.sort() merged: List[List[int]] = [self.batches[0]] for current_start, current_end in self.batches[1:]: @@ -3932,7 +5136,11 @@ def get_current_metadata(self) -> Dict: # Compute and cache image_path = self.image_files[self.current_index].path - meta = self.sidecar.get_metadata(image_path, create=False) + # migrate=False: this is a read-only display lookup that runs once per + # navigation step; the legacy-key migration scan would otherwise do + # O(sidecar entries) filesystem checks for every image that has no + # entry — hundreds of stats per keypress while holding an arrow key. + meta = self.sidecar.get_metadata(image_path, create=False, migrate=False) stack_info = self._get_stack_info(self.current_index) batch_info = self._get_batch_info(self.current_index) @@ -3946,7 +5154,7 @@ def get_current_metadata(self) -> Dict: # EXIF brief: instant from cache, otherwise schedule deferred read. # Always read EXIF from the variant group's main image (not backup/developed). - exif_key = self._exif_source_key(self.current_index) + exif_key = self._exif_brief_context_key(self.current_index) exif_brief = self._exif_brief_cache.get(exif_key, None) if exif_brief is None: exif_brief = "" @@ -3980,13 +5188,8 @@ def get_current_metadata(self) -> Dict: {".jpg", ".jpeg", ".jpe", ".tif", ".tiff", ".heif", ".heic"} ) - def _exif_source_key(self, index: int) -> str: - """Return a normalized cache key for the EXIF source of image at *index*. - - Resolves to the variant group's main image path when available, - so backups/developed files use the same EXIF as the original. - Uses ``normalize_path_key`` for Windows case-insensitivity. - """ + def _exif_source_path(self, index: int) -> Path: + """Return the file path used as the EXIF source of image at *index*.""" img = self.image_files[index] source_path = img.path key_cf = get_group_key_for_path(img.path, self._variant_map) @@ -3994,15 +5197,30 @@ def _exif_source_key(self, index: int) -> str: group = self._variant_map[key_cf] if group.main_path is not None: source_path = group.main_path + return source_path + + def _exif_source_key(self, index: int) -> str: + """Return a normalized cache key for the EXIF source of image at *index*. + + Resolves to the variant group's main image path when available, + so backups/developed files use the same EXIF as the original. + Uses ``normalize_path_key`` for Windows case-insensitivity. + """ + source_path = self._exif_source_path(index) return normalize_path_key(source_path) + def _exif_brief_context_key(self, index: int) -> tuple[str, str]: + """Return the EXIF brief cache key for current image and its neighbor.""" + previous_key = self._exif_source_key(index - 1) if index > 0 else "" + return self._exif_source_key(index), previous_key + def _read_exif_deferred(self): """Called after 150ms of no navigation. Submits EXIF read to background.""" if getattr(self, "_shutting_down", False): return if not self.image_files or self.current_index >= len(self.image_files): return - exif_key = self._exif_source_key(self.current_index) + exif_key = self._exif_brief_context_key(self.current_index) if self._exif_pending_path is not None and self._exif_pending_path != exif_key: self._exif_pending_path = None return # Different image path pending, or we aren't tracking this key. @@ -4010,14 +5228,12 @@ def _read_exif_deferred(self): if exif_key in self._exif_brief_cache: self._exif_pending_path = None return # already cached - # Resolve the actual file path for the EXIF source - img = self.image_files[self.current_index] - source_path = img.path - key_cf = get_group_key_for_path(img.path, self._variant_map) - if key_cf is not None: - group = self._variant_map[key_cf] - if group.main_path is not None: - source_path = group.main_path + source_path = self._exif_source_path(self.current_index) + previous_path = ( + self._exif_source_path(self.current_index - 1) + if self.current_index > 0 + else None + ) # Early return for formats without EXIF support if source_path.suffix.lower() not in self._EXIF_SUFFIXES: self._exif_brief_cache[exif_key] = "" @@ -4026,11 +5242,15 @@ def _read_exif_deferred(self): # Submit to dedicated EXIF executor to avoid blocking histograms signal = self._exifBriefReady - def _worker(key=exif_key, p=str(source_path)): + def _worker( + key=exif_key, + p=str(source_path), + previous=str(previous_path) if previous_path is not None else None, + ): from faststack.imaging.metadata import get_exif_brief try: - brief = get_exif_brief(p) + brief = get_exif_brief(p, previous) except Exception as e: log.error("Failed to get EXIF brief for %s: %s", p, e, exc_info=True) brief = "" @@ -4041,7 +5261,7 @@ def _worker(key=exif_key, p=str(source_path)): except RuntimeError: pass # executor shut down, ignore - def _on_exif_brief_ready(self, exif_key: str, brief: str): + def _on_exif_brief_ready(self, exif_key: tuple[str, str], brief: str): """Slot called on main thread when background EXIF read completes.""" if getattr(self, "_shutting_down", False) or not self.ui_state: return @@ -4052,7 +5272,7 @@ def _on_exif_brief_ready(self, exif_key: str, brief: str): if ( self.image_files and self.current_index < len(self.image_files) - and self._exif_source_key(self.current_index) == exif_key + and self._exif_brief_context_key(self.current_index) == exif_key ): self._metadata_cache_index = (-1, -1) if self.ui_state: @@ -4167,8 +5387,8 @@ def grid_add_selection_to_batch(self): self.dataChanged.emit() self.sync_ui_state() - # Refresh grid to show batch badges - self._thumbnail_model.refresh() + # Re-announce batch badges (no model reset needed) + self._thumbnail_model.notify_batch_state_changed() self.update_status_message(f"Added {added_count} image(s) to batch") log.info("Added %d image(s) to batch from grid selection", added_count) @@ -4208,7 +5428,7 @@ def add_favorites_to_batch(self): self.sync_ui_state() if hasattr(self, "_thumbnail_model") and self._thumbnail_model: - self._thumbnail_model.refresh() + self._thumbnail_model.notify_batch_state_changed() self.update_status_message( f"Added {added_count} favorite(s) to batch ({len(indices_to_add)} total favorites)" @@ -4252,7 +5472,7 @@ def add_uploaded_to_batch(self): self.sync_ui_state() if hasattr(self, "_thumbnail_model") and self._thumbnail_model: - self._thumbnail_model.refresh() + self._thumbnail_model.notify_batch_state_changed() self.update_status_message( f"Added {added_count} uploaded image(s) to batch ({len(indices_to_add)} total uploaded)" @@ -4294,7 +5514,7 @@ def add_edited_to_batch(self): self.sync_ui_state() if hasattr(self, "_thumbnail_model") and self._thumbnail_model: - self._thumbnail_model.refresh() + self._thumbnail_model.notify_batch_state_changed() self.update_status_message( f"Added {added_count} edited image(s) to batch ({len(indices_to_add)} total edited)" @@ -4305,6 +5525,51 @@ def add_edited_to_batch(self): f"All {len(indices_to_add)} edited image(s) already in batch." ) + def _auto_add_edited_to_batch_if_enabled(self, image_path: Path): + """If auto-add is enabled, add the edited image at this path to the batch.""" + if not self.ui_state.autoAddEditedToBatch: + return + + if not self.image_files: + return + + try: + image_key = self.sidecar.metadata_key_for_path(image_path) + except (OSError, TypeError, ValueError): + image_key = None + + # The edited image is almost always the current one; checking it first + # avoids paying a per-image sidecar-key lookup across the whole folder + # on every quick auto-adjust keystroke. + indices = list(range(len(self.image_files))) + if 0 <= self.current_index < len(indices): + indices.insert(0, indices.pop(self.current_index)) + for i in indices: + img = self.image_files[i] + try: + matches = ( + image_key is not None + and self.sidecar.metadata_key_for_path(img.path) == image_key + ) + except (OSError, TypeError, ValueError): + matches = self._key(img.path) == self._key(image_path) + + if matches: + in_batch = any(start <= i <= end for start, end in self.batches) + if not in_batch: + self.batches.append([i, i]) + self._normalize_batches() + self._invalidate_batch_cache() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + if hasattr(self, "_thumbnail_model") and self._thumbnail_model: + self._thumbnail_model.notify_batch_state_changed() + + log.info("Auto-added edited image to batch: %s", image_path.name) + break + def remove_from_batch_or_stack(self): """Remove current image from any batch or stack it's in.""" if not self.image_files or self.current_index >= len(self.image_files): @@ -4635,6 +5900,14 @@ def _snapshot_crop_mode_geometry(self) -> None: self.image_editor, "session_id", None ) self._crop_mode_has_saved_geometry = True + log.debug( + "Crop snapshot: crop=%s angle=%.4f rotation=%s path=%s session=%s", + self._crop_mode_saved_crop_box, + self._crop_mode_saved_straighten_angle, + self._crop_mode_saved_rotation, + self._crop_mode_saved_path_key, + self._crop_mode_saved_session_id, + ) def _restore_crop_mode_geometry(self) -> bool: if not self._crop_mode_has_saved_geometry: @@ -4697,16 +5970,15 @@ def _restore_crop_mode_geometry(self) -> bool: self._set_crop_overlay_box_only(overlay_box) if hasattr(self.ui_state, "cropRotation"): self.ui_state.cropRotation = 0.0 + log.debug( + "Crop restore: crop=%s angle=%.4f rotation=%s changed=%s", + saved_crop_box, + saved_angle, + saved_rotation, + changed, + ) return True - def _cancel_crop_transaction_for_session_boundary(self) -> None: - if self._crop_mode_has_saved_geometry: - self._restore_crop_mode_geometry() - if self.ui_state and getattr(self.ui_state, "isCropping", False) is True: - self.ui_state.isCropRotating = False - self.ui_state.isCropping = False - self._clear_crop_mode_snapshot() - def _reset_crop_only(self, *, clear_crop_transaction: bool = True): """Resets crop settings (crop box and straighten) to default and exits crop mode, PRESERVING 90-deg rotation.""" if clear_crop_transaction: @@ -4926,6 +6198,13 @@ def set_theme(self, theme_index): # tell QML it changed (once is enough) self.ui_state.themeChanged.emit() + def save_config(self): + """Save current UI state config values.""" + config.set( + "core", "auto_add_edited_to_batch", self.ui_state.autoAddEditedToBatch + ) + config.save() + @Slot(result=str) def get_color_mode(self): """Returns current color management mode: 'none', 'saturation', or 'icc'.""" @@ -5171,6 +6450,16 @@ def set_awb_mode(self, mode): def get_awb_strength(self): return config.getfloat("awb", "strength") + @Slot(result=float) + def get_awb_tint_damp(self): + return config.getfloat("awb", "tint_damp", 0.6) + + @Slot(float) + def set_awb_tint_damp(self, value): + value = max(0.0, min(1.0, value)) + config.set("awb", "tint_damp", f"{value:.6g}") + config.save() + @Slot(float) def set_awb_strength(self, value): config.set("awb", "strength", value) @@ -5196,6 +6485,13 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): if not self.image_editor.original_image: return + # During a crop transaction the QML rotate knob reports an angle + # relative to the displayed image, which already has any committed + # straighten baked in. Compose them so re-straightening an + # already-straightened image doesn't silently discard the old angle. + if self.ui_state.isCropping and self._crop_mode_has_saved_geometry: + angle = self._crop_mode_saved_straighten_angle + angle + # log.info(f"AppController.set_straighten_angle: {angle}, AR: {target_aspect_ratio}") # Update Aspect Ratio Compensation for Crop Box @@ -5262,8 +6558,12 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): right = 1000 left = max(0, left) - self.ui_state.currentCropBox = (left, top, right, bottom) - self.image_editor.set_crop_box((left, top, right, bottom)) + crop_box = (left, top, right, bottom) + if self.ui_state.isCropping: + self._set_crop_overlay_box_only(crop_box) + else: + self.ui_state.currentCropBox = crop_box + self.image_editor.set_crop_box(crop_box) log.debug("AppController.set_straighten_angle: %s", angle) # Pass the angle as-is (degrees CW). @@ -5276,6 +6576,8 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): # Crucially, sync_ui_state emits currentImageSourceChanged, forcing QML to reload. # self.display_generation += 1 # self.sync_ui_state() # DISABLE TO PREVENT FLASHING - QML handles preview live + if self.ui_state.isCropping: + self._kick_preview_worker() @Slot(result=int) def get_awb_warm_bias(self): @@ -5392,6 +6694,88 @@ def set_auto_level_strength_auto(self, value): config.set("core", "auto_level_strength_auto", "true" if value else "false") config.save() + @Slot(result=bool) + def get_auto_vibrance_enabled(self): + return self.auto_vibrance_enabled + + @Slot(bool) + def set_auto_vibrance_enabled(self, value): + self.auto_vibrance_enabled = bool(value) + config.set( + "core", + "auto_vibrance_enabled", + "true" if self.auto_vibrance_enabled else "false", + ) + config.save() + + @Slot(result=bool) + def get_auto_level_midtone(self): + return self.auto_level_midtone + + @Slot(bool) + def set_auto_level_midtone(self, value): + self.auto_level_midtone = bool(value) + config.set( + "core", + "auto_level_midtone", + "true" if self.auto_level_midtone else "false", + ) + config.save() + + @Slot(result=float) + def get_auto_level_midtone_target(self): + return self.auto_level_midtone_target + + @Slot(float) + def set_auto_level_midtone_target(self, value): + value = max(0.2, min(0.6, value)) + self.auto_level_midtone_target = value + config.set("core", "auto_level_midtone_target", f"{value:.6g}") + config.save() + + @Slot(result=float) + def get_auto_level_channel_budget(self): + return self.auto_level_channel_budget + + @Slot(float) + def set_auto_level_channel_budget(self, value): + value = max(1.0, min(10.0, value)) + self.auto_level_channel_budget = value + config.set("core", "auto_level_channel_budget", f"{value:.6g}") + config.save() + + @Slot(result=bool) + def get_levels_soft_knee(self): + return self.levels_soft_knee + + @Slot(bool) + def set_levels_soft_knee(self, value): + self.levels_soft_knee = bool(value) + self.image_editor.levels_soft_knee = self.levels_soft_knee + config.set( + "core", + "levels_soft_knee", + "true" if self.levels_soft_knee else "false", + ) + config.save() + # Levels rendering changed; refresh the live preview if edits exist. + self._kick_preview_worker() + + @Slot(result=bool) + def get_export_dither(self): + return self.export_dither + + @Slot(bool) + def set_export_dither(self, value): + self.export_dither = bool(value) + self.image_editor.export_dither = self.export_dither + config.set( + "core", + "export_dither", + "true" if self.export_dither else "false", + ) + config.save() + def open_directory_dialog(self): dialog = QFileDialog() dialog.setFileMode(QFileDialog.FileMode.Directory) @@ -6826,10 +8210,13 @@ def undo_delete(self): self._clear_live_edit_session_state() with self._preview_lock: self._clear_last_rendered_preview_locked() - self._bump_display_generation() + # Nothing changed on disk; just force a fresh decode of the + # current image so the display drops the reverted edits. if self.image_cache and 0 <= self.current_index < len(self.image_files): - self.image_cache.pop_path(self.image_files[self.current_index].path) - self.prefetcher.cancel_all() + self._invalidate_decoded_path( + self.image_files[self.current_index].path, + force=True, + ) self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() if self.ui_state.isHistogramVisible: @@ -7162,6 +8549,11 @@ def shutdown_nonqt(self): "exif", wait=False, ) + self._safe_shutdown_executor( + getattr(self, "_editor_prewarm_executor", None), + "editor prewarm", + wait=False, + ) # wait=True ensures pending saves/deletes complete to avoid data loss/corruption self._safe_shutdown_executor( self._save_executor, "save", wait=True, cancel_futures=False @@ -7176,7 +8568,7 @@ def shutdown_nonqt(self): # after the save executor drains so we do not race the same file twice. for tgt, req in list(self._pending_save_recovery.items()): try: - self.image_editor.save_from_snapshot(req["snapshot"]) + recovery_result = self.image_editor.save_from_snapshot(req["snapshot"]) except Exception: log.exception("Final shutdown retry failed for %s", tgt) continue @@ -7185,8 +8577,21 @@ def shutdown_nonqt(self): meta_path_str = ctx.get("save_metadata_path") or tgt meta_sidecar = ctx.get("save_sidecar") or self.sidecar if meta_path_str and meta_sidecar is not None: + saved_edit_state = None + if isinstance(recovery_result, tuple) and len(recovery_result) == 2: + saved_edit_state = self._build_saved_edit_state_for_result( + {"request": req}, + saved_path=recovery_result[0], + backup_path=recovery_result[1], + ) self._mark_image_edited_in_sidecar( - meta_sidecar, Path(meta_path_str) + meta_sidecar, + Path(meta_path_str), + completed_edit_state_request_id=ctx.get("save_request_id"), + saved_edit_state=saved_edit_state, + ) + self._forget_pending_edit_save_request( + ctx.get("save_image_key"), ctx.get("save_request_id") ) log.info("Recovered pending save for %s on shutdown", tgt) except Exception: @@ -7437,6 +8842,9 @@ def edit_in_photoshop(self): self.dataChanged.emit() self.sync_ui_state() + # Auto-add to batch if enabled + self._auto_add_edited_to_batch_if_enabled(image_file.path) + self.update_status_message( f"Opened {current_image_path.name} in Photoshop." ) @@ -7495,13 +8903,34 @@ def _capture_metadata_snapshot( } def _mark_image_edited_in_sidecar( - self, sidecar: SidecarManager, image_path: Path + self, + sidecar: SidecarManager, + image_path: Path, + *, + completed_edit_state_request_id: Optional[str] = None, + saved_edit_state: Optional[dict[str, Any]] = None, ) -> Optional[dict]: """Mark an image as edited and return the pre-save metadata snapshot.""" old_meta = self._capture_metadata_snapshot(sidecar, image_path) new_meta = dict(old_meta or {}) new_meta["edited"] = True new_meta["edited_date"] = datetime.now().strftime("%Y-%m-%d") + if saved_edit_state is not None: + current_edit_state = new_meta.get("edit_state") + if ( + isinstance(current_edit_state, dict) + and current_edit_state.get("status") == "pending_save" + and completed_edit_state_request_id is not None + and current_edit_state.get("request_id") + != completed_edit_state_request_id + ): + log.debug( + "Preserving newer pending edit state for %s after save %s finished", + image_path, + completed_edit_state_request_id, + ) + else: + new_meta["edit_state"] = saved_edit_state sidecar.update_metadata(image_path, new_meta) return old_meta @@ -7552,6 +8981,7 @@ def _restore_metadata_snapshot( current_meta = sidecar.data.entries.get(stable_key) restored_edited = bool(snapshot.get("edited", False)) if snapshot else False restored_edited_date = snapshot.get("edited_date") if snapshot else None + restored_edit_state = snapshot.get("edit_state") if snapshot else None changed = False if current_meta is None: @@ -7566,6 +8996,9 @@ def _restore_metadata_snapshot( if current_meta.edited_date != restored_edited_date: current_meta.edited_date = restored_edited_date changed = True + if current_meta.edit_state != restored_edit_state: + current_meta.edit_state = restored_edit_state + changed = True if snapshot is None: default_meta = EntryMetadata() @@ -7718,7 +9151,11 @@ def enable_raw_editing(self): if self._block_if_saving(current_image_path): return - # 1. Update State + image_file = self.image_files[self.current_index] + if not image_file.has_raw: + self.update_status_message("No RAW file available.") + return + # 1. Update State if self.current_edit_source_mode != "raw": self.current_edit_source_mode = "raw" @@ -7730,7 +9167,6 @@ def enable_raw_editing(self): # If the path returned IS the working TIFF (and it exists), we can just load it. # Check specific condition: - image_file = self.image_files[self.current_index] if path == image_file.working_tif_path and self.is_valid_working_tif(path): log.info("Valid working TIFF exists, switching to RAW mode immediately.") self.load_image_for_editing() # This will now pick up the TIFF via get_active_edit_path @@ -7740,27 +9176,79 @@ def enable_raw_editing(self): # (Pass through to existing backend logic) self._develop_raw_backend() + def _raw_development_key_for_image(self, image_file: ImageFile) -> str: + return self._key(image_file.working_tif_path) or str( + image_file.working_tif_path + ) + + def _is_raw_development_in_flight(self, image_file: ImageFile) -> bool: + key = self._raw_development_key_for_image(image_file) + with self._raw_develop_lock: + return key in self._raw_developing_keys + + @Slot(result=bool) + def is_raw_developing_current(self) -> bool: + if not self.image_files or not ( + 0 <= self.current_index < len(self.image_files) + ): + return False + return self._is_raw_development_in_flight(self.image_files[self.current_index]) + + def _mark_raw_development_started(self, image_file: ImageFile) -> Optional[str]: + key = self._raw_development_key_for_image(image_file) + with self._raw_develop_lock: + if key in self._raw_developing_keys: + return None + self._raw_developing_keys.add(key) + self.rawDevelopmentStateChanged.emit() + return key + + def _mark_raw_development_finished(self, key: Optional[str]) -> None: + if not key: + return + with self._raw_develop_lock: + if key not in self._raw_developing_keys: + return + self._raw_developing_keys.remove(key) + self.rawDevelopmentStateChanged.emit() + def _develop_raw_backend(self): """Internal: Triggers the actual RawTherapee process.""" if not self.image_files: - return + return False image_file = self.image_files[self.current_index] if not image_file.has_raw: self.update_status_message("No RAW file available.") - return + return False + + if self._is_raw_development_in_flight(image_file): + self.update_status_message("RAW development already in progress.") + return False raw_path = image_file.raw_path tif_path = image_file.working_tif_path + if raw_path is None or not raw_path.exists(): + self.update_status_message("RAW file is missing.") + return False + + develop_key = self._mark_raw_development_started(image_file) + if develop_key is None: + self.update_status_message("RAW development already in progress.") + return False + tmp_tif_path = tif_path.with_name( + f".{tif_path.stem}_{uuid.uuid4().hex[:8]}{tif_path.suffix}" + ) # Resolve RawTherapee Executable from faststack.config import config rt_exe = config.get("rawtherapee", "exe") if not rt_exe or not os.path.exists(rt_exe): + self._mark_raw_development_finished(develop_key) self.update_status_message("RawTherapee not found. Check settings.") log.error("RawTherapee executable not configured or missing: %s", rt_exe) - return + return False self.update_status_message("Developing RAW... please wait.") log.info("Starting RAW development: %s -> %s", raw_path, tif_path) @@ -7774,7 +9262,7 @@ def worker(): # -Y: Overwrite existing # -o: Output file # -c: Input file (must be last) - cmd = [rt_exe, "-t", "-b16", "-Y", "-o", str(tif_path)] + cmd = [rt_exe, "-t", "-b16", "-Y", "-o", str(tmp_tif_path)] if rt_args: try: @@ -7801,17 +9289,44 @@ def worker(): result = subprocess.run(cmd, check=False, **run_kwargs) if result.returncode == 0: - if tif_path.exists() and tif_path.stat().st_size > 0: + if tmp_tif_path.exists() and tmp_tif_path.stat().st_size > 0: + try: + _safe_replace(tmp_tif_path, tif_path) + except OSError as e: + msg = f"RawTherapee output was valid but could not replace working TIFF: {e}" + log.error(msg) + QTimer.singleShot( + 0, + functools.partial( + self._on_develop_finished, + False, + msg, + develop_key, + ), + ) + return log.info("RAW development successful.") # Use partial to bind variable deeply QTimer.singleShot( - 0, functools.partial(self._on_develop_finished, True, None) + 0, + functools.partial( + self._on_develop_finished, + True, + None, + develop_key, + ), ) return # Success path msg = f"RawTherapee exited successfully but output file is missing or empty.\nCommand: {cmd_str}" log.error(msg) QTimer.singleShot( - 0, functools.partial(self._on_develop_finished, False, msg) + 0, + functools.partial( + self._on_develop_finished, + False, + msg, + develop_key, + ), ) else: stderr = result.stderr.strip() if result.stderr else "(no stderr)" @@ -7819,41 +9334,47 @@ def worker(): err_msg = f"RawTherapee failed (exit code {result.returncode}):\nCommand: {cmd_str}\nstderr: {stderr}\nstdout: {stdout}" log.error(err_msg) QTimer.singleShot( - 0, functools.partial(self._on_develop_finished, False, err_msg) + 0, + functools.partial( + self._on_develop_finished, + False, + err_msg, + develop_key, + ), ) except subprocess.TimeoutExpired: err_msg = f"RawTherapee timed out after 60 seconds.\nCommand: {cmd_str}" log.error(err_msg) QTimer.singleShot( - 0, functools.partial(self._on_develop_finished, False, err_msg) + 0, + functools.partial( + self._on_develop_finished, + False, + err_msg, + develop_key, + ), ) except Exception as e: err_msg = f"Unexpected error running RawTherapee: {str(e)}" log.exception(err_msg) QTimer.singleShot( - 0, functools.partial(self._on_develop_finished, False, err_msg) + 0, + functools.partial( + self._on_develop_finished, + False, + err_msg, + develop_key, + ), ) finally: - # Cleanup if we failed and left a bad file or 0-byte file (unless success logic already returned) - # Note: success logic returns early. If we are here, we likely failed or fell through (e.g. 0 byte file case did not return) - # Actually, the 0-byte case calls on_finished but doesn't return, so it falls here. - # Let's check specifically if we need to cleanup. - # If we succeeded, we returned. - if tif_path.exists() and "result" in locals(): - # Only cleanup if result was assigned (subprocess ran) - # If it's 0 bytes or we are in an error state (which implies we didn't return early) - try: - if tif_path.stat().st_size == 0: - tif_path.unlink() - elif result.returncode != 0: - # If we crashed but left a file, delete it - tif_path.unlink() - except (OSError, AttributeError): - # AttributeError if result is None - pass + try: + tmp_tif_path.unlink(missing_ok=True) + except OSError: + pass threading.Thread(target=worker, daemon=True).start() + return True # Preserving legacy slot name for compatibility if QML calls it directly, # but QML should call enable_raw_editing now. @@ -7869,8 +9390,64 @@ def develop_raw_for_current_image(self): # False — load failed or was aborted _REUSED = 2 # truthy int so QML @Slot(result=bool) coerces to true + @Slot(int, bool, int) + @Slot(int, bool, int, str) + def note_compact_editor_reload_scheduled( + self, index: int, coalesced: bool, delay_ms: int, reason: str = "navigation" + ): + """Debug hook for compact-editor navigation reload debounce.""" + log.debug( + "[COMPACT_EDITOR_RELOAD] scheduled preview-only index=%d " + "reason=%s delay=%dms coalesced=%s", + index, + reason, + delay_ms, + coalesced, + ) + + @Slot(int, str) + def note_compact_editor_full_load_required(self, index: int, reason: str): + """Debug hook for compact-editor actions that need a full editor load.""" + log.debug( + "[COMPACT_EDITOR_RELOAD] full load required index=%d reason=%s", + index, + reason, + ) + + @Slot(int, str) + def note_compact_editor_reload_skipped(self, index: int, reason: str): + """Debug hook for compact-editor navigation reload debounce.""" + log.debug( + "[COMPACT_EDITOR_RELOAD] skipped index=%d reason=%s", + index, + reason, + ) + + @Slot(result=bool) + def load_image_for_editing_preview(self): + """Load the current image for compact navigation using preview buffers only.""" + t0 = time.perf_counter() + index = self.current_index + result = self._load_image_for_editing(preview_only=True) + result_label = ( + "reused" + if result is self._REUSED + else "loaded" if result is True else "failed" + ) + log.debug( + "[COMPACT_EDITOR_RELOAD] preview-only load index=%d " + "result=%s total=%dms", + index, + result_label, + int((time.perf_counter() - t0) * 1000), + ) + return result + @Slot(result=bool) def load_image_for_editing(self): + return self._load_image_for_editing(preview_only=False) + + def _load_image_for_editing(self, *, preview_only: bool): """Load the currently viewed image into the editor. Returns True on real reload, _REUSED when the existing session @@ -7885,7 +9462,15 @@ def load_image_for_editing(self): active_path = Path(self.view_override_path) else: active_path = self.get_active_edit_path(self.current_index) + if ( + self.current_edit_source_mode == "raw" + and active_path.suffix.lower() in RAW_EXTENSIONS + ): + self._develop_raw_backend() + return False + load_path, _edit_state = self._active_edit_load_path(active_path) filepath = str(active_path) + load_filepath = str(load_path) editor_path = getattr(self.image_editor, "current_filepath", None) match = False @@ -7899,16 +9484,32 @@ def load_image_for_editing(self): except (OSError, ValueError): pass - if match: - # Also require an intact float buffer — a preview_only load leaves - # current_filepath/mtime set but float_image=None, which breaks - # crop, darken, and full-editor flows that need the master buffer. + if match and not preview_only: + # Also require an intact float buffer. A preview_only load leaves + # current_filepath/mtime set but float_image=None; promote that + # session before crop, darken, save, or full-editor flows use it. if getattr(self.image_editor, "float_image", None) is None: - match = False + try: + t_promote = time.perf_counter() + self.image_editor._ensure_float_image() + log.debug( + "[COMPACT_EDITOR_RELOAD] full promotion index=%d " + "total=%dms %s", + self.current_index, + int((time.perf_counter() - t_promote) * 1000), + active_path.name, + ) + except RuntimeError: + match = False + else: + if getattr(self.image_editor, "float_image", None) is None: + match = False if match: log.debug( - "load_image_for_editing: Reusing existing session for %s", filepath + "load_image_for_editing: Reusing existing session for %s (preview_only=%s)", + filepath, + preview_only, ) self._ensure_live_edit_session_state() # Ensure the background renderer is current and notify UI to refresh @@ -7917,7 +9518,9 @@ def load_image_for_editing(self): return self._REUSED # Fetch cached preview if available for faster initial display - cached_preview = self.get_decoded_image(self.current_index) + # (declined when loading from a backup source — see + # _cached_preview_for_load for the double-applied-edits hazard). + cached_preview = self._cached_preview_for_load(load_path, active_path) # Determine if we should capture source EXIF (e.g., for RAW mode) source_exif = None @@ -7940,12 +9543,23 @@ def load_image_for_editing(self): # Load into editor if self.image_editor.load_image( - filepath, cached_preview=cached_preview, source_exif=source_exif + load_filepath, + cached_preview=cached_preview, + source_exif=source_exif, + preview_only=preview_only, ): + log.debug( + "load_image_for_editing: loaded %s for %s (preview_only=%s)", + load_filepath, + filepath, + preview_only, + ) self._clear_active_auto_adjust_state( "editor session reloaded", clear_editor=False, ) + self._bind_loaded_editor_output_path(active_path) + self._restore_pending_edit_state_for_loaded_path(active_path) self._ensure_live_edit_session_state(force_reset=True) # Notify UIState to update bindings # We do this via signals or by calling the update function on UIState if available @@ -7989,17 +9603,40 @@ def _sync_editor_state_from_session(self): self.ui_state.currentAspectRatioIndex = 0 self.ui_state.currentCropBox = (0, 0, 1000, 1000) - # Kick off background render - self._kick_preview_worker() + # Kick off a background render only when the session holds unsaved + # edits that the decoded file can't show. After a navigation reload + # the session merely replays the already-saved (or save-submitted) + # state, and publishing a preview-resolution render here would + # replace the sharp full-resolution decode in the loupe ~250ms after + # the image appears: a slight shift (crop rounded at preview vs full + # resolution), visible blur, and a sourceSize change that breaks the + # zoom percentage and Ctrl+1 (1:1 of preview pixels instead of + # native pixels). + if self._is_current_live_edit_session_dirty(): + self._kick_preview_worker() # Notify UI self.ui_state.editorImageChanged.emit() - def _on_develop_finished(self, success: bool, error_msg: Optional[str]): + def _on_develop_finished( + self, + success: bool, + error_msg: Optional[str], + develop_key: Optional[str] = None, + ): """Callback on main thread after RAW development.""" + self._mark_raw_development_finished(develop_key) if success: self.update_status_message("RAW Development complete.") - # Load active path (which should now be the developed TIFF) - self.load_image_for_editing() + if develop_key is None or ( + self.image_files + and 0 <= self.current_index < len(self.image_files) + and develop_key + == self._raw_development_key_for_image( + self.image_files[self.current_index] + ) + ): + # Load active path (which should now be the developed TIFF) + self.load_image_for_editing() else: self.update_status_message(f"Development failed: {error_msg}") # Ensure UI reflects failure (maybe revert mode? or just show error) @@ -8013,6 +9650,10 @@ def get_preview_data(self) -> Optional[DecodedImage]: @Slot(str, "QVariant") def set_edit_parameter(self, key: str, value: Any): """Sets an edit parameter and updates the UIState for the slider visual.""" + if self.ui_state.isCropping: + self.update_status_message("Apply or cancel the crop before editing") + return + # Robust guard: only allow edits if the editor is actually holding an image. if not self.image_editor: return @@ -8041,6 +9682,13 @@ def set_edit_parameter(self, key: str, value: Any): if hasattr(self.ui_state, key): setattr(self.ui_state, key, final_value) + if changed and key == "rotation": + crop_box = self._normalize_crop_box_tuple( + self.image_editor.get_edit_value("crop_box") + ) + if crop_box is not None: + self._set_crop_overlay_box_only(crop_box) + # Trigger a refresh of the image to show the edit, ONLY if something changed # Uses gate pattern: runs immediately if not inflight, else queues for next if changed: @@ -8049,6 +9697,12 @@ def set_edit_parameter(self, key: str, value: Any): clear_editor=False, ) self._kick_preview_worker() + + # Auto-add to batch if enabled and this is the first edit + if self.image_editor.current_filepath: + self._auto_add_edited_to_batch_if_enabled( + Path(self.image_editor.current_filepath) + ) except Exception as e: log.error("Error setting edit parameter %s=%s: %s", key, value, e) @@ -8059,9 +9713,22 @@ def set_crop_box(self, left: int, top: int, right: int, bottom: int): self.image_editor.set_crop_box(crop_box) self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) - @Slot() - def reset_edit_parameters(self): - """Resets all editing parameters in the editor.""" + def _reset_edit_parameters(self, *, allow_during_crop: bool = False) -> bool: + """Reset all editing parameters, optionally as a confirmed discard.""" + if self.ui_state.isCropping and not allow_during_crop: + self.update_status_message( + "Apply or cancel the crop before resetting edits" + ) + return False + + if self.ui_state.isCropping: + # Discard resets the whole edit session, so do not restore the crop + # snapshot as cancel_crop_mode() would. Clearing isCropping emits the + # QML cleanup path that releases frozen crop interaction state. + self.ui_state.isCropRotating = False + self.ui_state.isCropping = False + self._clear_crop_mode_snapshot() + self.image_editor.reset_edits() self._clear_active_auto_adjust_state( "editor parameters reset", @@ -8079,6 +9746,19 @@ def reset_edit_parameters(self): if self.ui_state.isHistogramVisible: self.update_histogram() + return True + + @Slot() + def reset_edit_parameters(self): + """Resets all editing parameters in the editor.""" + self._reset_edit_parameters() + + @Slot() + def discard_edit_parameters(self): + """Discard edits even if a transient crop transaction is active.""" + if self._reset_edit_parameters(allow_during_crop=True): + self._mark_current_live_edit_session_clean() + # ---- Background Darkening Tool ---- def _reset_darken_on_navigation(self): @@ -8731,6 +10411,55 @@ def _apply_histogram_result(self, payload): if pending: self.histogram_timer.start() + def _display_preview_long_edge(self) -> Optional[int]: + """Long-edge cap for display-only full-resolution renders. + + Returns None when the display size is unknown or the view is zoomed + (get_display_info reports 0x0 while zoomed), which renders uncapped. + """ + display_w, display_h, _ = self.get_display_info() + if display_w and display_h: + return max(int(display_w), int(display_h)) + return None + + def _build_live_crop_preview_edits_override(self) -> Optional[dict]: + """Snapshot a temporary crop override for live crop preview rendering.""" + if not getattr(self.ui_state, "isCropping", False): + return None + + draft_crop_box = self._normalize_crop_box_tuple( + getattr(self.ui_state, "currentCropBox", None) + ) + if draft_crop_box is None: + return None + + with self.image_editor._lock: + preview_edits = dict(self.image_editor.current_edits) + + # Use the same draft-to-source mapping as execute_crop so the live + # preview and the committed crop always agree. + if self._crop_mode_has_saved_geometry: + base_crop_box = self._crop_mode_saved_crop_box + base_angle = self._crop_mode_saved_straighten_angle + else: + base_crop_box = self._normalize_crop_box_tuple( + preview_edits.get("crop_box") + ) + base_angle = 0.0 + composed_crop_box = self.image_editor.map_crop_draft_to_source( + draft_crop_box, + base_crop_box, + base_angle, + ) + if composed_crop_box is None: + return None + + if preview_edits.get("crop_box") == composed_crop_box: + return None + + preview_edits["crop_box"] = composed_crop_box + return preview_edits + def _kick_preview_worker(self, *, full_resolution: Optional[bool] = None): """Kicks off a background preview render task.""" if getattr(self, "_shutting_down", False): @@ -8753,6 +10482,15 @@ def _kick_preview_worker(self, *, full_resolution: Optional[bool] = None): token = self._preview_token session_key = self._get_current_live_preview_session_key() + # Full-resolution live previews are only consumed unzoomed (the + # provider falls back to the decoded cache when zoomed), so cap the + # render at the display size — a 20MP master costs several hundred ms + # per render for pixels the screen cannot show. + display_long_edge = ( + self._display_preview_long_edge() if render_full_resolution else None + ) + preview_edits_override = self._build_live_crop_preview_edits_override() + # Submit task to dedicated preview executor try: fut = self._preview_executor.submit( @@ -8761,12 +10499,58 @@ def _kick_preview_worker(self, *, full_resolution: Optional[bool] = None): session_key, self.image_editor, render_full_resolution, + display_long_edge, + preview_edits_override, + self.previewReady.emit, ) fut.add_done_callback(self._on_preview_done) except RuntimeError: log.warning("Preview executor failed (shutting down?)") with self._preview_lock: self._preview_inflight = False + return + + # Schedule (or cancel) the idle-time high-quality refinement pass. + # Always called from the main thread (slots and queued signals). + if render_full_resolution: + self._hq_preview_timer.stop() + else: + self._hq_preview_timer.start() + + def _refine_preview_resolution(self): + """Re-render the last preview-resolution frame at display resolution. + + Armed by every preview-resolution kick; fires once slider/keyboard + input has been idle for the debounce interval. The full-resolution + path emits a cheap preview-size frame first, so a refinement that a + new drag interrupts never blocks live feedback. + """ + if getattr(self, "_shutting_down", False): + return + if self.ui_state is None or self.ui_state.isCropping or self.ui_state.isZoomed: + return + if not self.image_editor or self.image_editor.current_filepath is None: + return + + with self._preview_lock: + if self._preview_inflight: + # A render is still running; check again once it settles. + self._hq_preview_timer.start() + return + # Only refine a frame that is still being displayed: same index + # and same live edit session as when the drag happened. After + # navigation the session key changes and there is nothing to + # sharpen (the loupe serves the decoded file again). + has_current_buffer = ( + self._last_rendered_preview is not None + and self._last_rendered_preview_index == self.current_index + and self._last_rendered_preview_session_key + == self._get_current_live_preview_session_key() + ) + if not has_current_buffer: + return + + self._kick_preview_worker(full_resolution=True) @staticmethod def _render_preview_worker( @@ -8774,15 +10558,35 @@ def _render_preview_worker( session_key, image_editor, full_resolution: bool = False, + display_long_edge=None, + preview_edits_override=None, + emit_intermediate=None, ): # Heavy work (PIL apply_edits) happens here off-thread try: decoded = None if full_resolution: - decoded = image_editor.get_full_resolution_preview_data() + # Publish a cheap preview-sized frame first so keypresses give + # immediate feedback; the high-resolution frame replaces it + # when ready. Intermediate payloads carry final=False so the + # gate stays inflight until the real result lands. + if emit_intermediate is not None: + quick = image_editor.get_preview_data_cached( + allow_compute=True, + edits_override=preview_edits_override, + ) + if quick is not None: + emit_intermediate((token, session_key, quick, False)) + decoded = image_editor.get_full_resolution_preview_data( + max_long_edge=display_long_edge, + edits_override=preview_edits_override, + ) if decoded is None: # allow_compute=True ensures we actually do the work - decoded = image_editor.get_preview_data_cached(allow_compute=True) + decoded = image_editor.get_preview_data_cached( + allow_compute=True, + edits_override=preview_edits_override, + ) return token, session_key, decoded except Exception: log.exception("Preview render failed") @@ -8798,7 +10602,14 @@ def _on_preview_done(self, fut): token, session_key, decoded = None, None, None # Emit from worker thread; Qt will queue to UI thread - self.previewReady.emit((token, session_key, decoded)) + try: + self.previewReady.emit((token, session_key, decoded, True)) + except RuntimeError: + # The final payload is the only thing that releases the preview + # gate; if the emit fails (Qt object torn down mid-shutdown), + # release it here so a surviving session cannot wedge rendering. + with self._preview_lock: + self._preview_inflight = False @Slot(object) def _emit_preview_accepted_side_effects(self): @@ -8813,21 +10624,32 @@ def _apply_preview_result(self, payload): return try: - token, session_key, decoded = payload + token, session_key, decoded, is_final = payload except (TypeError, ValueError): - token, session_key, decoded = None, None, None + token, session_key, decoded, is_final = None, None, None, True should_kick = False should_accept = False with self._preview_lock: - self._preview_inflight = False + # If the future completed before add_done_callback registered, the + # final frame is delivered synchronously and can be processed + # before the queued intermediate; that late intermediate (gate + # already released) must not overwrite the full-quality result. + stale_intermediate = not is_final and not self._preview_inflight + + # Intermediate frames (fast preview-size pass of a full-resolution + # render) must not release the gate: the worker is still busy + # producing the final frame. + if is_final: + self._preview_inflight = False # Accept result only if: # 1. We got valid decoded data # 2. Token matches (not stale from an old request) # 3. No pending request waiting (avoid "snap back" stale frame flash) if ( - decoded is not None + not stale_intermediate + and decoded is not None and token == self._preview_token and not self._preview_pending and session_key is not None @@ -8837,10 +10659,18 @@ def _apply_preview_result(self, payload): self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index self._last_rendered_preview_gen = self.ui_refresh_generation + if ( + self._original_compare_active + and self._original_compare_preview is not None + and self._original_compare_index == self.current_index + and self._original_compare_session_key + == self._get_current_original_compare_session_key() + ): + self._original_compare_gen = self.ui_refresh_generation should_accept = True # Consume pending flag atomically before scheduling - if self._preview_pending: + if is_final and self._preview_pending: self._preview_pending = False should_kick = True @@ -8854,13 +10684,175 @@ def _apply_preview_result(self, payload): if should_kick: self._kick_preview_worker() + def _can_show_original_compare(self, event) -> bool: + if not self.image_files: + return False + if self._is_grid_view_active: + return False + if getattr(self.ui_state, "isCropping", False): + return False + if getattr(self.ui_state, "isEditorOpen", False) and getattr( + self.ui_state, "isEditorExpanded", False + ): + return False + blocked_modifiers = event.modifiers() & ( + Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier + ) + return not blocked_modifiers + + @Slot() + def start_original_compare_preview(self): + """Show the source image with crop framing while space is held.""" + if getattr(self, "_shutting_down", False): + return + if not self.image_files or self._is_grid_view_active: + return + if getattr(self.ui_state, "isCropping", False): + return + + active_path = self._ensure_active_image_loaded_for_auto_adjust() + if active_path is None: + return + + session_key = self._get_current_original_compare_session_key() + if session_key is None: + return + + render_full_resolution = ( + bool(getattr(self.ui_state, "isZoomed", False)) + or self._should_render_live_preview_full_resolution() + ) + + with self._preview_lock: + self._original_compare_active = True + if hasattr(self.ui_state, "originalCompareActive"): + self.ui_state.originalCompareActive = True + + buffer_ready = ( + self._original_compare_preview is not None + and self._original_compare_index == self.current_index + and self._original_compare_session_key == session_key + ) + if buffer_ready: + self.ui_refresh_generation += 1 + self._original_compare_gen = self.ui_refresh_generation + self.ui_state.currentImageSourceChanged.emit() + return + + if self._original_compare_inflight: + return + + self._original_compare_inflight = True + self._original_compare_token += 1 + token = self._original_compare_token + index = self.current_index + + try: + future = self._preview_executor.submit( + self._render_original_compare_worker, + token, + session_key, + index, + self.image_editor, + render_full_resolution, + ) + future.add_done_callback(self._on_original_compare_done) + except RuntimeError: + log.warning("Original compare render failed to start") + with self._preview_lock: + self._original_compare_inflight = False + + @Slot() + def stop_original_compare_preview(self): + """Return the loupe to the normal edited preview after space is released.""" + with self._preview_lock: + if not self._original_compare_active: + return + self._original_compare_active = False + self._original_compare_inflight = False + self._original_compare_token += 1 + + if hasattr(self.ui_state, "originalCompareActive"): + self.ui_state.originalCompareActive = False + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + + @staticmethod + def _render_original_compare_worker( + token, + session_key, + index: int, + image_editor, + full_resolution: bool, + ): + try: + decoded = image_editor.get_original_compare_preview_data( + full_resolution=full_resolution + ) + return token, session_key, index, decoded + except Exception: + log.exception("Original compare render failed") + return token, session_key, index, None + + def _on_original_compare_done(self, future): + if getattr(self, "_shutting_down", False): + return + + try: + payload = future.result() + except Exception: + payload = None + + self.originalCompareReady.emit(payload) + + @Slot(object) + def _apply_original_compare_result(self, payload): + try: + token, session_key, index, decoded = payload + except (TypeError, ValueError): + token, session_key, index, decoded = None, None, -1, None + + should_emit = False + with self._preview_lock: + if token == self._original_compare_token: + self._original_compare_inflight = False + if ( + decoded is not None + and self._original_compare_active + and token == self._original_compare_token + and index == self.current_index + and session_key is not None + and session_key == self._get_current_original_compare_session_key() + ): + self._original_compare_preview = decoded + self._original_compare_session_key = session_key + self._original_compare_index = index + self.ui_refresh_generation += 1 + self._original_compare_gen = self.ui_refresh_generation + should_emit = True + + if should_emit: + self.ui_state.currentImageSourceChanged.emit() + @Slot() def cancel_crop_mode(self): """Cancel crop mode without applying changes.""" if self.ui_state.isCropping: - self._restore_crop_mode_geometry() try: - decoded = self.image_editor.get_full_resolution_preview_data() + self._restore_crop_mode_geometry() + except Exception: + log.exception("cancel_crop_mode: failed to restore crop geometry") + + # Clear the mode before rendering the restored preview so the crop + # overlay cannot stay visible during slow or failed preview work. + self.ui_state.isCropRotating = False + self.ui_state.isCropping = False + + try: + # Runs synchronously on the UI thread; cap at display size. + decoded = self.image_editor.get_full_resolution_preview_data( + max_long_edge=self._display_preview_long_edge() + ) except Exception: log.exception("cancel_crop_mode: restored preview render failed") decoded = None @@ -8874,8 +10866,6 @@ def cancel_crop_mode(self): self._last_rendered_preview_index = self.current_index self._last_rendered_preview_gen = self.ui_refresh_generation - self.ui_state.isCropRotating = False - self.ui_state.isCropping = False self._clear_crop_mode_snapshot() if decoded is not None: self._emit_preview_accepted_side_effects() @@ -8891,8 +10881,8 @@ def toggle_crop_mode(self): # Exiting crop mode: reuse the specialized cleanup self.cancel_crop_mode() else: - if self.ui_state.isEditorOpen: - self.update_status_message("Close the editor before cropping") + if self.ui_state.isEditorOpen and self.ui_state.isEditorExpanded: + self.update_status_message("Collapse the editor before cropping") return # Entering crop mode requires a loaded image with a valid float buffer. @@ -8911,8 +10901,13 @@ def toggle_crop_mode(self): return self._snapshot_crop_mode_geometry() - # Reset to full image defaults for this crop transaction only. - self._reset_crop_only(clear_crop_transaction=False) + # Keep the committed crop in the editor; the overlay is the only + # thing that becomes draft state during this crop transaction. + self._set_crop_overlay_box_only((0, 0, 1000, 1000)) + if hasattr(self.ui_state, "cropRotation"): + self.ui_state.cropRotation = 0.0 + if hasattr(self.ui_state, "currentAspectRatioIndex"): + self.ui_state.currentAspectRatioIndex = 0 # Set isCropping to True now that reset is done self.ui_state.isCropRotating = False self.ui_state.isCropping = True @@ -9130,10 +11125,6 @@ def execute_crop(self): self.update_status_message("Invalid crop selection") return - if crop_box_raw == (0, 0, 1000, 1000): - self.update_status_message("No crop area selected") - return - if self.view_override_path: filepath = Path(self.view_override_path) else: @@ -9147,9 +11138,12 @@ def execute_crop(self): except (OSError, ValueError): paths_match = str(editor_path) == str(filepath) - has_buffers = ( - self.image_editor.original_image is not None - and self.image_editor.float_image is not None + # Accept preview-only sessions (auto-adjust/compact editor loads): + # reloading here would reset live edits, and the full-resolution render + # below materializes the float master via _ensure_float_image anyway. + has_buffers = self.image_editor.original_image is not None and ( + self.image_editor.float_image is not None + or self.image_editor.float_preview is not None ) if not paths_match or not has_buffers: log.debug( @@ -9171,14 +11165,62 @@ def execute_crop(self): self.image_editor.current_edits.get("straighten_angle", 0.0) ) - self.image_editor.set_crop_box(crop_box_raw) + committed_crop_box = self._normalize_crop_box_tuple( + self.image_editor.get_edit_value("crop_box") + ) + + # A full-image box with no prior crop and no straighten is a no-op: + # applying it would still mark the image edited, auto-add it to the + # batch, and report "Crop applied". A full box remains meaningful when + # a straighten is pending (committing the box makes the rotation + # render) or a crop is already committed (Enter confirms and exits). + if ( + crop_box_raw == (0, 0, 1000, 1000) + and committed_crop_box is None + and abs(current_rotation) <= 0.001 + ): + self.update_status_message("No crop area selected") + return + + # The draft overlay is normalized against the image displayed when + # crop mode was entered (the snapshot geometry), which with a + # committed straighten is a rotated, fill-trimmed window onto the + # source. Map it back through that geometry; a plain linear compose + # would pick the wrong source region. + if self._crop_mode_has_saved_geometry: + base_crop_box = self._crop_mode_saved_crop_box + base_angle = self._crop_mode_saved_straighten_angle + else: + base_crop_box = committed_crop_box + base_angle = 0.0 + final_crop_box = self.image_editor.map_crop_draft_to_source( + crop_box_raw, base_crop_box, base_angle + ) + if final_crop_box is None: + self.update_status_message("Invalid crop selection") + return + + log.debug( + "execute_crop: committed=%s draft=%s base=(%s, %.4f) final=%s angle=%.4f", + committed_crop_box, + crop_box_raw, + base_crop_box, + base_angle, + final_crop_box, + current_rotation, + ) + + self.image_editor.set_crop_box(final_crop_box) self.image_editor.set_edit_param("straighten_angle", current_rotation) # Render the committed crop from the full-resolution master and publish # it before clearing crop mode, so the loupe does not keep showing the # preview-resolution crop used while dragging the overlay. try: - decoded = self.image_editor.get_full_resolution_preview_data() + # Runs synchronously on the UI thread; cap at display size. + decoded = self.image_editor.get_full_resolution_preview_data( + max_long_edge=self._display_preview_long_edge() + ) except Exception: log.exception("execute_crop: full-resolution render failed") decoded = None @@ -9204,6 +11246,9 @@ def execute_crop(self): # `visible: isCropping` in QML, and re-entering crop mode resets the # box inside a new crop transaction. self.ui_state.resetZoomPan() + # Cropping is a real edit, so keep batch tagging in sync immediately + # instead of waiting for a later save/navigation refresh. + self._auto_add_edited_to_batch_if_enabled(filepath) if decoded is not None: self._emit_preview_accepted_side_effects() else: @@ -9230,22 +11275,53 @@ def auto_levels(self): blacks = recommendation["base_blacks"] whites = recommendation["base_whites"] + + # Midtone brightness: only auto-set when the user has not touched the + # brightness slider themselves. Applied before levels so the single + # preview kick below renders both. + auto_brightness_delta = 0.0 + changed_brightness = False + try: + current_brightness = float( + self.image_editor.get_edit_value("brightness", 0.0) + ) + except (TypeError, ValueError): + current_brightness = 0.0 + base_brightness = float(recommendation.get("base_brightness", 0.0)) + if abs(current_brightness) <= 0.001 and abs(base_brightness) > 0.001: + changed_brightness = self._apply_brightness_to_editor( + brightness=base_brightness + ) + auto_brightness_delta = base_brightness - current_brightness + msg = self._format_auto_levels_detail( p_low=recommendation["p_low"], p_high=recommendation["p_high"], blacks=blacks, whites=whites, + auto_brightness_delta=auto_brightness_delta, ) changed = self._apply_levels_to_editor( blacks=blacks, whites=whites, kick_preview=True, ) + if changed_brightness and not changed: + # Levels alone did not change anything; make sure the brightness + # nudge still reaches the preview. + self._kick_preview_worker() + if self.ui_state.isHistogramVisible: + self.update_histogram() + changed = changed or changed_brightness if not changed and recommendation["noop_reason"]: msg = f"Auto levels: no change ({recommendation['noop_reason']})" self.update_status_message(f"{msg} (preview only)", timeout=9000) + if changed and self.image_editor and self.image_editor.current_filepath: + self._auto_add_edited_to_batch_if_enabled( + Path(self.image_editor.current_filepath) + ) log.info( "Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s", active_path, @@ -9266,12 +11342,19 @@ def auto_levels(self): self._last_auto_levels_msg = msg return changed - def _seed_active_auto_adjust_state(self) -> Optional[ActiveAutoAdjustState]: + def _seed_active_auto_adjust_state( + self, + *, + include_auto_vibrance: bool = False, + ) -> Optional[ActiveAutoAdjustState]: """Create transient auto-adjust state from the current loaded image.""" if self._block_auto_adjust_during_crop(): return None recommendation = self._compute_auto_levels_recommendation() - state = self._build_active_auto_adjust_state(recommendation) + state = self._build_active_auto_adjust_state( + recommendation, + include_auto_vibrance=include_auto_vibrance, + ) self._active_auto_adjust_state = state return state @@ -9298,6 +11381,13 @@ def _apply_and_save_active_auto_adjust( whites=whites, kick_preview=False, ) + changed_vibrance = self._apply_vibrance_to_editor( + vibrance=state.base_vibrance, + ) + changed_brightness = self._apply_brightness_to_editor( + brightness=state.base_brightness, + ) + changed = changed or changed_vibrance or changed_brightness detail = self._format_auto_levels_detail( p_low=state.p_low, p_high=state.p_high, @@ -9305,6 +11395,8 @@ def _apply_and_save_active_auto_adjust( whites=whites, extra_highlight_steps=state.extra_highlight_steps, extra_black_steps=state.extra_black_steps, + auto_vibrance_delta=state.auto_vibrance_delta, + auto_brightness_delta=state.auto_brightness_delta, ) self._last_auto_levels_msg = detail @@ -9338,7 +11430,7 @@ def quick_auto_levels(self): if self._ensure_active_image_loaded_for_auto_adjust() is None: return - state = self._seed_active_auto_adjust_state() + state = self._seed_active_auto_adjust_state(include_auto_vibrance=True) if state is None: self.update_status_message("Auto levels failed") return @@ -9364,7 +11456,7 @@ def quick_auto_adjust(self): # auto_white_balance() is preview-only here: it mutates the in-memory # editor session and status text, but does not save or append undo. awb_msg = self.auto_white_balance() - state = self._seed_active_auto_adjust_state() + state = self._seed_active_auto_adjust_state(include_auto_vibrance=True) if state is None: self.update_status_message("Auto adjust failed") return @@ -9443,11 +11535,21 @@ def raise_auto_adjust_blacks(self): def _apply_auto_adjust_preview(self, state: ActiveAutoAdjustState) -> None: """Render the current auto-adjust state into the editor/UI immediately.""" blacks, whites = self._derive_auto_adjust_levels(state) - self._apply_levels_to_editor( + changed_levels = self._apply_levels_to_editor( blacks=blacks, whites=whites, - kick_preview=True, + kick_preview=False, ) + changed_vibrance = self._apply_vibrance_to_editor( + vibrance=state.base_vibrance, + ) + changed_brightness = self._apply_brightness_to_editor( + brightness=state.base_brightness, + ) + if changed_levels or changed_vibrance or changed_brightness: + self._kick_preview_worker() + if self.ui_state.isHistogramVisible: + self.update_histogram() detail = self._format_auto_levels_detail( p_low=state.p_low, p_high=state.p_high, @@ -9455,9 +11557,17 @@ def _apply_auto_adjust_preview(self, state: ActiveAutoAdjustState) -> None: whites=whites, extra_highlight_steps=state.extra_highlight_steps, extra_black_steps=state.extra_black_steps, + auto_vibrance_delta=state.auto_vibrance_delta, + auto_brightness_delta=state.auto_brightness_delta, ) self._last_auto_levels_msg = detail self.update_status_message(detail, timeout=9000) + # Every quick-adjust entry point funnels through here, so this is the + # single place that keeps batch tagging in sync with live edits. + if self.image_editor and self.image_editor.current_filepath: + self._auto_add_edited_to_batch_if_enabled( + Path(self.image_editor.current_filepath) + ) def _apply_auto_levels_at_index(self, index: int) -> bool: """Apply auto levels and save for image at the given index. @@ -9510,7 +11620,12 @@ def _apply_auto_levels_at_index(self, index: int) -> bool: timestamp = time.time() metadata_path = image_file.path metadata_before = self._mark_image_edited_in_sidecar( - self.sidecar, metadata_path + self.sidecar, + metadata_path, + saved_edit_state=self._build_saved_edit_state_from_editor( + saved_path=saved_path, + backup_path=backup_path, + ), ) self.undo_history.append( @@ -9531,7 +11646,7 @@ def _apply_auto_levels_at_index(self, index: int) -> bool: clear_editor=False, ) self.image_editor.clear() - self.image_cache.pop_path(saved_path) + self._invalidate_decoded_path(saved_path, force=True) return True return False @@ -9739,6 +11854,7 @@ def auto_white_balance_lab(self) -> Optional[str]: strength = config.getfloat("awb", "strength", 0.7) warm_bias = config.getint("awb", "warm_bias", 6) tint_bias = config.getint("awb", "tint_bias", 0) + tint_damp = config.getfloat("awb", "tint_damp", 0.6) rgb_lower_bound = config.getint("awb", "rgb_lower_bound", 5) rgb_upper_bound = config.getint("awb", "rgb_upper_bound", 250) luma_lower_bound = config.getint("awb", "luma_lower_bound", 30) @@ -9748,6 +11864,7 @@ def auto_white_balance_lab(self) -> Optional[str]: strength=strength, warm_bias=warm_bias, tint_bias=tint_bias, + tint_damp=tint_damp, rgb_lower_bound=rgb_lower_bound, rgb_upper_bound=rgb_upper_bound, luma_lower_bound=luma_lower_bound, @@ -9784,12 +11901,14 @@ def auto_white_balance_lab(self) -> Optional[str]: selected_pixels = int(estimate.get("selected_pixels", 0)) stride = int(estimate.get("stride", 0)) neutrality_limit = float(estimate.get("neutrality_limit", 0.0)) + confidence = float(estimate.get("confidence", 1.0)) log.debug( - "[AUTO_COLOR] total=%dms (selected=%d stride=%d neutral<=%.3f)", + "[AUTO_COLOR] total=%dms (selected=%d stride=%d neutral<=%.3f confidence=%.2f)", int((t_awb_end - t_awb_start) * 1000), selected_pixels, stride, neutrality_limit, + confidence, ) self.update_status_message(msg) return msg @@ -10169,7 +12288,15 @@ def main( log.info("Starting FastStack") os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" - os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") + app_qml_dir = faststack_qml_dir() + qt_qml_dir = pyside_qml_dir() + qml_import_paths = [str(app_qml_dir)] + if qt_qml_dir is not None: + qml_import_paths.append(str(qt_qml_dir)) + existing_qml_import_path = os.environ.get("QML2_IMPORT_PATH") + if existing_qml_import_path: + qml_import_paths.append(existing_qml_import_path) + os.environ["QML2_IMPORT_PATH"] = os.pathsep.join(qml_import_paths) app = QApplication( sys.argv @@ -10220,13 +12347,13 @@ def main( app.setApplicationName("FastStack") engine = QQmlApplicationEngine() - engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml")) + engine.addImportPath(str(app_qml_dir)) + if qt_qml_dir is not None: + engine.addImportPath(str(qt_qml_dir)) + qt5compat_path = qt_qml_dir / "Qt5Compat" + if qt5compat_path.is_dir(): + engine.addImportPath(str(qt5compat_path)) engine.addImportPath("qrc:/qt-project.org/imports") - engine.addImportPath(os.path.join(os.path.dirname(__file__), "qml")) - # Add the path to Qt5Compat.GraphicalEffects to QML import paths - engine.addImportPath( - os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat") - ) controller = AppController( image_dir=image_dir_path, @@ -10249,7 +12376,7 @@ def main( context.setContextProperty("controller", controller) context.setContextProperty("thumbnailModel", controller._thumbnail_model) - qml_file = Path(__file__).parent / "qml" / "Main.qml" + qml_file = app_qml_dir / "Main.qml" engine.load(QUrl.fromLocalFile(str(qml_file))) if debug: log.info("Startup: after engine.load(QML): %.3fs", time.perf_counter() - t0) diff --git a/faststack/config.py b/faststack/config.py index c586f74..1ef8752 100644 --- a/faststack/config.py +++ b/faststack/config.py @@ -91,6 +91,8 @@ def version_sort_key(path): "auto_level_threshold": "0.1", "auto_level_strength": "1.0", "auto_level_strength_auto": "False", + "auto_vibrance_enabled": "True", + "auto_add_edited_to_batch": "True", }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index ba17555..d978b54 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -1,5 +1,6 @@ """Non-destructive image editor: crop, rotate, exposure, contrast, WB, sharpness.""" +import io import logging import math import os @@ -14,6 +15,8 @@ import numpy as np from PIL import ExifTags, Image, ImageFilter, ImageOps +from faststack.imaging.jpeg import TURBO_AVAILABLE, decode_jpeg_rgb + # Mask subsystem (lazy imports avoided — lightweight dataclasses) from faststack.imaging.mask import MaskData from faststack.imaging.mask_engine import MaskRasterCache @@ -24,8 +27,10 @@ _highlight_recover_linear, _lerp, _linear_to_srgb, + _linear_to_srgb_fast, _smoothstep01, _srgb_to_linear, + _srgb_to_linear_fast, ) from faststack.imaging.orientation import apply_orientation_to_np, get_exif_orientation from faststack.imaging.prefetch import apply_loupe_color_correction @@ -42,6 +47,123 @@ _REPLACE_RETRY_DELAY = 0.3 _REPLACE_MAX_RETRIES = 3 +_AUTO_VIBRANCE_MAX = 0.18 +_AUTO_VIBRANCE_MIN = 0.03 +_AUTO_VIBRANCE_TARGET_SAT = 0.22 +_AUTO_VIBRANCE_SAT_CEILING = 0.18 +_AUTO_VIBRANCE_MIN_COLOR_DELTA = 0.015 +_AUTO_VIBRANCE_CLIP_TOLERANCE = 0.0001 +_AUTO_LEVELS_ANALYSIS_MAX_EDGE = 1920 +# Colorful-subject guard: median saturation can be low while a small subject +# is already vivid. Scale the boost down as the 90th-percentile saturation +# approaches full saturation so that subject is not pushed garish. +_AUTO_VIBRANCE_P90_SOFT = 0.55 +_AUTO_VIBRANCE_P90_HARD = 0.85 +# Skin protection: halve the boost when a meaningful share of the analyzed +# pixels falls in the skin-tone hue/saturation envelope. +_AUTO_VIBRANCE_SKIN_FRACTION = 0.04 +_AUTO_VIBRANCE_SKIN_FACTOR = 0.5 + +# Levels soft knee/toe: instead of hard-clipping the linear blacks/whites +# ramp, values beyond the shoulder (or toe) are compressed smoothly so +# stretched highlights keep tonal separation and hue instead of slamming each +# channel to pure white/black independently. Spans are output-range fractions. +_LEVELS_SHOULDER_SPAN = 0.05 +_LEVELS_TOE_SPAN = 0.02 +# Output values that correspond to a pre-soft-clip value of exactly 1.0 / 0.0: +# shoulder(1.0) = knee + span*(1 - e^-1) = 1 - span*e^-1, toe(0.0) = toe*e^-1. +# Used to count "effectively clipped" pixels whether or not the knee is active. +_SOFT_CLIP_HI_MARK = 1.0 - _LEVELS_SHOULDER_SPAN * math.exp(-1.0) +_SOFT_CLIP_LO_MARK = _LEVELS_TOE_SPAN * math.exp(-1.0) + +# Export dither: TPDF noise hides the 8-bit banding that appears when a +# levels stretch amplifies the source's quantization steps. Only applied at +# or above this stretch gain. +_EXPORT_DITHER_MIN_GAIN = 1.2 +# Cap on the longest edge of the buffer analyze_auto_vibrance renders. The +# recommendation only depends on aggregate saturation/clipping statistics, which +# are stable under uniform downsampling, so bounding the resolution keeps the +# several _apply_edits passes cheap on the UI thread (Shift+L is synchronous). +_AUTO_VIBRANCE_ANALYSIS_MAX_EDGE = 640 + + +_REC601_LUMA = np.array([0.299, 0.587, 0.114], dtype=np.float32) + + +def _rec601_gray(arr: np.ndarray) -> np.ndarray: + """Rec.601 luma of an (H, W, 3) float32 array, staying in float32. + + The naive ``arr.dot([0.299, 0.587, 0.114])`` promotes through float64 + (Python-list coefficients), which silently doubles the memory traffic of + every downstream blend; cv2.transform is also multithreaded. + """ + if cv2 is not None and arr.flags["C_CONTIGUOUS"]: + return cv2.transform(arr, _REC601_LUMA.reshape(1, 3)).reshape(arr.shape[:2]) + return arr @ _REC601_LUMA + + +def _float01_to_u8(arr: np.ndarray) -> np.ndarray: + """Convert [0,1]-range float RGB to uint8 for encoding. + + cv2.convertScaleAbs fuses scale+round+saturate into one multithreaded + pass (the numpy fallback truncates instead of rounding — a <=1 LSB + difference well below JPEG encoding noise). + """ + clipped = np.clip(arr, 0.0, 1.0) + if cv2 is not None: + return cv2.convertScaleAbs(clipped, alpha=255.0) + return (clipped * 255).astype(np.uint8) + + +def _apply_levels_soft_clip(arr: np.ndarray) -> np.ndarray: + """Soft shoulder/toe for the levels ramp (mutates ``arr`` in place). + + The linear blacks/whites ramp sends out-of-range values past [0, 1] where + they would later hard-clip per channel — hue shifts in bright saturated + areas and abrupt steps in crushed shadows. Compress everything beyond the + shoulder/toe with an exponential rolloff that is C1-continuous at the + junction and asymptotes to the range limits, so a value of exactly 1.0 + lands at ``_SOFT_CLIP_HI_MARK`` and strong overshoot still approaches 1.0. + + Callers must pass an array they own (the levels ramp always allocates). + """ + knee = 1.0 - _LEVELS_SHOULDER_SPAN + hi = arr > knee + if np.any(hi): + v = arr[hi] + # minimum() guards float32 rounding (knee + span can sum past 1.0) + arr[hi] = np.minimum( + knee + + _LEVELS_SHOULDER_SPAN + * (1.0 - np.exp((knee - v) / _LEVELS_SHOULDER_SPAN)), + 1.0, + ) + toe = _LEVELS_TOE_SPAN + lo = arr < toe + if np.any(lo): + v = arr[lo] + arr[lo] = np.maximum(toe * np.exp((v - toe) / toe), 0.0) + return arr + + +def _normalized_wb_gains(by: float, mg: float) -> Tuple[float, float, float]: + """Linear-space WB channel gains, normalized to preserve luminance. + + ``by``/``mg`` are the slider values already scaled by 0.5. The gains are + divided by their Rec.709-weighted sum so a neutral gray keeps its linear + luminance: white balance shifts hue only instead of also brightening or + darkening the image. Channel ratios (what the AWB estimator solves for) + are unaffected by the normalization. + """ + r_gain = 1.0 + by + b_gain = 1.0 - by + g_gain = 1.0 - mg + luma_gain = 0.2126 * r_gain + 0.7152 * g_gain + 0.0722 * b_gain + if luma_gain > 1e-6: + r_gain /= luma_gain + g_gain /= luma_gain + b_gain /= luma_gain + return r_gain, g_gain, b_gain def _safe_replace(tmp_path: Path, target_path: Path) -> None: @@ -133,19 +255,23 @@ def create_backup_file(original_path: Path) -> Optional[Path]: HEADROOM_COMPRESSION_STEEPNESS = 2.0 # Adaptive Parameters (tuned by image content analysis) -# Pivot: Brightness threshold where recovery starts -ADAPTIVE_PIVOT_MIN = 0.45 -ADAPTIVE_PIVOT_MAX = 0.65 - -# K Factor: Steepness of the compression shoulder +# Pivot: Linear-light brightness where recovery starts to take hold. The effect +# tapers in smoothly (smoothstep) from zero at the pivot to full at display +# white, so a low pivot widens the affected band across all bright pixels +# (Photoshop-style) while still leaving midtones near the pivot essentially +# untouched. linear 0.30/0.50 ≈ sRGB 0.58/0.74. +ADAPTIVE_PIVOT_MIN = 0.30 +ADAPTIVE_PIVOT_MAX = 0.50 + +# K Factor: Steepness of the over-white compression shoulder ADAPTIVE_K_BASE = 8.0 -ADAPTIVE_K_SCALING = 6.0 -ADAPTIVE_K_HEADROOM_BASE = 6.0 -ADAPTIVE_K_HEADROOM_SCALING = 8.0 +ADAPTIVE_K_SCALING = 4.0 +ADAPTIVE_K_HEADROOM_BASE = 8.0 +ADAPTIVE_K_HEADROOM_SCALING = 4.0 # Chroma Rolloff: Desaturation in extreme highlights -ADAPTIVE_ROLLOFF_MIN = 0.10 -ADAPTIVE_ROLLOFF_MAX = 0.30 +ADAPTIVE_ROLLOFF_MIN = 0.02 +ADAPTIVE_ROLLOFF_MAX = 0.12 # Analysis Safety HEADROOM_MAX_BRIGHTNESS_PERCENTILE = 99.5 @@ -258,6 +384,220 @@ def _rotated_rect_with_max_area(w: int, h: int, angle_rad: float) -> tuple[int, return cw, ch +def _expanded_canvas_size( + src_w: int, src_h: int, straighten_angle: float +) -> tuple[int, int]: + """Canvas size produced by PIL ``rotate(expand=True)`` for a src_w x src_h image. + + Mirrors PIL's expand computation (corner extents with ceil/floor) so + geometry can be reasoned about without rotating pixels first. + """ + # PIL special-cases right angles to exact transposes; the corner-extent + # math below would inflate them by 1-2px of float epsilon. + remainder = abs(straighten_angle) % 90.0 + if remainder < 0.01 or remainder > 89.99: + if round(straighten_angle / 90.0) % 2: + return src_h, src_w + return src_w, src_h + angle_rad = math.radians(straighten_angle) + cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad) + xs, ys = [], [] + for px, py in ((0, 0), (src_w, 0), (src_w, src_h), (0, src_h)): + dx, dy = px - src_w / 2.0, py - src_h / 2.0 + xs.append(dx * cos_a - dy * sin_a) + ys.append(dx * sin_a + dy * cos_a) + return ( + int(math.ceil(max(xs)) - math.floor(min(xs))), + int(math.ceil(max(ys)) - math.floor(min(ys))), + ) + + +def _rotated_content_point( + px: float, + py: float, + src_w: float, + src_h: float, + canvas_w: float, + canvas_h: float, + cos_a: float, + sin_a: float, +) -> tuple[float, float]: + """Where a source pixel lands on the expanded canvas after straightening. + + The image is rotated with PIL ``rotate(-straighten_angle, expand=True)``; + a content point at offset d from the source center maps to + ``R(+straighten_angle) @ d`` in this y-down formula (verified against a + rotated-marker ground truth). ``cos_a``/``sin_a`` are of + ``+straighten_angle``. + """ + dx, dy = px - src_w / 2.0, py - src_h / 2.0 + return ( + dx * cos_a - dy * sin_a + canvas_w / 2.0, + dx * sin_a + dy * cos_a + canvas_h / 2.0, + ) + + +def _source_footprint_halfplanes( + src_w: float, + src_h: float, + canvas_w: float, + canvas_h: float, + cos_a: float, + sin_a: float, +) -> list[tuple[float, float, float]]: + """Half-planes (unit outward normal nx, ny, offset c) bounding the rotated + source rectangle on the expanded canvas. A point p is valid (real pixels, + not rotation fill) iff ``nx*px + ny*py <= c`` for all four planes.""" + corners = [ + _rotated_content_point(px, py, src_w, src_h, canvas_w, canvas_h, cos_a, sin_a) + for px, py in ((0.0, 0.0), (src_w, 0.0), (src_w, src_h), (0.0, src_h)) + ] + cx = sum(p[0] for p in corners) / 4.0 + cy = sum(p[1] for p in corners) / 4.0 + planes = [] + for i in range(4): + x1, y1 = corners[i] + x2, y2 = corners[(i + 1) % 4] + ex, ey = x2 - x1, y2 - y1 + norm = math.hypot(ex, ey) + if norm <= 1e-9: + continue + nx, ny = ey / norm, -ex / norm + if nx * (cx - x1) + ny * (cy - y1) > 0: + nx, ny = -nx, -ny + planes.append((nx, ny, nx * x1 + ny * y1)) + return planes + + +def _trim_rect_to_halfplanes( + left: float, + top: float, + right: float, + bottom: float, + planes: list[tuple[float, float, float]], + inset: float = 0.0, +) -> tuple[float, float, float, float]: + """Shrink an axis-aligned rect until it lies inside every half-plane. + + Each violated plane is resolved by moving its extreme corner along the + plane normal (the minimal cut). Shrinking a side never increases any + plane's extreme-corner value, so one pass converges; extra passes guard + against float noise. + """ + for _ in range(3): + dirty = False + for nx, ny, c in planes: + qx = right if nx > 0 else left + qy = bottom if ny > 0 else top + d = nx * qx + ny * qy - (c - inset) + if d <= 1e-6: + continue + dirty = True + if nx > 0: + right -= nx * d + else: + left -= nx * d + if ny > 0: + bottom -= ny * d + else: + top -= ny * d + if not dirty: + break + return left, top, right, bottom + + +def _autocrop_canvas_rect( + cw: int, ch: int, canvas_w: int, canvas_h: int, straighten_angle: float +) -> tuple[int, int, int, int]: + """Center the max-area autocrop rect on the expanded canvas, applying the + legacy 2px inset (skipped for exact 90-degree angles) and clamps.""" + cx, cy = canvas_w / 2.0, canvas_h / 2.0 + left = round(cx - cw / 2.0) + top = round(cy - ch / 2.0) + right = left + cw + bottom = top + ch + + # Apply inset (2px) to match legacy behavior and avoid edge artifacts. + # Skip for exact 90-degree increments to preserve full dimensions. + is_exact_90 = abs(straighten_angle % 90.0) < 0.01 + inset = 0 if is_exact_90 else 2 + if (right - left) > 2 * inset and (bottom - top) > 2 * inset: + left += inset + top += inset + right -= inset + bottom -= inset + + left = max(0, min(canvas_w - 1, left)) + top = max(0, min(canvas_h - 1, top)) + right = max(left + 1, min(canvas_w, right)) + bottom = max(top + 1, min(canvas_h, bottom)) + return left, top, right, bottom + + +def _crop_box_canvas_rect( + crop_box: tuple[float, float, float, float], + src_w: int, + src_h: int, + straighten_angle: float, + canvas_w: int, + canvas_h: int, + inset: float = 2.0, +) -> tuple[int, int, int, int]: + """Map a 0-1000 source-space crop box onto the expanded rotated canvas. + + Returns the int canvas rect to slice: an upright rect with the drawn + box's own dimensions (swapped when the rotation lands at an odd + 90-degree multiple), centered where the framed content lands after + rotation, then shrunk just enough that it contains no rotation fill. + Using the bounding box of the rotated rectangle instead would deliver an + image larger than the drawn box by ``w*|cos| + h*|sin|`` per axis. + """ + angle_rad = math.radians(straighten_angle) + cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad) + + c_left = crop_box[0] * src_w / 1000.0 + c_top = crop_box[1] * src_h / 1000.0 + c_right = crop_box[2] * src_w / 1000.0 + c_bottom = crop_box[3] * src_h / 1000.0 + + ccx, ccy = _rotated_content_point( + (c_left + c_right) / 2.0, + (c_top + c_bottom) / 2.0, + src_w, + src_h, + canvas_w, + canvas_h, + cos_a, + sin_a, + ) + box_w = c_right - c_left + box_h = c_bottom - c_top + if round(straighten_angle / 90.0) % 2: + box_w, box_h = box_h, box_w + left = ccx - box_w / 2.0 + right = ccx + box_w / 2.0 + top = ccy - box_h / 2.0 + bottom = ccy + box_h / 2.0 + + planes = _source_footprint_halfplanes( + src_w, src_h, canvas_w, canvas_h, cos_a, sin_a + ) + left, top, right, bottom = _trim_rect_to_halfplanes( + left, top, right, bottom, planes, inset=inset + ) + + # Round inward so the slice never reintroduces fill at the borders. + left_i = max(0, int(math.ceil(left))) + top_i = max(0, int(math.ceil(top))) + right_i = min(canvas_w, int(math.floor(right))) + bottom_i = min(canvas_h, int(math.floor(bottom))) + left_i = min(left_i, canvas_w - 1) + top_i = min(top_i, canvas_h - 1) + right_i = max(right_i, left_i + 1) + bottom_i = max(bottom_i, top_i + 1) + return left_i, top_i, right_i, bottom_i + + def rotate_autocrop_rgb( img: Image.Image, angle_deg: float, inset: int = 2 ) -> Image.Image: @@ -337,6 +677,7 @@ def __init__(self): # Stores the currently applied edits (used for preview) self.current_edits: Dict[str, Any] = self._initial_edits() self.current_filepath: Optional[Path] = None + self.source_filepath: Optional[Path] = None self.session_id: Optional[str] = None # Caching support for smooth updates @@ -377,19 +718,32 @@ def __init__(self): self._cached_detail_bands: Optional[Dict[str, Any]] = None # Cached 768-entry LUT list for save_image_uint8_levels (R+G+B tables), - # keyed on (round(blacks, 3), round(whites, 3)). - self._cached_u8_lut: Optional[Tuple[Tuple[float, float], List[int]]] = None + # keyed on (round(blacks, 3), round(whites, 3), soft_knee). + self._cached_u8_lut: Optional[Tuple[Tuple[float, float, bool], List[int]]] = ( + None + ) self._cached_u8_wb_lut: Optional[Tuple[Tuple[float, float], List[int]]] = None # Mask subsystem — generic mask assets keyed by tool id self._mask_assets: Dict[str, MaskData] = {} self._mask_raster_cache = MaskRasterCache() + # Rendering preferences pushed in from AppController config: + # soft shoulder/toe on the levels ramp, and TPDF dither on 8-bit + # export when a strong levels stretch would band the source. + self.levels_soft_knee: bool = True + self.export_dither: bool = True + + # Statistics from the most recent analyze_auto_levels() call + # (median luma etc.), read by the auto-adjust midtone recommendation. + self.last_auto_levels_stats: Dict[str, float] = {} + def clear(self): """Clear all editor state so the next edit starts from a clean slate.""" with self._lock: self.original_image = None self.current_filepath = None + self.source_filepath = None self.session_id = None self.float_image = None self.float_preview = None @@ -429,7 +783,7 @@ def _initial_edits(self) -> Dict[str, Any]: "saturation": 0.0, "white_balance_by": 0.0, # Blue/Yellow (Cool/Warm) "white_balance_mg": 0.0, # Magenta/Green (Tint) - "crop_box": None, # (left, top, right, bottom) normalized to 0-1000 + "crop_box": None, # Normalized box after 90-degree rotation. "sharpness": 0.0, "rotation": 0, "exposure": 0.0, @@ -445,6 +799,62 @@ def _initial_edits(self) -> Dict[str, Any]: "darken_settings": None, # DarkenSettings or None } + @staticmethod + def _rotate_point_90_normalized( + x: float, y: float, steps_ccw: int + ) -> Tuple[float, float]: + """Rotate a normalized 0-1000 point around image center in 90-degree steps.""" + steps = steps_ccw % 4 + if steps == 1: + return y, 1000.0 - x + if steps == 2: + return 1000.0 - x, 1000.0 - y + if steps == 3: + return 1000.0 - y, x + return x, y + + @classmethod + def _rotate_crop_box_for_rotation_change( + cls, + crop_box: Any, + old_rotation: int, + new_rotation: int, + ) -> Any: + """Keep an existing crop visually stable when 90-degree rotation changes.""" + if crop_box is None: + return crop_box + + try: + if len(crop_box) != 4: + return crop_box + left, top, right, bottom = (float(v) for v in crop_box) + except (TypeError, ValueError): + return crop_box + + delta_steps = ((int(new_rotation) - int(old_rotation)) // 90) % 4 + if delta_steps == 0: + return crop_box + + corners = ( + (left, top), + (right, top), + (right, bottom), + (left, bottom), + ) + rotated = ( + cls._rotate_point_90_normalized(x, y, delta_steps) for x, y in corners + ) + xs, ys = zip(*rotated) + + new_left = max(0, min(1000, int(round(min(xs))))) + new_top = max(0, min(1000, int(round(min(ys))))) + new_right = max(0, min(1000, int(round(max(xs))))) + new_bottom = max(0, min(1000, int(round(max(ys))))) + + if new_right <= new_left or new_bottom <= new_top: + return crop_box + return new_left, new_top, new_right, new_bottom + @staticmethod def _edits_skip_linear(edits: Dict[str, Any]) -> bool: """True when no linear-space edits are active (WB, exposure, highlights, @@ -525,6 +935,7 @@ def load_image( self.float_image = None self.float_preview = None self.current_filepath = None + self.source_filepath = None self.session_id = None self._source_exif_bytes = None self._edits_rev += 1 @@ -553,97 +964,158 @@ def load_image( self._mask_assets.clear() self._mask_raster_cache.clear() + _is_tiff = load_filepath.suffix.lower() in (".tif", ".tiff") + _is_jpeg = load_filepath.suffix.lower() in (".jpg", ".jpeg") + try: - # We must load and close the original file handle immediately - with Image.open(load_filepath) as im: - # Keep original PIL for EXIF/Format preservation - loaded_original = im.copy() + # --- JPEG fast path: decode pixels with TurboJPEG --- + # JPEGs are always 8-bit, so the OpenCV 16-bit probe is pointless + # and Pillow's full decode (~150ms at 20MP) can be replaced by + # TurboJPEG (~60ms; decode_jpeg_rgb falls back to Pillow itself). + # A lazy BytesIO-backed PIL handle supplies EXIF/ICC metadata + # without decoding pixels. + jpeg_arr = None + jpeg_meta_info: Optional[dict] = None + jpeg_meta_exif = None + if _is_jpeg: + try: + file_bytes = load_filepath.read_bytes() + meta_image = Image.open(io.BytesIO(file_bytes)) + jpeg_meta_info = dict(meta_image.info) + jpeg_meta_exif = meta_image.getexif() + jpeg_arr = decode_jpeg_rgb( + file_bytes, source_path=str(load_filepath) + ) + except Exception as e: + log.warning( + "JPEG fast decode failed for %s (%s); using standard path", + load_filepath, + e, + ) + jpeg_arr = None + if jpeg_arr is not None and ( + jpeg_arr.ndim != 3 + or jpeg_arr.shape[2] != 3 + or jpeg_arr.dtype != np.uint8 + ): + jpeg_arr = None + + if jpeg_arr is None: + # We must load and close the original file handle immediately + with Image.open(load_filepath) as im: + # Keep original PIL for EXIF/Format preservation + loaded_original = im.copy() if _debug: t_pil = time.perf_counter() - # --- Convert to Float32 --- - # Use OpenCV for reliable 16-bit loading as Pillow often downsamples to 8-bit RGB - _is_tiff = load_filepath.suffix.lower() in (".tif", ".tiff") - if preview_only and not _is_tiff: - cv_img = None - elif cv2 is None: - log.warning( - "OpenCV not installed, falling back to Pillow (may lose 16-bit depth)" - ) - cv_img = None - else: - # Use IMREAD_UNCHANGED to preserve bit depth - # Note: OpenCV loads as BGR by default - cv_img = cv2.imread(str(load_filepath), cv2.IMREAD_UNCHANGED) - - # Robust validation: cv2.imread can return None or an empty/invalid array - cv_img_valid = ( - cv_img is not None - and isinstance(cv_img, np.ndarray) - and cv_img.size > 0 - ) - loaded_bit_depth = 8 loaded_float_image = None float_image_orientation_applied = False - # Read EXIF orientation early (before float conversion) so we can - # apply it to the PIL image on the 8-bit path — rotating uint8 is - # ~5x faster than rotating float32. - orientation = get_exif_orientation( - load_filepath, exif=loaded_original.getexif() - ) + if jpeg_arr is not None: + orientation = get_exif_orientation(load_filepath, exif=jpeg_meta_exif) + if orientation > 1: + # apply_orientation_to_np may return a non-contiguous view + jpeg_arr = np.ascontiguousarray( + apply_orientation_to_np(jpeg_arr, orientation) + ) + float_image_orientation_applied = True + loaded_original = Image.fromarray(jpeg_arr) + if jpeg_meta_info: + # Carry EXIF/ICC over so getexif() and info["icc_profile"] + # behave exactly like a Pillow-decoded image. + loaded_original.info.update(jpeg_meta_info) + if not preview_only: + loaded_float_image = jpeg_arr.astype(np.float32) + loaded_float_image *= np.float32(1.0 / 255.0) + log.info( + "Loaded 8-bit JPEG via %s: %s", + "TurboJPEG" if TURBO_AVAILABLE else "Pillow (turbo unavailable)", + load_filepath, + ) + else: + # --- Convert to Float32 (standard path) --- + # Use OpenCV for reliable 16-bit loading as Pillow often + # downsamples to 8-bit RGB + if preview_only and not _is_tiff: + cv_img = None + elif cv2 is None: + log.warning( + "OpenCV not installed, falling back to Pillow (may lose 16-bit depth)" + ) + cv_img = None + else: + # Use IMREAD_UNCHANGED to preserve bit depth + # Note: OpenCV loads as BGR by default + cv_img = cv2.imread(str(load_filepath), cv2.IMREAD_UNCHANGED) + + # Robust validation: cv2.imread can return None or an empty/invalid array + cv_img_valid = ( + cv_img is not None + and isinstance(cv_img, np.ndarray) + and cv_img.size > 0 + ) + + # Read EXIF orientation early (before float conversion) so we can + # apply it to the PIL image on the 8-bit path — rotating uint8 is + # ~5x faster than rotating float32. + orientation = get_exif_orientation( + load_filepath, exif=loaded_original.getexif() + ) - if cv_img_valid and cv_img.dtype == np.uint16: - loaded_bit_depth = 16 - # Normalize 0-65535 -> 0.0-1.0 - arr = cv_img.astype(np.float32) / 65535.0 - - # Handle channels - if len(arr.shape) == 2: - # Grayscale -> RGB - arr = np.stack((arr,) * 3, axis=-1) - elif len(arr.shape) == 3 and arr.shape[2] == 3: - # BGR -> RGB (OpenCV default) - # Note: If IMREAD_UNCHANGED loads a TIFF, it *might* be RGB depending on backend (libtiff). - # But consistently OpenCV uses BGR layout for 3-channel images. - # Let's verify by assuming BGR and swapping. - arr = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB) + if cv_img_valid and cv_img.dtype == np.uint16: + loaded_bit_depth = 16 + # Normalize 0-65535 -> 0.0-1.0 + arr = cv_img.astype(np.float32) / 65535.0 + + # Handle channels + if len(arr.shape) == 2: + # Grayscale -> RGB + arr = np.stack((arr,) * 3, axis=-1) + elif len(arr.shape) == 3 and arr.shape[2] == 3: + # BGR -> RGB (OpenCV default) + # Note: If IMREAD_UNCHANGED loads a TIFF, it *might* be RGB depending on backend (libtiff). + # But consistently OpenCV uses BGR layout for 3-channel images. + # Let's verify by assuming BGR and swapping. + arr = cv2.cvtColor(arr, cv2.COLOR_BGR2RGB) + else: + # Invalid channel count, fall back to Pillow + cv_img_valid = False + loaded_bit_depth = 8 + # For fallback 8-bit from bad CV2, orient PIL first then convert + if orientation > 1: + loaded_original = ImageOps.exif_transpose(loaded_original) + rgb = loaded_original.convert("RGB") + arr = np.array(rgb).astype(np.float32) / 255.0 + float_image_orientation_applied = orientation > 1 + log.warning( + "OpenCV loaded unexpected channel count, falling back to Pillow: %s", + load_filepath, + ) + + loaded_float_image = arr + if loaded_bit_depth == 16: + log.info("Loaded 16-bit image via OpenCV: %s", load_filepath) + else: + log.info( + "Loaded 8-bit image via Pillow (OpenCV fallback): %s", + load_filepath, + ) else: - # Invalid channel count, fall back to Pillow - cv_img_valid = False + # Fallback to Pillow logic for 8-bit or if OpenCV failed/returned 8-bit loaded_bit_depth = 8 - # For fallback 8-bit from bad CV2, orient PIL first then convert + # Apply EXIF orientation on PIL image BEFORE float conversion. + # Rotating uint8 PIL is ~5x faster than rotating float32 numpy. if orientation > 1: loaded_original = ImageOps.exif_transpose(loaded_original) - rgb = loaded_original.convert("RGB") - arr = np.array(rgb).astype(np.float32) / 255.0 - float_image_orientation_applied = orientation > 1 - log.warning( - "OpenCV loaded unexpected channel count, falling back to Pillow: %s", - load_filepath, - ) - - loaded_float_image = arr - if loaded_bit_depth == 16: - log.info("Loaded 16-bit image via OpenCV: %s", load_filepath) - else: - log.info( - "Loaded 8-bit image via Pillow (OpenCV fallback): %s", - load_filepath, - ) - else: - # Fallback to Pillow logic for 8-bit or if OpenCV failed/returned 8-bit - loaded_bit_depth = 8 - # Apply EXIF orientation on PIL image BEFORE float conversion. - # Rotating uint8 PIL is ~5x faster than rotating float32 numpy. - if orientation > 1: - loaded_original = ImageOps.exif_transpose(loaded_original) - float_image_orientation_applied = True - if not preview_only: - rgb = loaded_original.convert("RGB") - loaded_float_image = np.array(rgb).astype(np.float32) / 255.0 - log.info("Loaded 8-bit image via Pillow: %s", load_filepath) + float_image_orientation_applied = True + if not preview_only: + rgb = loaded_original.convert("RGB") + # In-place multiply avoids a second full-size float + # allocation (~40% faster than astype + divide at 20MP). + loaded_float_image = np.asarray(rgb).astype(np.float32) + loaded_float_image *= np.float32(1.0 / 255.0) + log.info("Loaded 8-bit image via Pillow: %s", load_filepath) if _debug: t_float = time.perf_counter() @@ -692,14 +1164,40 @@ def load_image( loaded_float_preview = preview_arr.astype(np.float32) / 255.0 else: - # Downscale from float_image (which now has orientation applied) - thumb = loaded_original.copy() - thumb.thumbnail((1920, 1080)) - thumb_rgb = thumb.convert("RGB") - loaded_float_preview = np.array(thumb_rgb).astype(np.float32) / 255.0 + # Downscale to preview size. The JPEG fast path already has the + # oriented pixels as a numpy array; cv2.resize is ~4x faster + # than the PIL thumbnail round-trip at 20MP. + if jpeg_arr is not None and cv2 is not None: + h, w = jpeg_arr.shape[:2] + scale = min(1920.0 / w, 1080.0 / h, 1.0) + if scale < 1.0: + preview_u8 = cv2.resize( + jpeg_arr, + (max(1, int(w * scale)), max(1, int(h * scale))), + interpolation=cv2.INTER_AREA, + ) + else: + preview_u8 = jpeg_arr + else: + thumb = loaded_original.copy() + thumb.thumbnail((1920, 1080)) + preview_u8 = np.asarray(thumb.convert("RGB"), dtype=np.uint8) + + # float_preview is display-space by contract: cached previews + # from the prefetcher arrive already "cooked" (ICC/saturation + # applied), and preview-sized renders are shown WITHOUT any + # further color correction. A preview built from raw source + # pixels must get the same treatment, or ICC mode displays it + # badly oversaturated on wide-gamut monitors. + preview_u8 = apply_loupe_color_correction( + preview_u8, + icc_bytes=loaded_original.info.get("icc_profile"), + ) + loaded_float_preview = preview_u8.astype(np.float32) + loaded_float_preview *= np.float32(1.0 / 255.0) - # Thumbnail is derived from loaded_original AFTER exif_transpose, - # so orientation is already correct. + # Preview is derived from oriented pixels (exif_transpose / + # apply_orientation_to_np already ran), so orientation is correct. if _debug: t_preview = time.perf_counter() @@ -707,6 +1205,7 @@ def load_image( # Assign all state atomically under lock to prevent race with preview worker with self._lock: self.current_filepath = load_filepath + self.source_filepath = load_filepath self.session_id = uuid.uuid4().hex self.original_image = loaded_original self.float_image = loaded_float_image @@ -740,6 +1239,7 @@ def load_image( self.float_image = None self.float_preview = None self.current_filepath = None + self.source_filepath = None self.session_id = None self._edits_rev += 1 self._cached_preview = None @@ -781,13 +1281,41 @@ def _apply_edits( mask_assets_override: Optional[Dict[str, "MaskData"]] = None, cache_override: Optional["MaskRasterCache"] = None, cache_context: Optional[dict] = None, + update_highlight_state: bool = True, + downscale_long_edge: Optional[int] = None, + protect_input: bool = False, ) -> np.ndarray: """Applies all current edits to the provided float32 numpy array. Returns float32 array (H, W, 3). + + ``update_highlight_state`` controls whether non-export renders publish + highlight telemetry for the live clipping indicator. Analysis callers + should disable it when rendering downsampled scratch buffers. + + ``downscale_long_edge`` resizes the array down to the given long edge + AFTER geometry (rotation/crop) but BEFORE tonal edits. Display-only + renders use it so a full-resolution master is not processed at 20MP + when the screen can only show a fraction of that. + + ``protect_input`` lets callers pass a shared buffer (e.g. + ``self.float_image``) without copying it first: after geometry and + downscale, the working array is copied only if it still shares memory + with ``img_arr``. Cropping then copies just the cropped region instead + of the whole master, and a downscale already produced fresh memory. """ if edits is None: edits = self.current_edits + debug_enabled = log.isEnabledFor(logging.DEBUG) + debug_t0 = time.perf_counter() if debug_enabled else None + debug_stage_marks: list[tuple[str, float]] | None = ( + [] if debug_enabled else None + ) + + def _mark(stage: str) -> None: + if debug_stage_marks is not None: + debug_stage_marks.append((stage, time.perf_counter())) + # Alias arr = img_arr @@ -830,6 +1358,21 @@ def _apply_edits( straighten_angle = float(edits.get("straighten_angle", 0.0)) has_crop_box = "crop_box" in edits and edits.get("crop_box", 0.0) + # Effective crop selection in source space (post-90, pre-straighten). + # A full-frame box selects everything, so it gets the same fill-free + # autocrop geometry as "no crop" — rotate-only commits must not keep + # the whole expanded canvas with its four black wedges. + crop_box_vals: Optional[tuple] = None + if has_crop_box: + crop_box_edit = edits.get("crop_box") + try: + if len(crop_box_edit) == 4: + crop_box_vals = tuple(float(v) for v in crop_box_edit) + except (TypeError, ValueError): + crop_box_vals = None + if crop_box_vals == (0.0, 0.0, 1000.0, 1000.0): + crop_box_vals = None + # Apply rotation if significant # During preview (for_export=False), we might skip this if QML handles visuals, # BUT current QML implementation likely expects the buffer to be pre-transformed? @@ -843,7 +1386,7 @@ def _apply_edits( apply_rotation = abs(straighten_angle) > 0.001 and (for_export or has_crop_box) - # Capture original dimensions BEFORE rotation for crop coordinate transformation + # Capture dimensions after 90-degree rotation and before free rotation. orig_h, orig_w = arr.shape[:2] if apply_rotation: @@ -855,7 +1398,7 @@ def _apply_edits( # Calculate auto-crop parameters BEFORE rotation if needed crop_rect = None - if not has_crop_box: + if crop_box_vals is None: h, w = arr.shape[:2] # Normalize angle for helper (helper expects radians, handles quadrants but ensuring positive can help) angle_rad = math.radians(straighten_angle) @@ -869,105 +1412,77 @@ def _apply_edits( # Apply Auto-Crop if calculated if crop_rect: cw, ch = crop_rect - # Center crop on the new expanded image rh, rw = arr.shape[:2] - cx, cy = rw / 2.0, rh / 2.0 - - left = round(cx - cw / 2.0) - top = round(cy - ch / 2.0) - right = left + cw - bottom = top + ch - - # Apply inset (2px) to match legacy behavior and avoid edge artifacts. - # Skip for exact 90-degree increments to preserve full dimensions. - is_exact_90 = abs(straighten_angle % 90.0) < 0.01 - inset = 0 if is_exact_90 else 2 - - if (right - left) > 2 * inset and (bottom - top) > 2 * inset: - left += inset - top += inset - right -= inset - bottom -= inset - - # Clamp - left = max(0, min(rw - 1, left)) - top = max(0, min(rh - 1, top)) - right = max(left + 1, min(rw, right)) - bottom = max(top + 1, min(rh, bottom)) - + left, top, right, bottom = _autocrop_canvas_rect( + cw, ch, rw, rh, straighten_angle + ) arr = arr[top:bottom, left:right, :] # 3. Crop - if has_crop_box: - crop_box = edits.get("crop_box", 0.0) - if len(crop_box) == 4: - # The crop_box is in 0-1000 normalized coordinates relative to the - # ORIGINAL (un-rotated) image. After rotation with expand=True, - # the original image is centered within a larger canvas. - # We need to transform the coordinates from original image space - # to the expanded canvas space. - - if apply_rotation and abs(straighten_angle) > 0.001: - # Transform crop box through rotation: - # 1. Convert 0-1000 to pixel coords in original image - # 2. Rotate corners around original center - # 3. Translate to expanded canvas - new_h, new_w = arr.shape[:2] - orig_cx, orig_cy = orig_w / 2.0, orig_h / 2.0 - canvas_cx, canvas_cy = new_w / 2.0, new_h / 2.0 - - # Get crop corners in original pixel space - c_left = crop_box[0] * orig_w / 1000 - c_top = crop_box[1] * orig_h / 1000 - c_right = crop_box[2] * orig_w / 1000 - c_bottom = crop_box[3] * orig_h / 1000 - - # Define the 4 corners, rotate each around original center - corners = [ - (c_left, c_top), - (c_right, c_top), - (c_right, c_bottom), - (c_left, c_bottom), - ] - angle_rad = math.radians(-straighten_angle) - cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad) - - rotated_corners = [] - for px, py in corners: - # Rotate around original center - dx, dy = px - orig_cx, py - orig_cy - rx = dx * cos_a - dy * sin_a - ry = dx * sin_a + dy * cos_a - # Translate to canvas center - rotated_corners.append((rx + canvas_cx, ry + canvas_cy)) - - # Get axis-aligned bounding box of rotated corners - xs = [c[0] for c in rotated_corners] - ys = [c[1] for c in rotated_corners] - left = int(min(xs)) - t = int(min(ys)) - r = int(max(xs)) - b = int(max(ys)) - - left = max(0, left) - t = max(0, t) - r = min(new_w, r) - b = min(new_h, b) - else: - # No rotation - use current dimensions directly - h, w = arr.shape[:2] - left = int(crop_box[0] * w / 1000) - t = int(crop_box[1] * h / 1000) - r = int(crop_box[2] * w / 1000) - b = int(crop_box[3] * h / 1000) + if crop_box_vals is not None: + # The crop_box is in 0-1000 normalized coordinates relative to the + # image after 90-degree rotation, but before free straighten. If + # straighten uses expand=True, transform that box onto the expanded + # canvas before slicing. + if apply_rotation and abs(straighten_angle) > 0.001: + new_h, new_w = arr.shape[:2] + left, t, r, b = _crop_box_canvas_rect( + crop_box_vals, orig_w, orig_h, straighten_angle, new_w, new_h + ) + else: + # No rotation - use current dimensions directly + h, w = arr.shape[:2] + left = int(crop_box_vals[0] * w / 1000) + t = int(crop_box_vals[1] * h / 1000) + r = int(crop_box_vals[2] * w / 1000) + b = int(crop_box_vals[3] * h / 1000) + + left = max(0, left) + t = max(0, t) + r = min(w, r) + b = min(h, b) + + if r > left and b > t: + arr = arr[t:b, left:r, :] - left = max(0, left) - t = max(0, t) - r = min(w, r) - b = min(h, b) + if debug_enabled: + log.debug( + "geometry: src=%dx%d crop_box=%s angle=%.3f for_export=%s out=%dx%d", + orig_w, + orig_h, + crop_box_vals, + straighten_angle, + for_export, + arr.shape[1], + arr.shape[0], + ) - if r > left and b > t: - arr = arr[t:b, left:r, :] + _mark("geometry") + + # 3.5. Display-size downscale (display-only renders) + # Tonal edits below are per-pixel, so applying them to an INTER_AREA + # downscale of the cropped master is visually equivalent to rendering + # at full resolution and letting the GPU scale it down — and several + # times cheaper. + if downscale_long_edge and cv2 is not None: + h, w = arr.shape[:2] + long_edge = max(h, w) + if long_edge > downscale_long_edge: + scale = downscale_long_edge / long_edge + new_w = max(1, round(w * scale)) + new_h = max(1, round(h * scale)) + arr = cv2.resize(arr, (new_w, new_h), interpolation=cv2.INTER_AREA) + + _mark("downscale") + + # Detach from a shared input buffer before any tonal op can touch it. + # Everything below either reassigns or mutates `arr` in place (vignette, + # the caller's final in-place clip), so from here on the array must be + # private memory when the caller didn't pass a copy. may_share_memory + # is a cheap bounds check; a false positive just costs the copy the + # caller would otherwise have made up front. + if protect_input and np.may_share_memory(arr, img_arr): + arr = arr.copy() # 4. Conversion to Linear Light # Cache sRGB u8 BEFORE linearization for accurate JPEG clipping detection. @@ -976,14 +1491,61 @@ def _apply_edits( # MOVED to after WB/Exposure so indicators reflect current pipeline state. # --- Skip linear round-trip optimization --- - # When exporting with only sRGB-space edits active (levels, brightness, - # contrast, saturation, vibrance, vignette), the sRGB→Linear→sRGB conversion - # is a no-op that costs ~3.5s on large images. Skip it entirely. - _skip_linear = for_export and self._edits_skip_linear(edits) + # When only sRGB-space edits are active (levels, brightness, contrast, + # saturation, vibrance, vignette), the sRGB→Linear→sRGB conversion is a + # no-op that costs ~3.5s on large images (and ~120ms per preview + # render). Skip it entirely. Previews still need the highlight + # telemetry for the live clipping indicators, which the skip branch + # computes from a 4x-strided view below. + _skip_linear = self._edits_skip_linear(edits) if for_export: log.debug("_apply_edits for_export: skip_linear=%s", _skip_linear) + if _skip_linear and not for_export: + upstream_hash = self._get_upstream_edits_hash(edits) + analysis_state = None + with self._lock: + cached_dict = ( + cache_context.get("highlight_analysis") + if cache_context is not None + else self._cached_highlight_analysis + ) + if cached_dict and cached_dict["hash"] == upstream_hash: + analysis_state = cached_dict["state"] + + if analysis_state is None: + arr_stride = arr[::4, ::4, :] + if cv2 is not None: + srgb_u8_stride = cv2.convertScaleAbs(arr_stride, alpha=255.0) + else: + srgb_u8_stride = (np.clip(arr_stride, 0.0, 1.0) * 255).astype( + np.uint8 + ) + # With no WB/exposure active (guaranteed by _edits_skip_linear) + # the pre-exposure and current linear states are identical. + linear_stride = _srgb_to_linear_fast(arr_stride) + analysis_state = _analyze_highlight_state( + linear_stride, + srgb_u8=srgb_u8_stride, + pre_exposure_linear=linear_stride, + ) + with self._lock: + entry = { + "hash": upstream_hash, + "state": analysis_state, + } + if cache_context is not None: + cache_context["highlight_analysis"] = entry + else: + self._cached_highlight_analysis = entry + + if update_highlight_state: + with self._lock: + self._last_highlight_state = analysis_state + + _mark("skip_linear") + if not _skip_linear: # Capture strided view for analysis ONLY if needed # We need analysis if: @@ -1011,15 +1573,16 @@ def _apply_edits( np.uint8 ) - arr = _srgb_to_linear(arr) + # Base image data is always in [0, 1], so the clamped LUT version + # is safe here; headroom (>1.0) only appears later, in linear space. + arr = _srgb_to_linear_fast(arr) + _mark("linear_convert") # 5. White Balance (Multipliers in Linear Space) by = edits.get("white_balance_by", 0.0) * 0.5 mg = edits.get("white_balance_mg", 0.0) * 0.5 if abs(by) > 0.001 or abs(mg) > 0.001: - r_gain = 1.0 + by - b_gain = 1.0 - by - g_gain = 1.0 - mg + r_gain, g_gain, b_gain = _normalized_wb_gains(by, mg) arr[:, :, 0] *= r_gain arr[:, :, 1] *= g_gain arr[:, :, 2] *= b_gain @@ -1080,7 +1643,7 @@ def _apply_edits( else: self._cached_highlight_analysis = entry - if not for_export: + if not for_export and update_highlight_state: with self._lock: self._last_highlight_state = analysis_state @@ -1096,6 +1659,8 @@ def _apply_edits( cache_context=cache_context, ) + _mark("linear_tone") + # 8-10. Clarity / Texture / Sharpness (Unified Pyramid Detail Bands) # # Uses a hierarchical luma-only pyramid decomposition to avoid: @@ -1279,6 +1844,8 @@ def _extract_2d(blur_result): gain = np.clip(gain, 0.5, 2.0) arr *= gain[..., None] + _mark("detail_bands") + # 11. Global Headroom Shoulder (safety net for values > 1.0) # This ONLY affects values above 1.0, compressing headroom smoothly. # It does NOT interfere with normal highlight slider work below 1.0. @@ -1287,7 +1854,10 @@ def _extract_2d(blur_result): arr = _apply_headroom_shoulder(arr, max_overshoot=0.05) # --- Conversion back to sRGB --- - arr = _linear_to_srgb(arr) + # The headroom shoulder above caps values at 1.05, inside the LUT + # domain, so the fast version is exact here (within quantization). + arr = _linear_to_srgb_fast(arr) + _mark("linear_exit") # --- sRGB Space Operations --- # NOTE: All operations below must be non-mutating (use reassignment) when @@ -1314,25 +1884,27 @@ def _extract_2d(blur_result): if abs(sat_val) > 0.001: # Scale effect to reduce sensitivity (0.5x) factor = 1.0 + sat_val * 0.5 - gray = arr.dot([0.299, 0.587, 0.114]) - gray = np.expand_dims(gray, axis=2) + gray = _rec601_gray(arr)[..., None] arr = gray + (arr - gray) * factor # 12. Vibrance (Smart Saturation) vibrance = edits.get("vibrance", 0.0) if abs(vibrance) > 0.001: - cmax = arr.max(axis=2) - cmin = arr.min(axis=2) + if cv2 is not None: + # ~3x faster than numpy axis reductions at full resolution + cmax = cv2.max(cv2.max(arr[:, :, 0], arr[:, :, 1]), arr[:, :, 2]) + cmin = cv2.min(cv2.min(arr[:, :, 0], arr[:, :, 1]), arr[:, :, 2]) + else: + cmax = arr.max(axis=2) + cmin = arr.min(axis=2) delta = cmax - cmin sat = np.zeros_like(cmax) - mask = cmax > 0.0001 - sat[mask] = delta[mask] / cmax[mask] + np.divide(delta, cmax, out=sat, where=cmax > 0.0001) sat_mask = np.clip(1.0 - sat, 0.0, 1.0) factor = 1.0 + vibrance * sat_mask - gray = arr.dot([0.299, 0.587, 0.114]) - gray = np.expand_dims(gray, axis=2) + gray = _rec601_gray(arr)[..., None] arr = gray + (arr - gray) * np.expand_dims(factor, axis=2) # 13. Levels (Blacks/Whites) @@ -1344,6 +1916,9 @@ def _extract_2d(blur_result): if abs(wp - bp) < 0.0001: wp = bp + 0.0001 arr = (arr - bp) / (wp - bp) + if self.levels_soft_knee: + # The ramp above allocates, so in-place soft clip is safe. + arr = _apply_levels_soft_clip(arr) # 13.5. Background Darkening (masked, after levels, before vignette) darken = edits.get("darken_settings") @@ -1379,6 +1954,8 @@ def _extract_2d(blur_result): edge_protection=darken.edge_protection, ) + _mark("srgb_ops") + # 14. Vignette vignette = edits.get("vignette", 0.0) if abs(vignette) > 0.001: @@ -1395,26 +1972,54 @@ def _extract_2d(blur_result): gain = 1.0 + dist_sq * (-vignette) arr *= np.expand_dims(gain, axis=2) + _mark("vignette") + # Export contract: return in [0,1] sRGB when skip_linear (no tone mapping # was applied, just sRGB-space ops). save_image also clips, but this - # ensures callers always get valid data. - if _skip_linear: + # ensures callers always get valid data. Non-export callers need the + # unclipped overshoot (e.g. analyze_auto_vibrance measures clipping). + if _skip_linear and for_export: arr = np.clip(arr, 0.0, 1.0) + if debug_enabled and debug_t0 is not None and debug_stage_marks is not None: + total_ms = (time.perf_counter() - debug_t0) * 1000.0 + if total_ms >= 500.0: + prev_time = debug_t0 + breakdown = [] + for name, mark_time in debug_stage_marks: + breakdown.append( + f"{name}={((mark_time - prev_time) * 1000.0):.0f}ms" + ) + prev_time = mark_time + breakdown.append( + f"final={((time.perf_counter() - prev_time) * 1000.0):.0f}ms" + ) + log.debug( + "[APPLY_EDITS_SLOW] total=%.0fms export=%s skip_linear=%s size=%dx%d stages=%s", + total_ms, + for_export, + _skip_linear, + arr.shape[1], + arr.shape[0], + ", ".join(breakdown), + ) + return ( arr # May exceed 1.0 in preview/non-export; clipped for skip_linear export. ) def auto_levels( - self, threshold_percent: float = 0.1 + self, threshold_percent: float = 0.1, channel_budget: float = 3.0 ) -> Tuple[float, float, float, float]: """ Returns (blacks, whites, p_low, p_high). - p_low/p_high are computed conservatively from RGB to avoid introducing new channel clipping. + p_low/p_high are luma-driven with a per-channel clip budget so a single + saturated channel cannot veto the stretch (see analyze_auto_levels). """ blacks, whites, p_low, p_high = self.analyze_auto_levels( threshold_percent, reset_levels=True, + channel_budget=channel_budget, ) with self._lock: @@ -1429,6 +2034,7 @@ def analyze_auto_levels( *, edits: Optional[Dict[str, Any]] = None, reset_levels: bool = True, + channel_budget: float = 3.0, ) -> Tuple[float, float, float, float]: """Analyze auto-levels on the current edited baseline without mutating edits.""" _debug = log.isEnabledFor(logging.DEBUG) @@ -1438,13 +2044,38 @@ def analyze_auto_levels( threshold_percent = max(0.0, min(10.0, threshold_percent)) with self._lock: - img_arr = ( - self.float_preview.copy() - if self.float_preview is not None - else (self.float_image.copy() if self.float_image is not None else None) - ) + # Auto-levels is an aggregate percentile estimate. If the full + # master is already warm, sample it down before copying so analysis + # follows source pixels without rendering a 20MP+ buffer. If not, + # fall back to the preview so the first quick auto-adjust keypress + # remains preview-only. Final saves still apply the scalar edits to + # the full-resolution master. + source_arr = None + source_label = "none" + source_is_full = False + if self.float_image is not None: + source_arr = self.float_image + source_label = "full" + source_is_full = True + elif self.float_preview is not None: + source_arr = self.float_preview + source_label = "preview" edits_snapshot = dict(self.current_edits) if edits is None else dict(edits) + if source_arr is not None: + if source_is_full: + longest_edge = max(source_arr.shape[0], source_arr.shape[1]) + stride = max( + 1, + math.ceil(longest_edge / _AUTO_LEVELS_ANALYSIS_MAX_EDGE), + ) + if stride > 1: + source_arr = source_arr[::stride, ::stride, :] + source_label = f"full/{stride}x" + img_arr = np.array(source_arr, dtype=np.float32, copy=True, order="C") + else: + img_arr = None + if img_arr is None: # Fallback for tests or cases where float data isn't initialized yet if self.original_image is not None: @@ -1452,6 +2083,7 @@ def analyze_auto_levels( np.array(self.original_image.convert("RGB")).astype(np.float32) / 255.0 ) + source_label = "pil" else: return 0.0, 0.0, 0.0, 255.0 @@ -1465,45 +2097,57 @@ def analyze_auto_levels( t_arr = time.perf_counter() edited_arr = self._apply_edits(img_arr, edits=edits_snapshot, for_export=False) - # Convert to uint8 (0-255) for histogram analysis - # This preserves the logic of the original algorithm which was tuned for 0-255 bins - rgb = (np.clip(edited_arr, 0.0, 1.0) * 255).astype(np.uint8) + # Quantize the float render to 10-bit bins for percentile analysis. + # 1024 bins resolve the endpoints ~4x finer than the legacy uint8 + # histogram, which keeps black/white placement stable between the + # preview-sized analysis and the full-resolution export. + nbins = 1024 + scaled = np.clip(edited_arr, 0.0, 1.0) + quantized = (scaled * (nbins - 1)).astype(np.uint16) if _debug: t_u8 = time.perf_counter() - low_p = threshold_percent - high_p = 100.0 - threshold_percent - - # --- Detect pre-clipping (per-channel) --- - # If *any* channel already has clipped pixels, do not push that end further. - # eps_pct strategy: "Practical" - ignore tiny hot pixels (0.01%) but pin - # if there is any meaningful pre-clipping, even if below the full threshold. - eps_pct = min(threshold_percent, 0.01) + bin_to_255 = 255.0 / (nbins - 1) + + # Per-channel clip budget: the luma percentiles drive the stretch, + # while each individual channel is allowed to clip up to + # channel_budget x threshold. A budget of 1.0 reproduces the old + # conservative min/max-channel anchors; larger budgets stop a single + # saturated channel (blue sky, red flower) from vetoing the whole + # stretch. + channel_budget = max(1.0, min(10.0, float(channel_budget))) + chan_t = min(50.0, threshold_percent * channel_budget) + + # No explicit pre-clip pinning is needed: percentile ranks already + # count clipped pixels, so stretching to the q-th percentile keeps the + # *total* clipped fraction (pre-existing plus new) within the + # threshold rather than disabling the stretch outright the moment a + # few specular pixels sit at 255. + chan_lows = [] + chan_highs = [] + for c in range(3): + hist = np.bincount(quantized[:, :, c].reshape(-1), minlength=nbins) + chan_lows.append(self._percentile_from_hist(hist, chan_t, method="lower")) + chan_highs.append( + self._percentile_from_hist(hist, 100.0 - chan_t, method="higher") + ) - total = rgb.shape[0] * rgb.shape[1] - clipped_low_pct = [] - clipped_high_pct = [] - p_lows = [] - p_highs = [] + luma_q = (_rec601_gray(scaled) * (nbins - 1)).astype(np.uint16) + luma_hist = np.bincount(luma_q.reshape(-1), minlength=nbins) + luma_low = self._percentile_from_hist( + luma_hist, threshold_percent, method="lower" + ) + luma_high = self._percentile_from_hist( + luma_hist, 100.0 - threshold_percent, method="higher" + ) + median_luma = self._percentile_from_hist(luma_hist, 50.0, method="lower") / ( + nbins - 1 + ) - for c in range(3): - chan = rgb[:, :, c] - hist = np.bincount(chan.reshape(-1), minlength=256) - # Treat near-white/near-black as clipped (JPEG artifacts often land on 254/1) - clipped_low_pct.append(100.0 * float(hist[0] + hist[1]) / float(total)) - clipped_high_pct.append(100.0 * float(hist[254] + hist[255]) / float(total)) - p_lows.append(self._u8_percentile_from_hist(hist, low_p, method="lower")) - p_highs.append(self._u8_percentile_from_hist(hist, high_p, method="higher")) - - # Conservative anchors to avoid new channel clipping - p_low = min(p_lows) - p_high = max(p_highs) - - # Pin ends if pre-clipping exists (prevents making it worse) - if max(clipped_high_pct) > eps_pct: - p_high = 255.0 - if max(clipped_low_pct) > eps_pct: - p_low = 0.0 + # Black point: luma-driven target, capped by the per-channel budgets. + p_low = min(luma_low, min(chan_lows)) * bin_to_255 + # White point: luma-driven target, floored by the per-channel budgets. + p_high = max(luma_high, max(chan_highs)) * bin_to_255 # Safety p_low = max(0.0, min(255.0, p_low)) @@ -1517,27 +2161,203 @@ def analyze_auto_levels( blacks = -p_low / 40.0 whites = (255.0 - p_high) / 40.0 + with self._lock: + self.last_auto_levels_stats = { + "median_luma": float(median_luma), + "p_low": float(p_low), + "p_high": float(p_high), + } + if _debug: t_end = time.perf_counter() - h, w = rgb.shape[:2] + h, w = scaled.shape[:2] log.debug( - "[AUTO_LEVEL] get_array=%dms render=%dms hist+clip=%dms total=%dms (%dx%d, %s)", + "[AUTO_LEVEL] get_array=%dms render=%dms hist+clip=%dms total=%dms " + "(%dx%d, %s, median_luma=%.3f)", int((t_arr - t0) * 1000), int((t_u8 - t_arr) * 1000), int((t_end - t_u8) * 1000), int((t_end - t0) * 1000), w, h, - "preview" if self.float_preview is not None else "full", + source_label, + median_luma, ) return blacks, whites, float(p_low), float(p_high) + def analyze_auto_vibrance( + self, + *, + blacks: float, + whites: float, + ) -> float: + """Recommend a conservative vibrance boost for low-color auto-adjusts.""" + with self._lock: + # Prefer the unedited master buffer so this analysis cannot double-apply + # edits if preview rendering semantics change. float_preview is only a + # fallback for preview-only loads where no master buffer exists. + source_arr = ( + self.float_image if self.float_image is not None else self.float_preview + ) + edits_snapshot = dict(self.current_edits) + fallback_original = ( + self.original_image.copy() + if source_arr is None and self.original_image is not None + else None + ) + + if source_arr is None: + if fallback_original is None: + return 0.0 + source_arr = ( + np.array(fallback_original.convert("RGB")).astype(np.float32) / 255.0 + ) + + # Downsample the source before copying so full-resolution masters stay + # cheap while _apply_edits still receives a private mutable array. + longest_edge = max(source_arr.shape[0], source_arr.shape[1]) + stride = max(1, longest_edge // _AUTO_VIBRANCE_ANALYSIS_MAX_EDGE) + analysis_source = ( + source_arr[::stride, ::stride, :] if stride > 1 else source_arr + ) + img_arr = np.array(analysis_source, dtype=np.float32, copy=True, order="C") + + try: + current_vibrance = float(edits_snapshot.get("vibrance", 0.0)) + current_saturation = float(edits_snapshot.get("saturation", 0.0)) + except (TypeError, ValueError): + return 0.0 + if abs(current_vibrance) > 0.001 or abs(current_saturation) > 0.001: + return 0.0 + + baseline_edits = dict(edits_snapshot) + baseline_edits["blacks"] = float(blacks) + baseline_edits["whites"] = float(whites) + baseline_edits["vibrance"] = current_vibrance + + # The baseline and every candidate share identical edits upstream of + # vibrance, so an isolated cache lets _apply_edits reuse the highlight + # analysis instead of recomputing it per pass (and avoids touching the + # live preview cache or live clipping telemetry). + analysis_cache: dict = {} + + baseline = self._apply_edits( + img_arr.copy(), + edits=baseline_edits, + for_export=False, + cache_context=analysis_cache, + update_highlight_state=False, + ) + rgb = np.clip(baseline, 0.0, 1.0) + cmax = rgb.max(axis=2) + cmin = rgb.min(axis=2) + delta = cmax - cmin + luma = _rec601_gray(rgb) + useful = (luma > 0.08) & (luma < 0.92) & (cmax > 0.04) + if int(np.count_nonzero(useful)) < 100: + return 0.0 + + sat = np.zeros_like(cmax) + np.divide(delta, cmax, out=sat, where=cmax > 0.0001) + useful_sat = sat[useful] + useful_delta = delta[useful] + median_sat = float(np.percentile(useful_sat, 50)) + high_color_delta = float(np.percentile(useful_delta, 95)) + if ( + median_sat >= _AUTO_VIBRANCE_SAT_CEILING + or high_color_delta < _AUTO_VIBRANCE_MIN_COLOR_DELTA + ): + return 0.0 + + recommended = min( + _AUTO_VIBRANCE_MAX, + (_AUTO_VIBRANCE_TARGET_SAT - median_sat) * 0.9, + ) + + # Colorful-subject guard: a gray-dominant scene with one vivid subject + # has a low *median* saturation, but boosting it pushes that subject + # toward garish. Fade the boost as the 90th-percentile saturation + # approaches full saturation. + p90_sat = float(np.percentile(useful_sat, 90)) + if p90_sat > _AUTO_VIBRANCE_P90_SOFT: + guard = (_AUTO_VIBRANCE_P90_HARD - p90_sat) / ( + _AUTO_VIBRANCE_P90_HARD - _AUTO_VIBRANCE_P90_SOFT + ) + recommended *= max(0.0, min(1.0, guard)) + + # Skin protection: vibrance is hue-blind, and skin tolerates extra + # saturation poorly. When a meaningful share of the analyzed pixels + # sits in the skin-tone envelope (orange hue band, moderate + # saturation, mid luma), halve the boost. + r = rgb[:, :, 0] + g = rgb[:, :, 1] + b = rgb[:, :, 2] + hue_ratio = (g - b) / np.maximum(delta, 1e-6) + skin = ( + useful + & (r >= g) + & (g > b) + & (delta > 0.02) + & (hue_ratio > 0.15) + & (hue_ratio < 0.8) + & (sat > 0.1) + & (sat < 0.65) + ) + useful_count = max(1, int(np.count_nonzero(useful))) + skin_fraction = float(np.count_nonzero(skin)) / useful_count + if skin_fraction > _AUTO_VIBRANCE_SKIN_FRACTION: + recommended *= _AUTO_VIBRANCE_SKIN_FACTOR + + if recommended < _AUTO_VIBRANCE_MIN: + return 0.0 + + baseline_clip = self._channel_overshoot_fraction(baseline) + for candidate in ( + recommended, + recommended * 0.75, + recommended * 0.5, + recommended * 0.25, + ): + candidate_edits = dict(baseline_edits) + candidate_edits["vibrance"] = current_vibrance + candidate + candidate_arr = self._apply_edits( + img_arr.copy(), + edits=candidate_edits, + for_export=False, + cache_context=analysis_cache, + update_highlight_state=False, + ) + candidate_clip = self._channel_overshoot_fraction(candidate_arr) + if candidate_clip <= baseline_clip + _AUTO_VIBRANCE_CLIP_TOLERANCE: + return float(candidate) + + return 0.0 + + @staticmethod + def _channel_overshoot_fraction(arr: np.ndarray) -> float: + """Return fraction of pixels with any channel effectively clipped. + + With the levels soft knee active, values that would have hard-clipped + are compressed to just inside [0, 1]; the marks correspond to a + pre-soft-clip value of exactly 0.0 / 1.0, so this measures "would have + clipped" in both soft and hard modes. Comparisons using this are + differential (candidate vs. baseline), so legitimately near-black or + near-white content cancels out. + """ + if arr.size == 0: + return 0.0 + overshoot = np.any( + (arr < _SOFT_CLIP_LO_MARK) | (arr > _SOFT_CLIP_HI_MARK), axis=2 + ) + total = arr.shape[0] * arr.shape[1] + return float(np.count_nonzero(overshoot)) / float(total) + @staticmethod - def _u8_percentile_from_hist( + def _percentile_from_hist( hist: np.ndarray, percentile: float, method: str = "lower" ) -> float: - """Return a discrete uint8 percentile directly from histogram counts.""" + """Return a discrete percentile (bin index) from histogram counts.""" total = int(hist.sum()) if total <= 0: return 0.0 @@ -1551,7 +2371,44 @@ def _u8_percentile_from_hist( cdf = np.cumsum(hist) value = int(np.searchsorted(cdf, target_index + 1, side="left")) - return float(max(0, min(255, value))) + return float(max(0, min(len(hist) - 1, value))) + + def _crop_view_for_analysis(self, img_arr: np.ndarray) -> np.ndarray: + """Return a view of ``img_arr`` restricted to the active crop box. + + Color statistics should describe the pixels the user is keeping, so + cropped-away borders/backgrounds cannot skew the estimate. The crop + box is defined after 90-degree rotation (a cheap numpy view that does + not change the pixel population); straighten is ignored because the + corner wedges are negligible for aggregate statistics. + """ + with self._lock: + edits = dict(self.current_edits) + crop_box = edits.get("crop_box") + if not crop_box: + return img_arr + try: + if len(crop_box) != 4: + return img_arr + left_n, top_n, right_n, bottom_n = (float(v) for v in crop_box) + except (TypeError, ValueError): + return img_arr + if (left_n, top_n, right_n, bottom_n) == (0.0, 0.0, 1000.0, 1000.0): + return img_arr + + try: + k = (int(edits.get("rotation", 0) or 0) % 360) // 90 + except (TypeError, ValueError): + k = 0 + view = np.rot90(img_arr, k=k) if k else img_arr + h, w = view.shape[:2] + left = max(0, min(w, int(left_n * w / 1000.0))) + top = max(0, min(h, int(top_n * h / 1000.0))) + right = max(0, min(w, int(right_n * w / 1000.0))) + bottom = max(0, min(h, int(bottom_n * h / 1000.0))) + if right - left < 32 or bottom - top < 32: + return img_arr + return view[top:bottom, left:right] def estimate_auto_white_balance( self, @@ -1559,19 +2416,27 @@ def estimate_auto_white_balance( strength: float = 0.7, warm_bias: int = 6, tint_bias: int = 0, + tint_damp: float = 0.6, luma_lower_bound: int = 30, luma_upper_bound: int = 220, rgb_lower_bound: int = 5, rgb_upper_bound: int = 250, target_pixels: int = 600_000, ) -> Optional[Dict[str, float]]: - """Estimate white-balance sliders from a robust preview-sized sample.""" + """Estimate white-balance sliders from a robust preview-sized sample. + + Combines a neutral-pixel weighted gray-world estimate with a + Shades-of-Gray (Minkowski) estimate, scales the applied strength by + confidence (neutral sample size and estimator agreement), and damps + the magenta/green axis since real illuminants vary mostly along + blue/yellow. + """ _debug = log.isEnabledFor(logging.DEBUG) if _debug: t0 = time.perf_counter() img_arr = ( - self.float_preview if self.float_preview is not None else self.float_image + self.float_image if self.float_image is not None else self.float_preview ) if img_arr is None: if self.original_image is None: @@ -1580,6 +2445,7 @@ def estimate_auto_white_balance( np.asarray(self.original_image.convert("RGB"), dtype=np.float32) / 255.0 ) + img_arr = self._crop_view_for_analysis(img_arr) h, w = img_arr.shape[:2] total_pixels = max(1, h * w) stride = max(1, int(math.sqrt(total_pixels / max(1, target_pixels)))) @@ -1597,6 +2463,10 @@ def estimate_auto_white_balance( if not np.any(mask): return None + # Exposure-valid population before neutral narrowing; used by the + # secondary Shades-of-Gray estimator below. + broad_mask = mask + spread = np.max(srgb, axis=2) - np.min(srgb, axis=2) chroma_ratio = spread / np.maximum(luma, 1.0 / 255.0) valid_ratio = chroma_ratio[mask] @@ -1645,8 +2515,47 @@ def estimate_auto_white_balance( g_gain_target = rb_target / max(g_mean, eps) mg_raw = 2.0 * (1.0 - g_gain_target) - by_value = (by_raw + (float(warm_bias) / 128.0)) * float(strength) - mg_value = (mg_raw + (float(tint_bias) / 128.0)) * float(strength) + # Secondary estimator: Shades-of-Gray (Minkowski p=6 mean) over the + # broad exposure-valid population. Its failure modes differ from the + # neutral-pixel estimate (it weights bright regions more), so + # agreement between the two is a confidence signal and blending + # tempers each one's biases. + by_sog: Optional[float] = None + mg_sog: Optional[float] = None + broad_linear = _srgb_to_linear(srgb[broad_mask]).astype(np.float32, copy=False) + if broad_linear.shape[0] >= 128 and np.isfinite(broad_linear).all(): + p_norm = 6.0 + sog = np.power( + np.mean(np.power(broad_linear, p_norm), axis=0), 1.0 / p_norm + ) + sog_r, sog_g, sog_b = (float(v) for v in sog) + if min(sog_r, sog_g, sog_b) > eps: + ratio_rb_sog = sog_b / sog_r + by_sog = 2.0 * (ratio_rb_sog - 1.0) / max(ratio_rb_sog + 1.0, eps) + rb_target_sog = 2.0 * sog_r * sog_b / max(sog_r + sog_b, eps) + mg_sog = 2.0 * (1.0 - rb_target_sog / sog_g) + + confidence = 1.0 + if by_sog is not None and mg_sog is not None: + disagreement = max(abs(by_raw - by_sog), abs(mg_raw - mg_sog)) + confidence = float(np.clip(1.25 - 2.5 * disagreement, 0.4, 1.0)) + by_raw = 0.7 * by_raw + 0.3 * by_sog + mg_raw = 0.7 * mg_raw + 0.3 * mg_sog + + # Few usable neutral pixels means an unreliable estimate; fade the + # correction toward identity instead of failing or over-correcting. + confidence *= float(np.clip((selected_count - 64) / 2000.0, 0.0, 1.0)) + if confidence <= 0.0: + return None + + # Real illuminants vary mostly along the blue/yellow (Planckian) + # axis; a large magenta/green component is more often subject color + # than color cast, so damp the tint axis. + mg_raw *= float(np.clip(tint_damp, 0.0, 1.0)) + + effective_strength = float(strength) * confidence + by_value = (by_raw + (float(warm_bias) / 128.0)) * effective_strength + mg_value = (mg_raw + (float(tint_bias) / 128.0)) * effective_strength by_value = float(np.clip(by_value, -1.0, 1.0)) mg_value = float(np.clip(mg_value, -1.0, 1.0)) @@ -1654,7 +2563,9 @@ def estimate_auto_white_balance( if _debug: t_end = time.perf_counter() log.debug( - "[AUTO_WB_EST] total=%dms sample=%dx%d stride=%d selected=%d neutral<=%.3f means=(%.4f, %.4f, %.4f) wb=(%.4f, %.4f)", + "[AUTO_WB_EST] total=%dms sample=%dx%d stride=%d selected=%d " + "neutral<=%.3f means=(%.4f, %.4f, %.4f) sog=(%s, %s) " + "confidence=%.2f wb=(%.4f, %.4f)", int((t_end - t0) * 1000), srgb.shape[1], srgb.shape[0], @@ -1664,6 +2575,9 @@ def estimate_auto_white_balance( r_mean, g_mean, b_mean, + f"{by_sog:.4f}" if by_sog is not None else "n/a", + f"{mg_sog:.4f}" if mg_sog is not None else "n/a", + confidence, by_value, mg_value, ) @@ -1677,6 +2591,7 @@ def estimate_auto_white_balance( "selected_pixels": float(selected_count), "stride": float(stride), "neutrality_limit": neutrality_limit, + "confidence": confidence, } def _get_upstream_edits_hash(self, edits: Dict[str, Any]) -> int: @@ -1751,7 +2666,9 @@ def _freeze(v): return (hash(frozen), frozen) def get_preview_data_cached( - self, allow_compute: bool = True + self, + allow_compute: bool = True, + edits_override: Optional[Dict[str, Any]] = None, ) -> Optional[DecodedImage]: """Return cached preview if available, otherwise compute and cache. @@ -1760,15 +2677,25 @@ def get_preview_data_cached( """ with self._lock: # Check cache validity - if self._cached_preview is not None and self._cached_rev == self._edits_rev: + if ( + edits_override is None + and self._cached_preview is not None + and self._cached_rev == self._edits_rev + ): return self._cached_preview if not allow_compute: return None - # Prepare for computation - snapshot data under lock - base = self.float_preview.copy() if self.float_preview is not None else None - edits = dict(self.current_edits) + # Prepare for computation - snapshot data under lock. float_preview + # is only ever reassigned, never mutated in place, so the render + # can share it; protect_input copies only the post-crop region. + base = self.float_preview + edits = ( + dict(self.current_edits) + if edits_override is None + else dict(edits_override) + ) icc_bytes = ( self.original_image.info.get("icc_profile") if self.original_image is not None @@ -1784,11 +2711,12 @@ def get_preview_data_cached( edits=edits, for_export=False, icc_bytes=icc_bytes, + protect_input=True, ) with self._lock: # Only cache if revision hasn't changed during computation - if self._edits_rev == rev: + if edits_override is None and self._edits_rev == rev: self._cached_preview = decoded self._cached_rev = rev @@ -1803,24 +2731,62 @@ def _render_decoded_from_float( apply_loupe_color: bool = False, icc_bytes: Optional[bytes] = None, cache_context: Optional[dict] = None, + downscale_long_edge: Optional[int] = None, + protect_input: bool = False, ) -> DecodedImage: """Render edits against a float RGB array and package it for Qt display.""" + _debug = log.isEnabledFor(logging.DEBUG) + if _debug: + t0 = time.perf_counter() arr = self._apply_edits( base, edits=edits, for_export=for_export, cache_context=cache_context, + downscale_long_edge=downscale_long_edge, + protect_input=protect_input, ) - arr = np.clip(arr, 0.0, 1.0) - arr_u8 = (arr * 255).astype(np.uint8) + if _debug: + t_apply = time.perf_counter() + # _apply_edits returns either a fresh array or a view of `base`; every + # caller passes a private copy or sets protect_input=True, so clipping + # in place cannot corrupt editor state. cv2.convertScaleAbs fuses + # scale+round+saturate into one multithreaded pass (~5x faster than + # clip + mul + astype). + if cv2 is not None: + np.clip(arr, 0.0, 1.0, out=arr) + arr_u8 = cv2.convertScaleAbs(arr, alpha=255.0) + else: + arr = np.clip(arr, 0.0, 1.0) + arr_u8 = (arr * 255).astype(np.uint8) + if _debug: + t_u8 = time.perf_counter() if apply_loupe_color: arr_u8 = apply_loupe_color_correction(arr_u8, icc_bytes=icc_bytes) + if _debug: + t_color = time.perf_counter() + log.debug( + "[RENDER_DECODED] apply=%dms u8=%dms color=%dms total=%dms (%dx%d, %s)", + int((t_apply - t0) * 1000), + int((t_u8 - t_apply) * 1000), + int((t_color - t_u8) * 1000), + int((t_color - t0) * 1000), + arr_u8.shape[1], + arr_u8.shape[0], + "export" if for_export else "preview", + ) if QImage is None: raise ImportError( "PySide6.QtGui.QImage is required for rendering decoded image data" ) + # tobytes() always serializes in C-contiguous (row-major) order, so + # bytes_per_line must reflect that layout. Operations like np.rot90 + # (90-degree rotation) leave arr_u8 as a non-contiguous view whose + # strides[0] is NOT width*channels; force contiguity so the stride and + # the serialized buffer agree, otherwise QImage decodes to a null image. + arr_u8 = np.ascontiguousarray(arr_u8) img_buffer = arr_u8.tobytes() return DecodedImage( buffer=memoryview(img_buffer), @@ -1830,8 +2796,18 @@ def _render_decoded_from_float( format=QImage.Format.Format_RGB888, ) - def get_full_resolution_preview_data(self) -> Optional[DecodedImage]: - """Apply current edits to the full-resolution master for live display.""" + def get_full_resolution_preview_data( + self, + max_long_edge: Optional[int] = None, + edits_override: Optional[Dict[str, Any]] = None, + ) -> Optional[DecodedImage]: + """Apply current edits to the full-resolution master for live display. + + ``max_long_edge`` caps the rendered output (applied after crop, before + tonal edits) so display-only renders do not process a 20MP master when + the screen can only show a fraction of it. Pass None for true full + resolution. + """ try: self._ensure_float_image() except RuntimeError: @@ -1840,8 +2816,17 @@ def get_full_resolution_preview_data(self) -> Optional[DecodedImage]: with self._lock: if self.float_image is None: return None - base = self.float_image.copy() - edits = dict(self.current_edits) + # Share the master instead of copying ~3 bytes/pixel of float32 up + # front; protect_input defers the copy until after crop/downscale, + # so only the (much smaller) displayed region is ever duplicated. + # float_image is only ever reassigned, never mutated in place, so + # rendering from the shared reference outside the lock is safe. + base = self.float_image + edits = ( + dict(self.current_edits) + if edits_override is None + else dict(edits_override) + ) icc_bytes = ( self.original_image.info.get("icc_profile") if self.original_image is not None @@ -1855,6 +2840,93 @@ def get_full_resolution_preview_data(self) -> Optional[DecodedImage]: apply_loupe_color=True, icc_bytes=icc_bytes, cache_context={}, + downscale_long_edge=max_long_edge, + protect_input=True, + ) + + def _crop_only_edits(self, edits: Dict[str, Any]) -> Dict[str, Any]: + """Return edits for before/after comparison while preserving crop framing.""" + crop_only = self._initial_edits() + for key in ("crop_box", "rotation", "straighten_angle"): + crop_only[key] = edits.get(key, crop_only[key]) + return crop_only + + def _float_preview_from_master(self) -> Optional[np.ndarray]: + """Build a display-sized float preview from the unedited source buffer. + + The result is display-space ("cooked"), matching the float_preview + contract — preview-sized renders are shown without further color + correction. + """ + with self._lock: + if self.float_preview is not None: + return self.float_preview.copy() + source = self.float_image.copy() if self.float_image is not None else None + original = ( + self.original_image.copy() if self.original_image is not None else None + ) + icc_bytes = ( + self.original_image.info.get("icc_profile") + if self.original_image is not None + else None + ) + + if source is not None: + arr_u8 = (np.clip(source, 0.0, 1.0) * 255).astype(np.uint8) + thumb = Image.fromarray(arr_u8, mode="RGB") + elif original is not None: + thumb = original.convert("RGB") + else: + return None + + thumb.thumbnail((1920, 1080)) + preview_u8 = apply_loupe_color_correction( + np.asarray(thumb.convert("RGB"), dtype=np.uint8), + icc_bytes=icc_bytes, + ) + preview = preview_u8.astype(np.float32) + preview *= np.float32(1.0 / 255.0) + return preview + + def get_original_compare_preview_data( + self, *, full_resolution: bool = False + ) -> Optional[DecodedImage]: + """Render the source image with only crop framing applied.""" + if full_resolution: + try: + self._ensure_float_image() + except RuntimeError: + return None + + with self._lock: + if full_resolution and self.float_image is None: + return None + # Share the master (never mutated in place) and let protect_input + # copy only the cropped region; the preview branch already returns + # a private array, so it needs no protection. + base = ( + self.float_image + if full_resolution + else self._float_preview_from_master() + ) + edits = self._crop_only_edits(dict(self.current_edits)) + icc_bytes = ( + self.original_image.info.get("icc_profile") + if self.original_image is not None + else None + ) + + if base is None: + return None + + return self._render_decoded_from_float( + base, + edits=edits, + for_export=full_resolution, + apply_loupe_color=full_resolution, + icc_bytes=icc_bytes, + cache_context={}, + protect_input=full_resolution, ) def get_preview_data(self) -> Optional[DecodedImage]: @@ -1873,6 +2945,7 @@ def set_edit_param(self, key: str, value: Any) -> bool: # Guard against arbitrary angles in 'rotation'. It expects 90-degree steps. # For arbitrary rotation (drag to rotate), use 'straighten_angle'. try: + current_val = int(self.current_edits.get(key, 0)) % 360 # Round to nearest 90 degrees val_deg = float(value) rounded_deg = round(val_deg / 90.0) * 90 @@ -1885,6 +2958,14 @@ def set_edit_param(self, key: str, value: Any) -> bool: final_val, ) + crop_box = self.current_edits.get("crop_box") + self.current_edits["crop_box"] = ( + self._rotate_crop_box_for_rotation_change( + crop_box, + current_val, + final_val, + ) + ) self.current_edits[key] = final_val self._edits_rev += 1 return True @@ -2093,11 +3174,14 @@ def _apply_highlights_shadows( # nudge pivot earlier to expose micro-contrast (Photoshop-like feel) if headroom_pct < 0.01: if near_white_pct > 0.05 and clipped_pct < 0.05: - # Lots of recoverable near-white, not much flat clipping - pivot = max(0.60, pivot - 0.12 * near_white_pct) + # Lots of recoverable near-white, not much flat clipping: + # nudge the pivot earlier so the broad highlight band is + # engaged, floored low enough to keep covering ordinary + # bright pixels rather than just the clipped top end. + pivot = max(0.28, pivot - 0.12 * near_white_pct) if clipped_pct > 0.02: # Increase chroma rolloff for flat-clipped JPEGs - chroma_rolloff = max(chroma_rolloff, 0.25) + chroma_rolloff = max(chroma_rolloff, 0.14) arr = _highlight_recover_linear( arr, @@ -2117,8 +3201,149 @@ def _apply_highlights_shadows( def set_crop_box(self, crop_box: Tuple[int, int, int, int]): """Set the normalized crop box (left, top, right, bottom) from 0-1000.""" with self._lock: + if self.current_edits.get("crop_box") == crop_box: + return False self.current_edits["crop_box"] = crop_box self._edits_rev += 1 + return True + + def map_crop_draft_to_source( + self, + draft_box: Tuple[int, int, int, int], + base_crop_box: Optional[Tuple[int, int, int, int]], + base_straighten_angle: float, + ) -> Optional[Tuple[int, int, int, int]]: + """Map a crop box drawn on the displayed render back into source space. + + ``draft_box`` is normalized 0-1000 against the image shown when crop + mode was entered: the render produced by ``base_crop_box`` + + ``base_straighten_angle``. The result is normalized to source space + (post 90-degree rotation, pre-straighten) — the space + ``current_edits["crop_box"]`` uses. With a committed straighten the + display is a rotated, fill-trimmed window onto the source, so a + linear composition into the committed box selects the wrong region; + this replicates the render geometry and inverts it. + """ + if draft_box is None: + return None + try: + draft = tuple(float(v) for v in draft_box) + except (TypeError, ValueError): + return None + if len(draft) != 4: + return None + if base_crop_box is not None and tuple(base_crop_box) == (0, 0, 1000, 1000): + base_crop_box = None + + def _finalize(values: tuple) -> Tuple[int, int, int, int]: + left, top, right, bottom = (int(round(v)) for v in values) + left, right = min(left, right), max(left, right) + top, bottom = min(top, bottom), max(top, bottom) + left = max(0, min(1000, left)) + top = max(0, min(1000, top)) + right = max(0, min(1000, right)) + bottom = max(0, min(1000, bottom)) + if right <= left: + right = min(1000, left + 1) + if bottom <= top: + bottom = min(1000, top + 1) + return left, top, right, bottom + + with self._lock: + original = self.original_image + try: + rotation = int(self.current_edits.get("rotation", 0) or 0) % 360 + except (TypeError, ValueError): + rotation = 0 + + if abs(base_straighten_angle) <= 0.001 or original is None: + # The display is an unrotated window: plain linear composition. + if base_crop_box is None: + return _finalize(draft) + base_left, base_top, base_right, base_bottom = ( + float(v) for v in base_crop_box + ) + base_w = base_right - base_left + base_h = base_bottom - base_top + if base_w <= 0 or base_h <= 0: + return _finalize(draft) + return _finalize( + ( + base_left + base_w * draft[0] / 1000.0, + base_top + base_h * draft[1] / 1000.0, + base_left + base_w * draft[2] / 1000.0, + base_top + base_h * draft[3] / 1000.0, + ) + ) + + src_w, src_h = original.size + if rotation in (90, 270): + src_w, src_h = src_h, src_w + if src_w <= 0 or src_h <= 0: + return _finalize(draft) + + canvas_w, canvas_h = _expanded_canvas_size(src_w, src_h, base_straighten_angle) + + # The rect of the expanded canvas that was displayed (same geometry + # the renderer uses in _apply_edits). + if base_crop_box is None: + angle_rad = math.radians(base_straighten_angle) + cw, ch = _rotated_rect_with_max_area(src_w, src_h, angle_rad) + d_left, d_top, d_right, d_bottom = _autocrop_canvas_rect( + cw, ch, canvas_w, canvas_h, base_straighten_angle + ) + else: + d_left, d_top, d_right, d_bottom = _crop_box_canvas_rect( + tuple(float(v) for v in base_crop_box), + src_w, + src_h, + base_straighten_angle, + canvas_w, + canvas_h, + ) + + disp_w = d_right - d_left + disp_h = d_bottom - d_top + if disp_w <= 0 or disp_h <= 0: + return _finalize(draft) + + # Draft rect in canvas pixels. + c_left = d_left + disp_w * draft[0] / 1000.0 + c_top = d_top + disp_h * draft[1] / 1000.0 + c_right = d_left + disp_w * draft[2] / 1000.0 + c_bottom = d_top + disp_h * draft[3] / 1000.0 + + angle_rad = math.radians(base_straighten_angle) + + # Exact inverse of the renderer's geometry: a source box renders as + # an upright rect of the box's dimensions (swapped at odd 90-degree + # multiples) centered where the box center lands. So the source box + # takes the drawn rect's dimensions (swapped back) and is centered at + # the drawn rect's center inverse-rotated into source space — which + # makes repeated crop sessions compose exactly. + target_w = c_right - c_left + target_h = c_bottom - c_top + if round(base_straighten_angle / 90.0) % 2: + target_w, target_h = target_h, target_w + + dx = (c_left + c_right) / 2.0 - canvas_w / 2.0 + dy = (c_top + c_bottom) / 2.0 - canvas_h / 2.0 + inv_cos, inv_sin = math.cos(-angle_rad), math.sin(-angle_rad) + scx = dx * inv_cos - dy * inv_sin + src_w / 2.0 + scy = dx * inv_sin + dy * inv_cos + src_h / 2.0 + left = max(0.0, scx - target_w / 2.0) + top = max(0.0, scy - target_h / 2.0) + right = min(float(src_w), scx + target_w / 2.0) + bottom = min(float(src_h), scy + target_h / 2.0) + + return _finalize( + ( + left * 1000.0 / src_w, + top * 1000.0 / src_h, + right * 1000.0 / src_w, + bottom * 1000.0 / src_h, + ) + ) def _write_tiff_16bit(self, path: Path, arr_float: np.ndarray): """ @@ -2221,7 +3446,8 @@ def _ensure_float_image(self) -> None: # 2. Expensive conversion outside lock rgb = original_ref.convert("RGB") - float_arr = np.array(rgb).astype(np.float32) / 255.0 + float_arr = np.asarray(rgb).astype(np.float32) + float_arr *= np.float32(1.0 / 255.0) # 3. Store result under lock (checking if someone beat us to it, or if image changed) with self._lock: @@ -2299,8 +3525,15 @@ def snapshot_for_export( mask_snapshot = None export_cache = None + mask_assets_snapshot = { + key: copy.deepcopy(mask) + for key, mask in self._mask_assets.items() + if mask is not None + } + # --- Paths --- filepath_snapshot = self.current_filepath + source_filepath_snapshot = self.source_filepath or self.current_filepath # --- EXIF (may read original_image and _source_exif_bytes) --- main_exif = self._get_sanitized_exif_bytes() @@ -2327,12 +3560,53 @@ def snapshot_for_export( "export_cache": export_cache, "original_path": original_path, "filepath_snapshot": filepath_snapshot, + "source_filepath": source_filepath_snapshot, + "current_mtime": self.current_mtime, + "bit_depth": self.bit_depth, "main_exif": main_exif, "source_exif": source_exif, + "source_icc_bytes": self.original_image.info.get("icc_profile"), "write_developed_jpg": write_developed_jpg, "developed_path": developed_path, + "mask_assets": mask_assets_snapshot, } + def _dither_for_export( + self, final_float: np.ndarray, edits: Dict[str, Any] + ) -> np.ndarray: + """Add ±1 LSB TPDF dither before 8-bit quantization when warranted. + + A strong blacks/whites stretch amplifies the source's 8-bit + quantization steps into visible banding (skies, gradients). Triangular + noise decorrelates the quantization error and hides the bands. The + noise plane is shared across channels (luminance-only, no chroma + speckle) and the RNG is seeded so identical edits export identical + bytes. Returns a new array; the input snapshot buffer is not mutated. + """ + if not self.export_dither: + return final_float + try: + blacks = float(edits.get("blacks", 0.0)) + whites = float(edits.get("whites", 0.0)) + except (TypeError, ValueError): + return final_float + bp = -blacks * 0.15 + wp = 1.0 - (whites * 0.15) + gain = 1.0 / max(wp - bp, 1e-4) + if gain < _EXPORT_DITHER_MIN_GAIN: + return final_float + + h, w = final_float.shape[:2] + rng = np.random.default_rng(0x5EED) + noise = rng.random((h, w), dtype=np.float32) + noise -= rng.random((h, w), dtype=np.float32) + noise *= 1.0 / 255.0 + log.debug( + "[EXPORT_DITHER] applied TPDF dither (levels gain %.2f)", + gain, + ) + return final_float + noise[..., None] + def save_from_snapshot( self, snapshot: Dict[str, Any] ) -> Optional[Tuple[Path, Path]]: @@ -2396,6 +3670,13 @@ def save_from_snapshot( # 3. Save Main File is_tiff = original_path.suffix.lower() in [".tif", ".tiff"] + # 8-bit outputs (main JPEG and/or developed JPG) share one + # dithered buffer; the 16-bit TIFF path stays undithered. + if not is_tiff or write_developed_jpg: + dithered_float = self._dither_for_export(final_float, edits_snapshot) + else: + dithered_float = final_float + if is_tiff: tmp_path = original_path.with_name( f".{original_path.stem}_{uuid.uuid4().hex[:8]}{original_path.suffix}" @@ -2407,7 +3688,7 @@ def save_from_snapshot( tmp_path.unlink(missing_ok=True) raise else: - arr_u8 = (np.clip(final_float, 0.0, 1.0) * 255).astype(np.uint8) + arr_u8 = _float01_to_u8(dithered_float) img_u8 = Image.fromarray(arr_u8, mode="RGB") save_kwargs = {"quality": 95} @@ -2448,7 +3729,7 @@ def save_from_snapshot( elif source_exif: exif_bytes = sanitize_exif_orientation(source_exif) - arr_u8 = (np.clip(final_float, 0.0, 1.0) * 255).astype(np.uint8) + arr_u8 = _float01_to_u8(dithered_float) img_u8 = Image.fromarray(arr_u8) dev_kwargs = {"quality": 90} @@ -2619,12 +3900,23 @@ def save_image_uint8_levels( if abs(blacks) <= 0.001 and abs(whites) <= 0.001: return None + bp = -blacks * 0.15 + wp = 1.0 - (whites * 0.15) + if abs(wp - bp) < 0.0001: + wp = bp + 0.0001 + + # A LUT cannot dither (it is pointwise), so when the stretch is strong + # enough to band 8-bit sources, decline the fast path and let the + # float pipeline add TPDF dither during quantization. + if self.export_dither and 1.0 / max(wp - bp, 1e-4) >= _EXPORT_DITHER_MIN_GAIN: + return None + _debug = log.isEnabledFor(logging.DEBUG) if _debug: t0 = time.perf_counter() # Build 768-entry LUT matching _apply_edits step 13 (cached by rounded key) - cache_key = (round(blacks, 3), round(whites, 3)) + cache_key = (round(blacks, 3), round(whites, 3), bool(self.levels_soft_knee)) with self._lock: cached = self._cached_u8_lut if cached is not None and cached[0] == cache_key: @@ -2633,12 +3925,10 @@ def save_image_uint8_levels( lut_rgb = None if lut_rgb is None: - bp = -blacks * 0.15 - wp = 1.0 - (whites * 0.15) - if abs(wp - bp) < 0.0001: - wp = bp + 0.0001 lut = np.arange(256, dtype=np.float32) / 255.0 lut = (lut - bp) / (wp - bp) + if self.levels_soft_knee: + lut = _apply_levels_soft_clip(lut) lut = np.clip(lut, 0.0, 1.0) lut_rgb = (lut * 255.0).astype(np.uint8).tolist() * 3 # 768 entries with self._lock: @@ -2723,11 +4013,11 @@ def save_image_uint8_white_balance( lut_rgb = None if lut_rgb is None: - by_scaled = by * 0.5 - mg_scaled = mg * 0.5 - r_gain = max(0.0, 1.0 + by_scaled) - g_gain = max(0.0, 1.0 - mg_scaled) - b_gain = max(0.0, 1.0 - by_scaled) + # Must match _apply_edits step 5 (luma-preserving gains). + r_gain, g_gain, b_gain = _normalized_wb_gains(by * 0.5, mg * 0.5) + r_gain = max(0.0, r_gain) + g_gain = max(0.0, g_gain) + b_gain = max(0.0, b_gain) lut = np.arange(256, dtype=np.float32) / 255.0 lut_linear = _srgb_to_linear(lut) @@ -2780,15 +4070,27 @@ def _restore_file_times(self, path: Path, original_stat: os.stat_result) -> None def rotate_image_cw(self): """Decreases the rotation edit parameter by 90° modulo 360 (clockwise).""" with self._lock: - current = self.current_edits.get("rotation", 0) - self.current_edits["rotation"] = (current - 90) % 360 + current = int(self.current_edits.get("rotation", 0)) % 360 + new_rotation = (current - 90) % 360 + self.current_edits["crop_box"] = self._rotate_crop_box_for_rotation_change( + self.current_edits.get("crop_box"), + current, + new_rotation, + ) + self.current_edits["rotation"] = new_rotation self._edits_rev += 1 def rotate_image_ccw(self): """Increases the rotation edit parameter by 90° modulo 360 (counter-clockwise).""" with self._lock: - current = self.current_edits.get("rotation", 0) - self.current_edits["rotation"] = (current + 90) % 360 + current = int(self.current_edits.get("rotation", 0)) % 360 + new_rotation = (current + 90) % 360 + self.current_edits["crop_box"] = self._rotate_crop_box_for_rotation_change( + self.current_edits.get("crop_box"), + current, + new_rotation, + ) + self.current_edits["rotation"] = new_rotation self._edits_rev += 1 diff --git a/faststack/imaging/math_utils.py b/faststack/imaging/math_utils.py index a186a76..404efeb 100644 --- a/faststack/imaging/math_utils.py +++ b/faststack/imaging/math_utils.py @@ -28,6 +28,67 @@ def _linear_to_srgb(x: np.ndarray) -> np.ndarray: return np.where(x <= 0.0031308, 12.92 * x, (1.0 + a) * (x ** (1.0 / 2.4)) - a) +# LUT-accelerated transfer functions for full-image renders. The exact +# np.where + power versions above cost ~60ms per megapixel-scale call; the +# quantized lookups below are ~3x faster. 65536 entries keeps the worst-case +# round-trip error under 0.03 of one u8 step, far below the 8-bit +# quantization every render goes through afterwards. Keep the exact versions +# for tiny inputs where exactness is free (e.g. building 256-entry save LUTs). +_TRANSFER_LUT_SIZE = 65536 +# Headroom shoulder caps linear values at 1.0 + max_overshoot (0.05); leave +# a little margin. Inputs above the domain clamp to the last entry, which is +# equivalent to the exact path once the caller clips display output to [0,1]. +_LINEAR_TO_SRGB_DOMAIN = 1.06 + +_srgb_to_linear_lut: Optional[np.ndarray] = None +_linear_to_srgb_lut: Optional[np.ndarray] = None + + +def _srgb_to_linear_fast(x: np.ndarray) -> np.ndarray: + """LUT-based `_srgb_to_linear` for preview/display renders. + + Input is clamped to [0.0, 1.0] — unlike the exact version this does NOT + preserve headroom above 1.0, so only call it on base image data (which is + always in [0, 1]) or analysis buffers feeding u8 display output. + """ + global _srgb_to_linear_lut + lut = _srgb_to_linear_lut + if lut is None: + xs = np.linspace(0.0, 1.0, _TRANSFER_LUT_SIZE, dtype=np.float64) + lut = _srgb_to_linear(xs).astype(np.float32) + _srgb_to_linear_lut = lut + idx = np.clip( + x * np.float32(_TRANSFER_LUT_SIZE - 1) + np.float32(0.5), + 0, + _TRANSFER_LUT_SIZE - 1, + ).astype(np.uint16) + return lut[idx] + + +def _linear_to_srgb_fast(x: np.ndarray) -> np.ndarray: + """LUT-based `_linear_to_srgb` for preview/display renders. + + Covers [0, 1.06]; inputs above that clamp to the last entry, which is + indistinguishable from the exact version after the caller clips display + output to [0, 1]. + """ + global _linear_to_srgb_lut + lut = _linear_to_srgb_lut + if lut is None: + xs = np.linspace( + 0.0, _LINEAR_TO_SRGB_DOMAIN, _TRANSFER_LUT_SIZE, dtype=np.float64 + ) + lut = _linear_to_srgb(xs).astype(np.float32) + _linear_to_srgb_lut = lut + idx = np.clip( + x * np.float32((_TRANSFER_LUT_SIZE - 1) / _LINEAR_TO_SRGB_DOMAIN) + + np.float32(0.5), + 0, + _TRANSFER_LUT_SIZE - 1, + ).astype(np.uint16) + return lut[idx] + + def _smoothstep01(x: np.ndarray) -> np.ndarray: """Hermite smoothstep: 0 at x<=0, 1 at x>=1, smooth S-curve between.""" x = np.clip(x, 0.0, 1.0) @@ -178,48 +239,61 @@ def _highlight_recover_linear( - By computing a single brightness metric and rescaling all channels equally, we preserve the original RGB color ratios (hue and relative saturation). - For 16-bit sources with headroom (values > 1.0), the curve compresses into - [pivot, headroom_ceiling] rather than [pivot, 1.0], preserving subtle tonal - separation above 1.0 that represents real recovered detail. + The curve keeps normal whites bright, avoids changing midtones, and rolls + over values above display white into visible highlight range instead of + crushing them toward the pivot. Args: rgb_linear: Float32 RGB array (H, W, 3) in linear light, may have values > 1.0 amount: Recovery strength 0.0-1.0 (mapped from slider -100 to 0) pivot: Brightness threshold below which no recovery occurs - k: Compression factor (adaptive). Higher k = stronger shoulder. + k: Compression factor for values above display white. chroma_rolloff: Desaturation amount in extreme highlights (0-1) - headroom_ceiling: Maximum output brightness (> 1.0 preserves headroom detail) + headroom_ceiling: Estimated source headroom used to size the over-white shoulder Returns: Recovered float32 RGB array (linear) """ + amount = float(np.clip(amount, 0.0, 1.0)) if amount < 0.001: return rgb_linear eps = 1e-7 + pivot = float(np.clip(pivot, 0.0, 0.95)) + headroom_ceiling = max(float(headroom_ceiling), 1.0) + overwhite_k = max(float(k), eps) # Use max-channel as brightness metric - handles saturated highlights better than luminance brightness = rgb_linear.max(axis=2) - # Highlights recovery: we want to pull down highlights to reveal detail. - # Rational compression formula: y = x / (1 + kx). - # We apply this relative to the pivot. - # normalization: brightness is already linear. - x_norm = (brightness - pivot) / (headroom_ceiling - pivot + eps) - x_norm = np.clip(x_norm, 0.0, None) - - # Compressed value (normalized context) - # At amount=1, we use full rational compression. - # At amount=0, we use identity. - compressed_norm = x_norm / (1.0 + k * amount * x_norm) - - # Map back to brightness scale - target_brightness = pivot + compressed_norm * (headroom_ceiling - pivot) - # Clamp to headroom_ceiling to satisfy docstring contract (small amount can cause overshoot) - target_brightness = np.minimum(target_brightness, headroom_ceiling) - - # If brightness was below pivot, keep it as is - target_brightness = np.where(brightness > pivot, target_brightness, brightness) + # The old rational curve moved display white near the pivot at full strength, + # which made recovered highlights look dull. Use a bounded shoulder instead: + # 1. Below display white, subtract a small smooth rolloff that is strongest + # at white and zero at the pivot. + # 2. Above display white, compress exposure/headroom overshoot back below + # clipping while keeping tonal separation. + normal_range = max(1.0 - pivot, eps) + white_drop = min(0.20, normal_range * 0.45) * amount + target_at_white = 1.0 - white_drop + + highlight_mask = _smoothstep01((brightness - pivot) / normal_range) + target_brightness = brightness - white_drop * highlight_mask + + overwhite_mask = brightness > 1.0 + if np.any(overwhite_mask): + excess = brightness[overwhite_mask] - 1.0 + retained_excess = ( + excess * (1.0 - amount) / (1.0 + overwhite_k * amount * excess) + ) + headroom_span = min(max(headroom_ceiling - 1.0, 0.0), 2.0) + visible_span = white_drop * (1.0 + 0.25 * headroom_span) + shoulder_width = 0.25 + 0.35 * (1.0 - amount) + 0.10 * headroom_span + visible_excess = visible_span * excess / (excess + shoulder_width + eps) + overwhite_target = target_at_white + retained_excess + visible_excess + target_brightness[overwhite_mask] = np.minimum( + overwhite_target, + brightness[overwhite_mask], + ) # Rescale RGB to preserve hue/chroma # Protect against div-by-zero or huge scale factors for near-black pixels diff --git a/faststack/imaging/metadata.py b/faststack/imaging/metadata.py index be044cb..1fcf2f0 100644 --- a/faststack/imaging/metadata.py +++ b/faststack/imaging/metadata.py @@ -163,7 +163,150 @@ def format_shutter_speed_camera_style(exposure_value: Any) -> str: return _SHUTTER_TABLE[best_i][1] -def get_exif_brief(path: Union[str, Path]) -> str: +_EXIF_BRIEF_SUFFIXES = frozenset( + {".jpg", ".jpeg", ".jpe", ".tif", ".tiff", ".heif", ".heic"} +) +_GPS_IFD_TAG = 0x8825 +_EARTH_RADIUS_METERS = 6_371_008.8 + + +def _exif_rational_to_float(x: Any) -> Optional[float]: + """Convert EXIF rational-ish values to float.""" + if x is None: + return None + if hasattr(x, "numerator") and hasattr(x, "denominator"): + try: + n, d = int(x.numerator), int(x.denominator) + if d != 0: + return float(Fraction(n, d)) + except Exception as e: + log.debug( + "_exif_rational_to_float failed for rational object %r (%s): %s", + x, + type(x).__name__, + e, + ) + if isinstance(x, (tuple, list)) and len(x) == 2: + try: + n, d = int(x[0]), int(x[1]) + if d != 0: + return float(Fraction(n, d)) + except Exception as e: + log.debug( + "_exif_rational_to_float failed for tuple/list %r (%s): %s", + x, + type(x).__name__, + e, + ) + try: + return float(x) + except Exception as e: + if x is not None: + log.debug( + "_exif_rational_to_float failed for value %r (%s): %s", + x, + type(x).__name__, + e, + ) + return None + + +def _get_exif_ifd(exif_obj: Any, ifd_tag: int) -> dict: + """Read a Pillow EXIF sub-IFD as a plain dict.""" + if not hasattr(exif_obj, "get_ifd"): + return {} + try: + return dict(exif_obj.get_ifd(ifd_tag) or {}) + except Exception as e: + log.debug("Failed to read EXIF IFD %s: %s", ifd_tag, e) + return {} + + +def _clean_gps_ref(value: Any) -> str: + return clean_exif_value(value).upper() + + +def _gps_degrees(value: Any) -> Optional[float]: + if not isinstance(value, (list, tuple)) or len(value) != 3: + return None + degrees = _exif_rational_to_float(value[0]) + minutes = _exif_rational_to_float(value[1]) + seconds = _exif_rational_to_float(value[2]) + if degrees is None or minutes is None or seconds is None: + return None + return degrees + (minutes / 60.0) + (seconds / 3600.0) + + +def _gps_coordinates_from_info(gps_info: Any) -> Optional[tuple[float, float]]: + if not isinstance(gps_info, dict): + return None + + lat_value = gps_info.get(2) or gps_info.get("GPSLatitude") + lon_value = gps_info.get(4) or gps_info.get("GPSLongitude") + if lat_value is None or lon_value is None: + return None + + lat = _gps_degrees(lat_value) + lon = _gps_degrees(lon_value) + if lat is None or lon is None: + return None + + lat_ref = gps_info.get(1) or gps_info.get("GPSLatitudeRef") + lon_ref = gps_info.get(3) or gps_info.get("GPSLongitudeRef") + if lat_ref is not None and _clean_gps_ref(lat_ref) == "S": + lat = -lat + if lon_ref is not None and _clean_gps_ref(lon_ref) == "W": + lon = -lon + + if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0): + return None + return lat, lon + + +def get_exif_gps_coordinates(path: Union[str, Path]) -> Optional[tuple[float, float]]: + """Return decimal GPS coordinates from EXIF, if present.""" + path = Path(path) + if path.suffix.lower() not in _EXIF_BRIEF_SUFFIXES or not path.exists(): + return None + + try: + with Image.open(path) as img: + exif_obj = img.getexif() + if not exif_obj: + return None + gps_ifd = _get_exif_ifd(exif_obj, _GPS_IFD_TAG) + gps_raw = dict(exif_obj).get(_GPS_IFD_TAG) + except Exception: + return None + + gps_info = gps_ifd if gps_ifd else (gps_raw if isinstance(gps_raw, dict) else None) + return _gps_coordinates_from_info(gps_info) + + +def _distance_meters( + first: tuple[float, float], second: tuple[float, float] +) -> Optional[float]: + lat1, lon1 = (math.radians(first[0]), math.radians(first[1])) + lat2, lon2 = (math.radians(second[0]), math.radians(second[1])) + d_lat = lat2 - lat1 + d_lon = lon2 - lon1 + a = ( + math.sin(d_lat / 2.0) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(d_lon / 2.0) ** 2 + ) + distance = 2.0 * _EARTH_RADIUS_METERS * math.asin(min(1.0, math.sqrt(a))) + if not math.isfinite(distance): + return None + return distance + + +def _format_distance_meters(distance: float) -> str: + return f"{max(0, int(distance + 0.5))} m" + + +def get_exif_brief( + path: Union[str, Path], previous_path: Union[str, Path, None] = None +) -> str: """Return a compact EXIF summary for the status bar. Opens only the image header (Pillow lazy-loads), extracts ISO, aperture, @@ -173,15 +316,7 @@ def get_exif_brief(path: Union[str, Path]) -> str: Supported formats: JPEG, TIFF, HEIF. """ path = Path(path) - if path.suffix.lower() not in { - ".jpg", - ".jpeg", - ".jpe", - ".tif", - ".tiff", - ".heif", - ".heic", - }: + if path.suffix.lower() not in _EXIF_BRIEF_SUFFIXES: return "" if not path.exists(): return "" @@ -194,6 +329,7 @@ def get_exif_brief(path: Union[str, Path]) -> str: exif_ifd = dict( exif.get_ifd(ExifTags.IFD.Exif) if hasattr(ExifTags, "IFD") else {} ) + gps_ifd = _get_exif_ifd(exif, _GPS_IFD_TAG) if not exif: return "" @@ -202,6 +338,9 @@ def get_exif_brief(path: Union[str, Path]) -> str: tags = dict(exif) tags.update(exif_ifd) + gps_raw = tags.get(_GPS_IFD_TAG) + gps_info = gps_ifd if gps_ifd else (gps_raw if isinstance(gps_raw, dict) else None) + gps_coordinates = _gps_coordinates_from_info(gps_info) parts: list[str] = [] @@ -249,6 +388,13 @@ def get_exif_brief(path: Union[str, Path]) -> str: except Exception as e: log.error(f"Failed to parse EXIF datetime {dt!r}: {e}", exc_info=True) + if previous_path is not None and gps_coordinates is not None: + previous_gps_coordinates = get_exif_gps_coordinates(previous_path) + if previous_gps_coordinates is not None: + distance = _distance_meters(previous_gps_coordinates, gps_coordinates) + if distance is not None: + parts.append(_format_distance_meters(distance)) + return " | ".join(parts) @@ -277,7 +423,7 @@ def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: # Fetch GPS sub-IFD while image is still open (Pillow ≥8.2 # stores GPSInfo as an integer IFD offset, not a dict) - gps_ifd = dict(exif_obj.get_ifd(0x8825)) if hasattr(ExifTags, "IFD") else {} + gps_ifd = _get_exif_ifd(exif_obj, _GPS_IFD_TAG) # Normalize to a dict for consistency exif = dict(exif_obj) @@ -394,32 +540,10 @@ def get_val(key): # if it is already a mapping (older Pillow versions). gps_raw = get_val("GPSInfo") gps_info = gps_ifd if gps_ifd else (gps_raw if isinstance(gps_raw, dict) else None) - if gps_info: - try: - - def convert_to_degrees(value): - d = float(value[0]) - m = float(value[1]) - s = float(value[2]) - return d + (m / 60.0) + (s / 3600.0) - - # GPSInfo keys are integers. - # 1: GPSLatitudeRef, 2: GPSLatitude - # 3: GPSLongitudeRef, 4: GPSLongitude - - if 2 in gps_info and 4 in gps_info: - lat = convert_to_degrees(gps_info[2]) - lon = convert_to_degrees(gps_info[4]) - - if 1 in gps_info and gps_info[1] == "S": - lat = -lat - if 3 in gps_info and gps_info[3] == "W": - lon = -lon - - summary["GPS"] = f"{lat:.5f}, {lon:.5f}" - except Exception as e: - log.warning(f"Failed to parse GPS info: {e}") - pass + coordinates = _gps_coordinates_from_info(gps_info) + if coordinates is not None: + lat, lon = coordinates + summary["GPS"] = f"{lat:.5f}, {lon:.5f}" # Convert all values in full dict to string to ensure JSON serializability for QML # Apply cleaning to all values diff --git a/faststack/imaging/orientation.py b/faststack/imaging/orientation.py index 692c54c..28f1319 100644 --- a/faststack/imaging/orientation.py +++ b/faststack/imaging/orientation.py @@ -7,6 +7,8 @@ import numpy as np from PIL import Image +from faststack.imaging.optional_deps import cv2 + log = logging.getLogger(__name__) @@ -51,6 +53,23 @@ def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray: return np.ascontiguousarray(buffer) return buffer + # cv2 fast paths: materializing a numpy rot90/flip view via + # ascontiguousarray is a cache-hostile transposing copy (~244ms at 20MP); + # cv2.rotate/flip produce bit-identical contiguous output ~5x faster. + # Orientations 5/7 (mirror + rotate) stay on numpy — cameras essentially + # never emit them. + if cv2 is not None and buffer.flags["C_CONTIGUOUS"]: + if orientation == 2: + return cv2.flip(buffer, 1) + if orientation == 3: + return cv2.rotate(buffer, cv2.ROTATE_180) + if orientation == 4: + return cv2.flip(buffer, 0) + if orientation == 6: + return cv2.rotate(buffer, cv2.ROTATE_90_CLOCKWISE) + if orientation == 8: + return cv2.rotate(buffer, cv2.ROTATE_90_COUNTERCLOCKWISE) + # Apply transformation based on orientation if orientation == 2: # Mirrored horizontally diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index adde7c1..3a3c6d1 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -114,6 +114,10 @@ def _make_raw_placeholder(width: int, height: int) -> np.ndarray: # Cache for ICC transforms to avoid rebuilding on every image _icc_transform_cache: Dict[tuple, ImageCms.ImageCmsTransform] = {} +# Cache parsed source ICC profiles by digest so we do not rebuild the same +# source profile object for every preview render. +_source_profile_cache: Dict[str, ImageCms.ImageCmsProfile] = {} + # Thread lock for all ICC caches _icc_cache_lock = threading.Lock() @@ -146,14 +150,43 @@ def get_icc_transform( def clear_icc_caches(): """Clear all ICC-related caches (profiles and transforms).""" - global _monitor_profile_cache, _icc_transform_cache, _monitor_profile_warning_logged + global _monitor_profile_cache, _icc_transform_cache, _source_profile_cache + global _monitor_profile_warning_logged with _icc_cache_lock: _monitor_profile_cache.clear() _icc_transform_cache.clear() + _source_profile_cache.clear() _monitor_profile_warning_logged = False log.info("Cleared ICC profile and transform caches") +def _get_source_profile( + icc_bytes: Optional[bytes], +) -> tuple[ImageCms.ImageCmsProfile, str]: + """Return a cached source ICC profile and stable cache key.""" + if not icc_bytes: + return SRGB_PROFILE, "srgb_builtin" + + src_profile_key = hashlib.sha256(icc_bytes).hexdigest() + with _icc_cache_lock: + cached = _source_profile_cache.get(src_profile_key) + if cached is not None: + return cached, src_profile_key + + try: + src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) + except Exception as e: + log.warning("Failed to parse ICC profile: %s", e) + return SRGB_PROFILE, "srgb_builtin" + + with _icc_cache_lock: + if len(_source_profile_cache) >= 32: + _source_profile_cache.pop(next(iter(_source_profile_cache))) + _source_profile_cache[src_profile_key] = src_profile + + return src_profile, src_profile_key + + def get_monitor_profile() -> Optional[ImageCms.ImageCmsProfile]: """Dynamically load monitor ICC profile based on current config. @@ -333,18 +366,7 @@ def apply_loupe_color_correction( if monitor_profile is None: return corrected - src_profile = None - src_profile_key = None - if icc_bytes: - try: - src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) - src_profile_key = hashlib.sha256(icc_bytes).hexdigest() - except Exception as e: - log.warning("Failed to parse ICC profile: %s", e) - - if src_profile is None: - src_profile = SRGB_PROFILE - src_profile_key = "srgb_builtin" + src_profile, src_profile_key = _get_source_profile(icc_bytes) try: img = PILImage.fromarray(corrected) @@ -419,9 +441,31 @@ def __init__( def set_image_files(self, image_files: List[ImageFile]): with self._futures_lock: - if self.image_files != image_files: - self.image_files = image_files - self._cancel_all_locked() + if self.image_files == image_files: + return + old = self.image_files + self.image_files = image_files + + # A save changes one entry's metadata (timestamp/backup flag); + # cancelling the whole generation would force the entire prefetch + # window to re-decode. When the list shape is unchanged and only a + # few entries differ, invalidate just those indices. + if len(old) == len(image_files): + changed = [ + i for i, (a, b) in enumerate(zip(old, image_files)) if a != b + ] + if len(changed) <= 8: + for i in changed: + fut = self.futures.get(i) + if fut is not None: + fut.cancel() + self.futures.pop(i, None) + self.future_paths.pop(i, None) + for scheduled in self._scheduled.values(): + scheduled.discard(i) + return + + self._cancel_all_locked() def update_prefetch( self, @@ -656,6 +700,11 @@ def _decode_and_cache( if generation != self.generation or self._stop_event.is_set(): return None + # Captured BEFORE the file is read: cache_put consumers compare this + # against per-path invalidation epochs to reject decodes whose source + # file was replaced (saved) while the decode was in flight. + decode_started = time.monotonic() + # Use override path if provided, otherwise default to image_file.path target_path = override_path if override_path is not None else image_file.path @@ -775,20 +824,7 @@ def _decode_and_cache( "Failed to read metadata from %s: %s", target_path, e ) - src_profile = None - src_profile_key = None - if icc_bytes: - try: - src_profile = ImageCms.ImageCmsProfile( - io.BytesIO(icc_bytes) - ) - src_profile_key = hashlib.sha256(icc_bytes).hexdigest() - except Exception as e: - log.warning("Failed to parse ICC profile: %s", e) - - if src_profile is None: - src_profile = SRGB_PROFILE - src_profile_key = "srgb_builtin" + src_profile, src_profile_key = _get_source_profile(icc_bytes) try: transform = get_icc_transform( @@ -908,7 +944,7 @@ def _decode_and_cache( return None cache_key = build_cache_key(target_path, display_generation) - self.cache_put(cache_key, decoded) + self.cache_put(cache_key, decoded, target_path, decode_started) return (target_path, display_generation) except Exception as e: @@ -925,6 +961,28 @@ def _cleanup_future(self, index: int, future: Future): self.futures.pop(index, None) self.future_paths.pop(index, None) + def invalidate_path(self, path: Path): + """Targeted invalidation for one file (e.g. after it was re-saved). + + Cancels any in-flight decode for ``path`` and removes its index from + the scheduled sets so the next update_prefetch() re-submits it. Unlike + cancel_all(), this does not touch the generation counter, so decodes + of OTHER paths stay valid. + """ + with self._futures_lock: + for idx, p in list(self.future_paths.items()): + if p == path: + fut = self.futures.get(idx) + if fut is not None: + fut.cancel() + self.futures.pop(idx, None) + self.future_paths.pop(idx, None) + for i, image_file in enumerate(self.image_files): + if image_file.path == path: + for scheduled in self._scheduled.values(): + scheduled.discard(i) + break + def _cancel_all_locked(self): """Internal helper to cancel all pending prefetching tasks. Assumes _futures_lock is already held. diff --git a/faststack/imaging/turbo.py b/faststack/imaging/turbo.py index d64ea89..adf48d2 100644 --- a/faststack/imaging/turbo.py +++ b/faststack/imaging/turbo.py @@ -26,6 +26,7 @@ def _candidate_library_paths() -> list[Optional[str]]: explicit = os.getenv("FASTSTACK_TURBOJPEG_LIB") or os.getenv("TURBOJPEG_LIB") if explicit: candidates.append(explicit) + candidates.extend(_bundled_library_paths()) candidates.append(None) if os.name == "nt": @@ -75,6 +76,38 @@ def _candidate_library_paths() -> list[Optional[str]]: return unique +def _bundled_library_paths() -> list[str]: + """Return libjpeg-turbo candidates inside a frozen app bundle.""" + roots: list[Path] = [] + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + roots.append(Path(meipass)) + + if getattr(sys, "frozen", False) and sys.executable: + executable_dir = Path(sys.executable).resolve().parent + roots.extend([executable_dir, executable_dir / "_internal"]) + + if os.name == "nt": + library_names = ("turbojpeg.dll",) + elif sys.platform == "darwin": + library_names = ("libturbojpeg.dylib",) + else: + library_names = ("libturbojpeg.so", "libturbojpeg.so.0") + + candidates: list[str] = [] + seen: set[str] = set() + for root in roots: + for directory in (root, root / "lib", root / "bin"): + for library_name in library_names: + candidate = directory / library_name + key = os.path.normcase(str(candidate)) + if key in seen: + continue + seen.add(key) + candidates.append(str(candidate)) + return candidates + + def _install_hint() -> str: """Return a concise, platform-specific libjpeg-turbo install hint.""" if os.name == "nt": diff --git a/faststack/io/sidecar.py b/faststack/io/sidecar.py index eae64cd..09e7faf 100644 --- a/faststack/io/sidecar.py +++ b/faststack/io/sidecar.py @@ -40,6 +40,14 @@ def _entrymetadata_from_json(meta: dict) -> EntryMetadata: return EntryMetadata() +def _entrymetadata_to_json(meta: EntryMetadata) -> dict: + """Convert EntryMetadata to a JSON-ready dict without noisy empty edit state.""" + data = meta.__dict__.copy() + if data.get("edit_state") is None: + data.pop("edit_state", None) + return data + + class SidecarManager: def __init__(self, directory: Path, watcher, debug: bool = False): self.directory = directory @@ -120,7 +128,8 @@ def save(self): "version": self.data.version, "last_index": self.data.last_index, "entries": { - key: meta.__dict__ for key, meta in self.data.entries.items() + key: _entrymetadata_to_json(meta) + for key, meta in self.data.entries.items() }, "stacks": self.data.stacks, } @@ -138,27 +147,47 @@ def save(self): @overload def get_metadata( - self, image_ref: Union[str, Path], *, create: Literal[True] = True + self, + image_ref: Union[str, Path], + *, + create: Literal[True] = True, + migrate: bool = True, ) -> EntryMetadata: ... @overload def get_metadata( - self, image_ref: Union[str, Path], *, create: Literal[False] + self, + image_ref: Union[str, Path], + *, + create: Literal[False], + migrate: bool = True, ) -> Optional[EntryMetadata]: ... @overload def get_metadata( - self, image_ref: Union[str, Path], *, create: bool + self, image_ref: Union[str, Path], *, create: bool, migrate: bool = True ) -> Optional[EntryMetadata]: ... def get_metadata( - self, image_ref: Union[str, Path], *, create: bool = True + self, + image_ref: Union[str, Path], + *, + create: bool = True, + migrate: bool = True, ) -> Optional[EntryMetadata]: """Get metadata for an image, optionally creating a persistent entry. When create=True (default), always returns an EntryMetadata (creating and storing one if it doesn't exist). When create=False, returns None if no entry exists — callers must handle the None case explicitly. + + ``migrate=False`` skips the legacy-key migration scan, which walks + EVERY sidecar entry with per-entry filesystem checks. That scan runs + on every lookup miss, so bulk read-only callers (grid refresh, + bulk metadata maps) — which miss for most images in a folder — must + disable it or pay O(images x entries) filesystem stats per refresh. + User-action paths keep migrate=True so ancient entries still get + migrated when an image is actually viewed or modified. """ stable_key, candidate_keys = self._lookup_keys(image_ref) if not stable_key: @@ -180,7 +209,7 @@ def get_metadata( if candidate_key in self.data.entries and candidate_key != stable_key: del self.data.entries[candidate_key] break - if meta is None: + if meta is None and migrate: for existing_key, existing_meta in list(self.data.entries.items()): if existing_key == stable_key: continue diff --git a/faststack/io/watcher.py b/faststack/io/watcher.py index acf2044..71a3628 100644 --- a/faststack/io/watcher.py +++ b/faststack/io/watcher.py @@ -14,6 +14,10 @@ # Matches FastStack backup filenames: name-backup.jpg, name-backup2.jpg, etc. _BACKUP_RE = re.compile(r"-backup\d*\.jpe?g$") +_TEMP_IMAGE_RE = re.compile( + r"/\.[^/]+\.(?:jpe?g|jpe|tiff?|cr2|cr3|nef|arw|orf|rw2|raf|dng)$", + re.IGNORECASE, +) def _is_ignored_path(path: str) -> bool: @@ -24,6 +28,7 @@ def _is_ignored_path(path: str) -> bool: p.endswith(".tmp") or p.endswith("faststack.json") or ".__faststack_tmp__" in p + or _TEMP_IMAGE_RE.search(p) is not None or _BACKUP_RE.search(p) is not None or "image recycle bin" in p.split("/") ) diff --git a/faststack/models.py b/faststack/models.py index c83c5fb..15684e8 100644 --- a/faststack/models.py +++ b/faststack/models.py @@ -72,6 +72,7 @@ class EntryMetadata: uploaded_date: Optional[str] = None edited: bool = False edited_date: Optional[str] = None + edit_state: Optional[Dict[str, Any]] = None restacked: bool = False restacked_date: Optional[str] = None favorite: bool = False diff --git a/faststack/qml/CompactEditorWindow.qml b/faststack/qml/CompactEditorWindow.qml index 50f2b09..3d34075 100644 --- a/faststack/qml/CompactEditorWindow.qml +++ b/faststack/qml/CompactEditorWindow.qml @@ -41,6 +41,8 @@ Window { } else { positionAtRightGutter() } + compactEditor.keyboardHandlerReady = true + Qt.callLater(compactEditor.focusKeyboardHandler) } function positionAtRightGutter() { @@ -53,6 +55,7 @@ Window { onXChanged: if (visible) compactSettings.savedX = x onYChanged: if (visible) compactSettings.savedY = y + onActiveChanged: if (active) compactEditor.focusKeyboardHandler() // --- Color Palette (matches full editor) --- readonly property color backgroundColor: "#1e1e1e" @@ -69,9 +72,39 @@ Window { Material.accent: compactEditor.accentColor property int updatePulse: 0 + readonly property bool cropActive: compactEditor.uiStateRef ? compactEditor.uiStateRef.isCropping : false property int lastLoadedIndex: -1 + property bool lastLoadWasFull: false + property bool forceNextPreviewLoad: false property string closeTooltip: "Close editor" + // Key of the slider that the Up/Down arrow keys will adjust. The matching + // row is highlighted so the user can see which control is targeted. Click a + // slider's label (or value) to retarget. Defaults to the first slider so the + // arrow keys work immediately when the editor opens. + property string highlightedSliderKey: "exposure" + property bool keyboardHandlerReady: false + + function focusKeyboardHandler() { + if (compactEditor.visible && compactEditor.keyboardHandlerReady) keyScope.forceActiveFocus() + } + + // Adjust the currently highlighted slider by `delta` slider-space steps + // (the sliders all run -100..100). Driven by the Up/Down arrow keys. + function adjustHighlightedSlider(delta) { + if (compactEditor.cropActive) return + if (!compactEditor.controllerRef || !compactEditor.highlightedSliderKey) return + compactEditor.ensureEditorLoaded("slider-key") + var key = compactEditor.highlightedSliderKey + var scale = compactEditor.sliderEditScale(key) + // Convert the stored edit-space value into slider space, step it, clamp, + // then convert back to edit space the way the sliders do. + var sliderVal = compactEditor.getBackendValue(key) / scale * 100 + sliderVal = Math.max(-100, Math.min(100, sliderVal + delta)) + compactEditor.controllerRef.set_edit_parameter(key, sliderVal / 100 * scale) + compactEditor.updatePulse++ // refreshes sliders + histogram + } + function refreshCloseTooltip() { if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) compactEditor.closeTooltip = "Discard unsaved edits and close" @@ -79,28 +112,86 @@ Window { compactEditor.closeTooltip = "Close editor" } + function requestClose() { + if (compactEditor.cropActive) { + if (compactEditor.controllerRef) compactEditor.controllerRef.cancel_crop_mode() + return + } + if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) { + if (!discardDialog.opened) discardDialog.open() + } else { + if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false + } + } + + function handleArrowKey(key) { + if (compactEditor.cropActive || discardDialog.opened) return false + if (key === Qt.Key_Left || key === Qt.Key_Right) { + if (compactEditor.controllerRef) + compactEditor.controllerRef.handle_key_from_compact_editor(key, Qt.NoModifier, "") + return true + } + if (key === Qt.Key_Up) { + compactEditor.adjustHighlightedSlider(1) + return true + } + if (key === Qt.Key_Down) { + compactEditor.adjustHighlightedSlider(-1) + return true + } + return false + } + Timer { id: deferredLoadTimer - interval: 200 + interval: 250 repeat: false onTriggered: { if (!compactEditor.visible || !compactEditor.controllerRef || !compactEditor.uiStateRef) return var idx = compactEditor.uiStateRef.currentIndex - if (idx === compactEditor.lastLoadedIndex) return + var forceLoad = compactEditor.forceNextPreviewLoad + compactEditor.forceNextPreviewLoad = false + if (!forceLoad && idx === compactEditor.lastLoadedIndex) { + compactEditor.controllerRef.note_compact_editor_reload_skipped(idx, "already-loaded") + return + } + var loaded = compactEditor.controllerRef.load_image_for_editing_preview() + if (!loaded) { + compactEditor.lastLoadedIndex = -1 + compactEditor.lastLoadWasFull = false + return + } compactEditor.lastLoadedIndex = idx - compactEditor.controllerRef.load_image_for_editing() + compactEditor.lastLoadWasFull = false compactEditor.controllerRef.update_histogram() compactEditor.updatePulse++ } } - function ensureEditorLoaded() { + function schedulePreviewLoad(reason, forceLoad) { + if (!compactEditor.visible || !compactEditor.controllerRef || !compactEditor.uiStateRef) return + var idx = compactEditor.uiStateRef.currentIndex + var coalesced = deferredLoadTimer.running + compactEditor.forceNextPreviewLoad = compactEditor.forceNextPreviewLoad || !!forceLoad + if (idx !== compactEditor.lastLoadedIndex) compactEditor.lastLoadWasFull = false + compactEditor.controllerRef.note_compact_editor_reload_scheduled(idx, coalesced, deferredLoadTimer.interval, reason) + deferredLoadTimer.restart() + } + + function ensureEditorLoaded(reason) { if (!compactEditor.controllerRef || !compactEditor.uiStateRef) return var idx = compactEditor.uiStateRef.currentIndex - if (idx !== compactEditor.lastLoadedIndex) { + if (idx !== compactEditor.lastLoadedIndex || !compactEditor.lastLoadWasFull) { deferredLoadTimer.stop() + compactEditor.controllerRef.note_compact_editor_full_load_required(idx, reason || "user-action") + var loaded = compactEditor.controllerRef.load_image_for_editing() + if (!loaded) { + compactEditor.lastLoadedIndex = -1 + compactEditor.lastLoadWasFull = false + return + } compactEditor.lastLoadedIndex = idx - compactEditor.controllerRef.load_image_for_editing() + compactEditor.lastLoadWasFull = true compactEditor.controllerRef.update_histogram() compactEditor.updatePulse++ } @@ -109,15 +200,17 @@ Window { Connections { target: compactEditor.uiStateRef function onCurrentIndexChanged() { - if (!compactEditor.visible) return - deferredLoadTimer.restart() + compactEditor.schedulePreviewLoad("navigation") } } onVisibleChanged: { if (visible && compactEditor.controllerRef) { - ensureEditorLoaded() + compactEditor.schedulePreviewLoad("open", true) if (compactSettings.savedX < 0) positionAtRightGutter() + Qt.callLater(compactEditor.focusKeyboardHandler) + } else { + deferredLoadTimer.stop() } } @@ -138,41 +231,90 @@ Window { return 0.0; } + function sliderEditScale(key) { + if (key === "contrast") return 0.5 + return (key === "exposure" || key === "whites") ? 2.0 : 1.0 + } + + Shortcut { + sequence: "Left" + context: Qt.WindowShortcut + enabled: compactEditor.visible && !compactEditor.cropActive && !discardDialog.opened + onActivated: compactEditor.handleArrowKey(Qt.Key_Left) + } + + Shortcut { + sequence: "Right" + context: Qt.WindowShortcut + enabled: compactEditor.visible && !compactEditor.cropActive && !discardDialog.opened + onActivated: compactEditor.handleArrowKey(Qt.Key_Right) + } + onClosing: (close) => { if (compactEditor.uiStateRef && compactEditor.controllerRef) { - if (compactEditor.controllerRef.has_unsaved_edits()) { - close.accepted = false - discardDialog.open() - return - } - compactEditor.uiStateRef.isEditorOpen = false + close.accepted = false + compactEditor.requestClose() } } - // Forward navigation keys to main window + // Keyboard handling for the compact editor window. + // + // Arrow keys are handled by WindowShortcut entries above so they still work + // when a child control has focus: + // - Left / Right -> previous / next image + // - Up / Down -> raise / lower the highlighted slider + // + // This focus scope handles the remaining compact-editor keys: + // - Esc / E / S / O -> editor-local actions (close / save / crop) + // - everything else -> forwarded to the main window key bindings so + // keys like B (batch), F, D, I, etc. still work + // while the editor is focused. FocusScope { id: keyScope anchors.fill: parent focus: compactEditor.visible Keys.onPressed: function(event) { - if (event.key === Qt.Key_Escape) { - if (compactEditor.uiStateRef && compactEditor.controllerRef) { - if (compactEditor.controllerRef.has_unsaved_edits()) { - discardDialog.open() - } else { - compactEditor.uiStateRef.isEditorOpen = false - } + if (compactEditor.cropActive) { + if (event.key === Qt.Key_Escape) { + if (compactEditor.controllerRef) compactEditor.controllerRef.cancel_crop_mode() + event.accepted = true + return + } else if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + if (compactEditor.controllerRef) compactEditor.controllerRef.execute_crop() + event.accepted = true + return + } else if (event.key === Qt.Key_O) { + if (compactEditor.controllerRef) compactEditor.controllerRef.toggle_crop_mode() + event.accepted = true + return + } else if (event.key === Qt.Key_S) { + if (compactEditor.uiStateRef) compactEditor.uiStateRef.statusMessage = "Apply or cancel the crop before saving" + event.accepted = true + return + } else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Up || event.key === Qt.Key_Down) { + event.accepted = true + return } + } + + if (compactEditor.handleArrowKey(event.key)) { + event.accepted = true + } else if (event.key === Qt.Key_Escape) { + compactEditor.requestClose() event.accepted = true } else if (event.key === Qt.Key_E && !(event.modifiers & Qt.ControlModifier)) { - if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false + compactEditor.requestClose() event.accepted = true - } else if (event.key === Qt.Key_S && !(event.modifiers & Qt.ControlModifier)) { - compactEditor.ensureEditorLoaded() - if (compactEditor.controllerRef) compactEditor.controllerRef.save_edited_image() + } else if (event.key === Qt.Key_S) { + // S or Ctrl+S both save the live edits from the compact editor. + compactEditor.ensureEditorLoaded("save") + if (compactEditor.uiStateRef && !compactEditor.uiStateRef.isSaving && compactEditor.controllerRef) + compactEditor.controllerRef.save_edited_image() event.accepted = true - } else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) { + } else { + // Forward every other key (B, F, D, I, G, etc.) to the main + // window's key bindings. if (compactEditor.controllerRef) compactEditor.controllerRef.handle_key_from_compact_editor(event.key, event.modifiers, event.text) event.accepted = true @@ -195,7 +337,7 @@ Window { } onAccepted: { - if (compactEditor.controllerRef) compactEditor.controllerRef.reset_edit_parameters() + if (compactEditor.controllerRef) compactEditor.controllerRef.discard_edit_parameters() if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false } } @@ -221,15 +363,73 @@ Window { Item { Layout.fillWidth: true } + Button { + id: rotateCcwBtn + implicitWidth: 26; implicitHeight: 26 + Layout.minimumWidth: 26; Layout.preferredWidth: 26; Layout.maximumWidth: 26 + Layout.minimumHeight: 26; Layout.preferredHeight: 26; Layout.maximumHeight: 26 + enabled: !compactEditor.cropActive + padding: 0 + flat: true + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: "Rotate counter-clockwise" + onClicked: { + compactEditor.ensureEditorLoaded("rotate-ccw") + if (compactEditor.controllerRef) compactEditor.controllerRef.rotate_image_ccw() + } + contentItem: Text { + text: "↺" + font.pixelSize: 16 + color: rotateCcwBtn.hovered ? compactEditor.accentColorHover : compactEditor.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: rotateCcwBtn.hovered ? "#30ffffff" : "transparent" + } + } + + Button { + id: rotateCwBtn + implicitWidth: 26; implicitHeight: 26 + Layout.minimumWidth: 26; Layout.preferredWidth: 26; Layout.maximumWidth: 26 + Layout.minimumHeight: 26; Layout.preferredHeight: 26; Layout.maximumHeight: 26 + enabled: !compactEditor.cropActive + padding: 0 + flat: true + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: "Rotate clockwise" + onClicked: { + compactEditor.ensureEditorLoaded("rotate-cw") + if (compactEditor.controllerRef) compactEditor.controllerRef.rotate_image_cw() + } + contentItem: Text { + text: "↻" + font.pixelSize: 16 + color: rotateCwBtn.hovered ? compactEditor.accentColorHover : compactEditor.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: 4 + color: rotateCwBtn.hovered ? "#30ffffff" : "transparent" + } + } + // Expand button Button { id: expandBtn implicitWidth: 26; implicitHeight: 26 Layout.minimumWidth: 26; Layout.preferredWidth: 26; Layout.maximumWidth: 26 Layout.minimumHeight: 26; Layout.preferredHeight: 26; Layout.maximumHeight: 26 + enabled: !compactEditor.cropActive padding: 0 flat: true onClicked: { + compactEditor.ensureEditorLoaded("expand") if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorExpanded = true } contentItem: Text { @@ -257,13 +457,7 @@ Window { ToolTip.delay: 500 ToolTip.text: compactEditor.closeTooltip onHoveredChanged: if (hovered) compactEditor.refreshCloseTooltip() - onClicked: { - if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) { - discardDialog.open() - } else { - if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false - } - } + onClicked: compactEditor.requestClose() contentItem: Text { text: "✕" font.pixelSize: 13 @@ -454,10 +648,11 @@ Window { implicitWidth: 36; implicitHeight: 16 Layout.minimumWidth: 36; Layout.preferredWidth: 36; Layout.maximumWidth: 36 Layout.minimumHeight: 16; Layout.preferredHeight: 16; Layout.maximumHeight: 16 + enabled: !compactEditor.cropActive padding: 0 flat: true onClicked: { - compactEditor.ensureEditorLoaded() + compactEditor.ensureEditorLoaded("auto-levels") if (compactEditor.controllerRef) compactEditor.controllerRef.auto_levels() compactEditor.updatePulse++ } @@ -479,6 +674,8 @@ Window { ListModel { id: lightModel ListElement { name: "Exposure"; key: "exposure"; min: -100; max: 100 } + ListElement { name: "Brightness"; key: "brightness"; min: -100; max: 100 } + ListElement { name: "Contrast"; key: "contrast"; min: -100; max: 100 } ListElement { name: "Whites"; key: "whites"; min: -100; max: 100 } ListElement { name: "Shadows"; key: "shadows"; min: -100; max: 100 } ListElement { name: "Blacks"; key: "blacks"; min: -100; max: 100 } @@ -508,10 +705,11 @@ Window { implicitWidth: 36; implicitHeight: 16 Layout.minimumWidth: 36; Layout.preferredWidth: 36; Layout.maximumWidth: 36 Layout.minimumHeight: 16; Layout.preferredHeight: 16; Layout.maximumHeight: 16 + enabled: !compactEditor.cropActive padding: 0 flat: true onClicked: { - compactEditor.ensureEditorLoaded() + compactEditor.ensureEditorLoaded("auto-white-balance") if (compactEditor.controllerRef) compactEditor.controllerRef.auto_white_balance() compactEditor.updatePulse++ } @@ -550,10 +748,11 @@ Window { flat: true Layout.preferredWidth: 60 Layout.preferredHeight: 28 + enabled: !compactEditor.cropActive font.pixelSize: 11 Material.foreground: compactEditor.mutedText onClicked: { - compactEditor.ensureEditorLoaded() + compactEditor.ensureEditorLoaded("reset") if (compactEditor.controllerRef) compactEditor.controllerRef.reset_edit_parameters() compactEditor.updatePulse++ } @@ -574,13 +773,7 @@ Window { ToolTip.delay: 500 ToolTip.text: compactEditor.closeTooltip onHoveredChanged: if (hovered) compactEditor.refreshCloseTooltip() - onClicked: { - if (compactEditor.controllerRef && compactEditor.controllerRef.has_unsaved_edits()) { - discardDialog.open() - } else { - if (compactEditor.uiStateRef) compactEditor.uiStateRef.isEditorOpen = false - } - } + onClicked: compactEditor.requestClose() contentItem: Text { text: closeBtn.text font: closeBtn.font @@ -602,9 +795,9 @@ Window { Layout.preferredWidth: 80 Layout.preferredHeight: 28 font.pixelSize: 11 - enabled: compactEditor.uiStateRef ? !compactEditor.uiStateRef.isSaving : true + enabled: compactEditor.uiStateRef ? (!compactEditor.uiStateRef.isSaving && !compactEditor.cropActive) : true onClicked: { - compactEditor.ensureEditorLoaded() + compactEditor.ensureEditorLoaded("save") if (compactEditor.controllerRef) compactEditor.controllerRef.save_edited_image() } contentItem: Text { @@ -639,25 +832,56 @@ Window { Layout.fillWidth: true spacing: 6 - Text { - text: sliderRow.name - color: compactEditor.textColor - font.pixelSize: 11 - font.weight: Font.Medium + // Clickable label. Clicking it makes this the slider that the + // Up/Down arrow keys adjust; the highlighted row is tinted. + Rectangle { Layout.preferredWidth: 70 + Layout.preferredHeight: 18 Layout.alignment: Qt.AlignVCenter - elide: Text.ElideRight + radius: 3 + property bool isActive: compactEditor.highlightedSliderKey === sliderRow.key + color: isActive ? "#332f6df0" : "transparent" + border.color: isActive ? compactEditor.accentColor : "transparent" + border.width: 1 + + Text { + anchors.fill: parent + anchors.leftMargin: 4 + verticalAlignment: Text.AlignVCenter + text: sliderRow.name + color: parent.isActive ? compactEditor.accentColorHover : compactEditor.textColor + font.pixelSize: 11 + font.weight: parent.isActive ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + + MouseArea { + anchors.fill: parent + enabled: !compactEditor.cropActive + cursorShape: Qt.PointingHandCursor + onClicked: { + compactEditor.highlightedSliderKey = sliderRow.key + compactEditor.focusKeyboardHandler() + } + } } Slider { id: slider Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter + enabled: !compactEditor.cropActive + focusPolicy: Qt.NoFocus from: sliderRow.min to: sliderRow.max stepSize: 1 + property real editScale: compactEditor.sliderEditScale(sliderRow.key) + + property real backendValue: compactEditor.getBackendValue(sliderRow.key) / slider.editScale * sliderRow.max - property real backendValue: compactEditor.getBackendValue(sliderRow.key) * sliderRow.max + function editValueFromSliderValue(sliderValue) { + return sliderValue / sliderRow.max * slider.editScale + } Binding { target: slider @@ -674,7 +898,7 @@ Window { repeat: true onTriggered: { if (Math.abs(slider._pendingValue - slider._lastSentValue) > 0.001) { - if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, slider._pendingValue / sliderRow.max) + if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, slider.editValueFromSliderValue(slider._pendingValue)) slider._lastSentValue = slider._pendingValue } } @@ -697,7 +921,8 @@ Window { } function triggerReset() { - compactEditor.ensureEditorLoaded() + compactEditor.ensureEditorLoaded("slider-reset") + compactEditor.highlightedSliderKey = sliderRow.key slider.isResetting = true sendTimer.stop() if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, 0.0) @@ -710,7 +935,9 @@ Window { onPressedChanged: { if (pressed) { - compactEditor.ensureEditorLoaded() + compactEditor.ensureEditorLoaded("slider-change") + compactEditor.highlightedSliderKey = sliderRow.key + compactEditor.focusKeyboardHandler() compactEditor.slidersPressedCount++ if (!slider.isResetting) { _pendingValue = value @@ -723,7 +950,7 @@ Window { if (slider.isResetting) { if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, 0.0) } else { - if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, value / sliderRow.max) + if (compactEditor.controllerRef) compactEditor.controllerRef.set_edit_parameter(sliderRow.key, slider.editValueFromSliderValue(value)) } if (compactEditor.controllerRef) compactEditor.controllerRef.update_histogram() } @@ -731,6 +958,7 @@ Window { onMoved: { if (slider.isResetting) return + compactEditor.highlightedSliderKey = sliderRow.key _pendingValue = value if (!sendTimer.running) sendTimer.start() } @@ -791,8 +1019,11 @@ Window { MouseArea { anchors.fill: parent + enabled: !compactEditor.cropActive cursorShape: Qt.PointingHandCursor onClicked: { + compactEditor.highlightedSliderKey = sliderRow.key + compactEditor.focusKeyboardHandler() if (!slider.isResetting) slider.triggerReset() } } diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 64df933..1f99782 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -73,9 +73,30 @@ Item { return true } + function cancelCropMode() { + if (!loupeView.uiStateRef || !loupeView.uiStateRef.isCropping || !loupeView.controllerRef) return false + + mainMouseArea.clearPendingRotation(0) + mainMouseArea.endCropInteraction() + mainMouseArea.cropRotation = 0 + mainMouseArea.isRotating = false + loupeView.controllerRef.cancel_crop_mode() + return true + } + Connections { target: loupeView.uiStateRef function onCurrentIndexChanged() { + if (mainMouseArea && ( + mainMouseArea.cropReleasePending + || mainMouseArea.isCropDragging + || mainMouseArea.cropDragMode !== "none" + || mainMouseArea.isRotating + )) { + mainMouseArea.endCropInteraction() + mainMouseArea.isRotating = false + } + // Smart High-Res Logic: // Before the new image loads, decide if we should keep high-res mode. // Rule: Only keep high-res if we are currently "meaningfully zoomed" (> 1.1x fit). @@ -112,12 +133,7 @@ Item { if (mainMouseArea.isRotating) { loupeView.cancelActiveCropRotation() event.accepted = true - } else if (loupeView.controllerRef) { - mainMouseArea.clearPendingRotation(0) - mainMouseArea.endCropInteraction() - loupeView.controllerRef.cancel_crop_mode() - mainMouseArea.cropRotation = 0 - mainMouseArea.isRotating = false + } else if (loupeView.cancelCropMode()) { event.accepted = true } } @@ -365,8 +381,8 @@ Item { id: darkenOverlay anchors.fill: parent z: 90 - visible: loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && loupeView.uiStateRef.darkenOverlayVisible - source: (loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && loupeView.uiStateRef.darkenOverlayVisible) + visible: loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && loupeView.uiStateRef.darkenOverlayVisible && !loupeView.uiStateRef.originalCompareActive + source: (loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && loupeView.uiStateRef.darkenOverlayVisible && !loupeView.uiStateRef.originalCompareActive) ? "image://provider/mask_overlay/" + loupeView.uiStateRef.darkenOverlayGeneration : "" fillMode: Image.Stretch @@ -604,6 +620,47 @@ Item { + } + + // Alignment grid for rotate mode. Lives in the viewport (NOT inside + // imageRotator/mainImage) so the lines stay screen-horizontal and + // screen-vertical while the image rotates underneath them. + Item { + id: rotateAlignGrid + anchors.fill: parent + z: 10 + visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping && mainMouseArea.isRotating + property real gridSpacing: 56 + property color lineColor: "#59ffffff" + property color centerLineColor: "#b3ffffff" + + Repeater { + id: rotateGridVLines + model: rotateAlignGrid.visible ? 2 * Math.ceil(rotateAlignGrid.width / (2 * rotateAlignGrid.gridSpacing)) + 1 : 0 + Rectangle { + required property int index + property int centerIndex: (rotateGridVLines.count - 1) / 2 + x: rotateAlignGrid.width / 2 + (index - centerIndex) * rotateAlignGrid.gridSpacing - width / 2 + y: 0 + width: 1 + height: rotateAlignGrid.height + color: index === centerIndex ? rotateAlignGrid.centerLineColor : rotateAlignGrid.lineColor + } + } + + Repeater { + id: rotateGridHLines + model: rotateAlignGrid.visible ? 2 * Math.ceil(rotateAlignGrid.height / (2 * rotateAlignGrid.gridSpacing)) + 1 : 0 + Rectangle { + required property int index + property int centerIndex: (rotateGridHLines.count - 1) / 2 + x: 0 + y: rotateAlignGrid.height / 2 + (index - centerIndex) * rotateAlignGrid.gridSpacing - height / 2 + width: rotateAlignGrid.width + height: 1 + color: index === centerIndex ? rotateAlignGrid.centerLineColor : rotateAlignGrid.lineColor + } + } } } @@ -633,6 +690,14 @@ Item { property bool isDraggingOutside: false property int dragThreshold: 10 // Minimum distance before checking for outside drag property bool isCropDragging: false + // Flaky-trackpad tolerance: when a crop drag "ends", finalizing is + // deferred briefly (cropReleaseGraceTimer). Cheap trackpads emit + // spurious right-button release/press pairs while the user is still + // dragging; without this grace window each spurious release aborts the + // crop and the next press restarts a tiny box, so the user can never + // draw the box they intend. While true, a real release is pending and a + // fresh press will resume the in-progress drag instead of restarting. + property bool cropReleasePending: false property real cropStartX: 0 property real cropStartY: 0 @@ -701,6 +766,24 @@ Item { setCropBoxStart(box[0], box[1], box[2], box[3]) } + function hasRightButton(mouse) { + return mouse.button === Qt.RightButton || (mouse.buttons & Qt.RightButton) + } + + function setInitialCropBoxAt(mx, my) { + if (!loupeView.uiStateRef) return + + var left = Math.max(0, Math.min(999, mx)) + var top = Math.max(0, Math.min(999, my)) + var right = Math.min(1000, left + 10) + var bottom = Math.min(1000, top + 10) + + if (right - left < 10) left = Math.max(0, right - 10) + if (bottom - top < 10) top = Math.max(0, bottom - 10) + + loupeView.uiStateRef.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + } + function beginNewCrop(mouseX, mouseY, mx, my) { var clampedMx = Math.max(0, Math.min(1000, mx)) var clampedMy = Math.max(0, Math.min(1000, my)) @@ -719,6 +802,11 @@ Item { } function endCropInteraction() { + // Any definitive end (commit, escape, navigation, grace timeout) + // clears the pending-release guard so it can't leak across sessions. + cropReleaseGraceTimer.stop() + cropReleasePending = false + isCropDragging = false cropDragMode = "none" @@ -735,6 +823,22 @@ Item { } } + // Grace window after a crop drag release. A genuine release finalizes + // the crop when this fires; a spurious trackpad release that is quickly + // followed by another press cancels it (see onPressed) and the drag + // resumes seamlessly. + Timer { + id: cropReleaseGraceTimer + interval: 250 + repeat: false + onTriggered: { + if (mainMouseArea.cropReleasePending) { + mainMouseArea.cropReleasePending = false + mainMouseArea.endCropInteraction() + } + } + } + onPressed: function(mouse) { lastX = mouse.x lastY = mouse.y @@ -742,6 +846,14 @@ Item { startY = mouse.y isDraggingOutside = false + // Flaky-trackpad tolerance: if a crop release is still within its + // grace window, finalize that deferred release before interpreting + // the new press. The crop hit-test below handles both normal follow-up + // drags and stuttered trackpad presses from the current visible box. + if (cropReleasePending) { + endCropInteraction() + } + // Darken painting mode if (loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && !loupeView.uiStateRef.isCropping && loupeView.controllerRef) { var imgCoords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) @@ -756,12 +868,12 @@ Item { return } - if (mouse.button === Qt.RightButton) { + if (hasRightButton(mouse) && (!loupeView.uiStateRef || !loupeView.uiStateRef.isCropping)) { // Activate drag guard BEFORE toggle_crop_mode so that any // source/geometry changes it triggers are properly deferred. beginCropInteraction() - if (!loupeView.uiStateRef.isCropping && loupeView.controllerRef) { + if (loupeView.uiStateRef && !loupeView.uiStateRef.isCropping && loupeView.controllerRef) { loupeView.controllerRef.toggle_crop_mode() // Ensure mode is ON } @@ -779,6 +891,7 @@ Item { var my = coords.y * 1000 beginNewCrop(mouse.x, mouse.y, mx, my) + setInitialCropBoxAt(mx, my) return } @@ -970,7 +1083,7 @@ Item { return } - if (pressed && !isDraggingOutside) { + if (pressed && (pressedButtons & Qt.LeftButton) && !(pressedButtons & Qt.RightButton) && !isDraggingOutside) { // Check if we've moved beyond the threshold var dx = mouse.x - startX var dy = mouse.y - startY @@ -1009,7 +1122,13 @@ Item { isDraggingOutside = false if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping && isCropDragging) { - endCropInteraction() + // Defer finalizing the crop briefly. Cheap trackpads emit + // spurious release/press pairs mid-drag; if another press + // arrives within the grace window we resume the drag instead of + // aborting it (see onPressed and cropReleaseGraceTimer). + cropReleasePending = true + isCropDragging = false + cropReleaseGraceTimer.restart() } } diff --git a/faststack/qml/ImageEditorDialog.qml b/faststack/qml/ImageEditorDialog.qml index cfa8e2d..3d3dfa7 100644 --- a/faststack/qml/ImageEditorDialog.qml +++ b/faststack/qml/ImageEditorDialog.qml @@ -45,7 +45,8 @@ Window { Material.accent: accentColor onClosing: (close) => { - if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false + close.accepted = false + imageEditorDialog.requestClose() } onVisibleChanged: { @@ -72,6 +73,19 @@ Window { return 0.0; } + function sliderEditScale(key) { + if (key === "contrast") return 0.5 + return (key === "exposure" || key === "whites") ? 2.0 : 1.0 + } + + function requestClose() { + if (imageEditorDialog.controllerRef && imageEditorDialog.controllerRef.has_unsaved_edits()) { + if (!discardDialog.opened) discardDialog.open() + } else { + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false + } + } + // Background color: imageEditorDialog.backgroundColor @@ -79,7 +93,7 @@ Window { sequence: "Escape" context: Qt.WindowShortcut onActivated: { - if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false + imageEditorDialog.requestClose() } } Shortcut { @@ -99,6 +113,25 @@ Window { } } + Dialog { + id: discardDialog + title: "Discard Edits?" + modal: true + anchors.centerIn: parent + width: 280 + standardButtons: Dialog.Yes | Dialog.No + + Label { + text: "You have unsaved edits.\nDiscard and close?" + wrapMode: Text.WordWrap + } + + onAccepted: { + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.discard_edit_parameters() + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false + } + } + // Component for Section Separator Component { id: sectionSeparator @@ -351,11 +384,11 @@ Window { spacing: 8 Button { - text: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.isRawActive) ? "RAW Loaded" : "Load RAW" + text: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.isRawDeveloping) ? "Developing RAW..." : ((imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.isRawActive && imageEditorDialog.uiStateRef.hasWorkingTif) ? "RAW Loaded" : (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.hasWorkingTif ? "Load RAW" : "Develop RAW")) Layout.fillWidth: true Layout.preferredHeight: imageEditorDialog.secondaryButtonHeight font.pixelSize: 12 - enabled: imageEditorDialog.uiStateRef ? !imageEditorDialog.uiStateRef.isRawActive : false + enabled: imageEditorDialog.uiStateRef ? (!imageEditorDialog.uiStateRef.isRawDeveloping && !(imageEditorDialog.uiStateRef.isRawActive && imageEditorDialog.uiStateRef.hasWorkingTif)) : false onClicked: { if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.enableRawEditing() imageEditorDialog.updatePulse++ @@ -533,7 +566,7 @@ Window { text: "Close" Layout.preferredWidth: 100 onClicked: { - if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false + imageEditorDialog.requestClose() } contentItem: Text { text: closeEditorButton.text @@ -604,18 +637,25 @@ Window { } // Slider - Slider { - id: slider - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - from: sliderRow.minVal - to: sliderRow.maxVal - stepSize: 1 + Slider { + id: slider + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + focusPolicy: Qt.StrongFocus + from: sliderRow.minVal + to: sliderRow.maxVal + stepSize: 1 + property real editScale: imageEditorDialog.sliderEditScale(sliderRow.key) property real backendValue: { - var val = imageEditorDialog.getBackendValue(sliderRow.key) * sliderRow.maxVal + var val = imageEditorDialog.getBackendValue(sliderRow.key) / slider.editScale * sliderRow.maxVal return sliderRow.isReversed ? -val : val } + + function editValueFromSliderValue(sliderValue) { + var sendValue = sliderRow.isReversed ? -sliderValue : sliderValue + return sendValue / sliderRow.maxVal * slider.editScale + } // Auto-sync visual slider with backend changes when not dragging Binding { @@ -633,8 +673,7 @@ Window { repeat: true onTriggered: { if (Math.abs(slider._pendingValue - slider._lastSentValue) > 0.001) { - var sendValue = sliderRow.isReversed ? -slider._pendingValue : slider._pendingValue - if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, sendValue / sliderRow.maxVal) + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, slider.editValueFromSliderValue(slider._pendingValue)) slider._lastSentValue = slider._pendingValue } } @@ -673,6 +712,56 @@ Window { resetTimer.restart() } + function nudgeByKeyboard(step) { + if (slider.isResetting) return false + var nextValue = Math.max(slider.from, Math.min(slider.to, slider.value + step)) + if (nextValue === slider.value) return true + slider.value = nextValue + _pendingValue = nextValue + if (!sendTimer.running) sendTimer.start() + return true + } + + function commitKeyboardAdjust() { + if (slider.isResetting) { + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, 0.0) + } else { + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, slider.editValueFromSliderValue(value)) + } + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.update_histogram() + } + + Keys.priority: Keys.BeforeItem + + Keys.onPressed: function(event) { + if (slider.isResetting) { + if (event.key === Qt.Key_Up || event.key === Qt.Key_Right || event.key === Qt.Key_Down || event.key === Qt.Key_Left) { + event.accepted = true + } + return + } + if (event.key === Qt.Key_Up || event.key === Qt.Key_Right) { + if (slider.nudgeByKeyboard(1)) event.accepted = true + } else if (event.key === Qt.Key_Down || event.key === Qt.Key_Left) { + if (slider.nudgeByKeyboard(-1)) event.accepted = true + } + } + + Keys.onReleased: function(event) { + if (event.isAutoRepeat) return + if (slider.isResetting) { + if (event.key === Qt.Key_Up || event.key === Qt.Key_Right || event.key === Qt.Key_Down || event.key === Qt.Key_Left) { + event.accepted = true + } + return + } + if (event.key === Qt.Key_Up || event.key === Qt.Key_Right || event.key === Qt.Key_Down || event.key === Qt.Key_Left) { + sendTimer.stop() + slider.commitKeyboardAdjust() + event.accepted = true + } + } + onPressedChanged: { if (pressed) { imageEditorDialog.slidersPressedCount++ @@ -692,8 +781,7 @@ Window { if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, 0.0) } else { // Send final value immediately - var sendValue = sliderRow.isReversed ? -value : value - if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, sendValue / sliderRow.maxVal) + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, slider.editValueFromSliderValue(value)) } if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.update_histogram() diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 9cb41f0..65f6c93 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -65,13 +65,38 @@ ApplicationWindow { } } + function clampWindowToVisibleScreen() { + if (root.visibility !== Window.Windowed || !root.screen) return + + var screenX = root.screen.virtualX + var screenY = root.screen.virtualY + var screenWidth = root.screen.desktopAvailableWidth + var screenHeight = root.screen.desktopAvailableHeight + if (screenWidth <= 0 || screenHeight <= 0) return + + var newWidth = Math.min(root.width, screenWidth) + var newHeight = Math.min(root.height, screenHeight) + var newX = Math.max(screenX, Math.min(root.x, screenX + screenWidth - newWidth)) + var newY = Math.max(screenY, Math.min(root.y, screenY + screenHeight - newHeight)) + + root.x = newX + root.y = newY + root.width = newWidth + root.height = newHeight + } + + function openDialogSafely(dialog) { + root.clampWindowToVisibleScreen() + dialog.open() + } + onClosing: function(close) { if (!root.allowCloseWithBatches && root.controllerRef) { var definedBatchCount = root.controllerRef.get_defined_batch_count() if (definedBatchCount > 0) { close.accepted = false quitBatchesDialog.batchCount = definedBatchCount - quitBatchesDialog.open() + root.openDialogSafely(quitBatchesDialog) return } } @@ -81,7 +106,7 @@ ApplicationWindow { && root.uiStateRef.hasRecycleBinItems) { close.accepted = false root.uiStateRef.refreshRecycleBinStats() - recycleBinCleanupDialog.open() + root.openDialogSafely(recycleBinCleanupDialog) return } @@ -128,12 +153,12 @@ ApplicationWindow { function openExifDialog(data) { exifDialog.summaryData = data.summary exifDialog.fullData = data.full - exifDialog.open() + root.openDialogSafely(exifDialog) } function openColorInfoDialog(text) { colorInfoDialog.infoText = text - colorInfoDialog.open() + root.openDialogSafely(colorInfoDialog) } function setGridPrefetch(item, enabled) { @@ -187,6 +212,37 @@ ApplicationWindow { return "" } + // The EXIF brief shown in the status bar may end with a GPS-derived + // distance segment formatted as " m" (see metadata._format_distance_meters). + // These helpers let the status bar render the distance as its own label so a + // tooltip can explain what it means. + function exifBriefDistanceRegExp() { + return /^\d+ m$/ + } + + function exifBriefWithoutDistance(brief) { + var s = root.stringOrEmpty(brief) + if (s === "") return "" + var re = root.exifBriefDistanceRegExp() + var kept = [] + var parts = s.split(" | ") + for (var i = 0; i < parts.length; i++) { + if (!re.test(parts[i])) kept.push(parts[i]) + } + return kept.join(" | ") + } + + function exifBriefDistance(brief) { + var s = root.stringOrEmpty(brief) + if (s === "") return "" + var re = root.exifBriefDistanceRegExp() + var parts = s.split(" | ") + for (var i = 0; i < parts.length; i++) { + if (re.test(parts[i])) return parts[i] + } + return "" + } + function itemsWithStatus(items, status) { var source = root.toArray(items) var result = [] @@ -660,7 +716,7 @@ ApplicationWindow { text: "Settings..." hoverFillColor: root.menuHoverColor onClicked: { - settingsDialog.open() + root.openDialogSafely(settingsDialog) fileMenu.close() } defaultTextColor: root.currentTextColor @@ -792,8 +848,8 @@ ApplicationWindow { // Develop RAW (True Headroom) MenuActionItem { width: 220 - text: (root.uiStateRef && root.uiStateRef.hasWorkingTif) ? "Re-develop RAW" : "Develop RAW" - enabled: root.uiStateRef ? root.uiStateRef.hasRaw : false + text: (root.uiStateRef && root.uiStateRef.isRawDeveloping) ? "Developing RAW..." : ((root.uiStateRef && root.uiStateRef.hasWorkingTif) ? "Re-develop RAW" : "Develop RAW") + enabled: root.uiStateRef ? (root.uiStateRef.hasRaw && !root.uiStateRef.isRawDeveloping) : false hoverFillColor: root.menuHoverColor defaultTextColor: root.currentTextColor disabledTextColor: root.isDarkTheme ? "#666666" : "#999999" @@ -839,6 +895,18 @@ ApplicationWindow { actionsMenu.close() } } + MenuActionItem { + width: 220 + text: "Duplicate Image" + enabled: root.uiStateRef ? root.uiStateRef.imageCount > 0 : false + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + disabledTextColor: root.isDarkTheme ? "#666666" : "#999999" + onClicked: { + if (root.controllerRef) root.controllerRef.duplicate_current_image() + actionsMenu.close() + } + } MenuActionItem { width: 220 @@ -866,7 +934,7 @@ ApplicationWindow { text: "Show Stacks" hoverFillColor: root.menuHoverColor defaultTextColor: root.currentTextColor - onClicked: { showStacksDialog.open(); actionsMenu.close() } + onClicked: { root.openDialogSafely(showStacksDialog); actionsMenu.close() } } MenuActionItem { width: 220 @@ -880,7 +948,7 @@ ApplicationWindow { text: "Filter Images..." hoverFillColor: root.menuHoverColor defaultTextColor: root.currentTextColor - onClicked: { filterDialog.open(); actionsMenu.close() } + onClicked: { root.openDialogSafely(filterDialog); actionsMenu.close() } } // Separator before Sort options @@ -973,6 +1041,22 @@ ApplicationWindow { actionsMenu.close() } } + + MenuActionItem { + width: 220 + text: "Automatically add edited photos to batch" + showCheckbox: true + checkboxChecked: root.uiStateRef ? root.uiStateRef.autoAddEditedToBatch : true + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + onClicked: { + if (root.uiStateRef) { + root.uiStateRef.autoAddEditedToBatch = !root.uiStateRef.autoAddEditedToBatch + } + actionsMenu.close() + } + } + MenuActionItem { width: 220 text: "Jump to Last Uploaded" @@ -1094,6 +1178,19 @@ ApplicationWindow { actionsMenu.close() } } + MenuActionItem { + width: 180 + text: "By Date (reverse)" + hoverFillColor: root.menuHoverColor + selectedFillColor: root.menuSelectedColor + selected: root.uiStateRef && root.uiStateRef.sortMode === "date_reverse" + defaultTextColor: root.currentTextColor + onClicked: { + if (root.controllerRef) root.controllerRef.set_sort_mode("date_reverse") + sortSubMenu.close() + actionsMenu.close() + } + } } } @@ -1118,7 +1215,7 @@ ApplicationWindow { text: "Key Bindings" hoverFillColor: root.menuHoverColor defaultTextColor: root.currentTextColor - onClicked: { aboutDialog.open(); helpMenu.close() } + onClicked: { root.openDialogSafely(aboutDialog); helpMenu.close() } } } } @@ -1265,7 +1362,10 @@ ApplicationWindow { // Global Key for saving edited image (Ctrl+S) when editor is open if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { - if (root.uiStateRef.isEditorOpen) { + if (root.uiStateRef.isEditorOpen && root.uiStateRef.isCropping) { + root.uiStateRef.statusMessage = "Apply or cancel the crop before saving" + event.accepted = true + } else if (root.uiStateRef.isEditorOpen) { root.controllerRef.save_edited_image() event.accepted = true } @@ -1345,13 +1445,39 @@ ApplicationWindow { : "N/A" color: root.currentTextColor } + // EXIF brief: ISO, aperture, shutter speed, capture time. Label { + id: exifBriefLabel visible: root.uiStateRef && root.uiStateRef.imageCount > 0 - && root.stringOrEmpty(root.uiStateRef.exifBrief).length > 0 - text: root.uiStateRef ? root.stringOrEmpty(root.uiStateRef.exifBrief) : "" + && root.exifBriefWithoutDistance(root.uiStateRef.exifBrief).length > 0 + text: root.uiStateRef ? root.exifBriefWithoutDistance(root.uiStateRef.exifBrief) : "" color: root.currentTextColor } + // GPS distance from the previous image, in meters. Shown as its own + // label so a tooltip can explain it on hover. + Label { + id: exifDistanceLabel + property string distanceText: root.uiStateRef ? root.exifBriefDistance(root.uiStateRef.exifBrief) : "" + visible: root.uiStateRef + && root.uiStateRef.imageCount > 0 + && exifDistanceLabel.distanceText.length > 0 + text: exifBriefLabel.visible + ? ("| " + exifDistanceLabel.distanceText) + : exifDistanceLabel.distanceText + color: root.currentTextColor + + ToolTip.visible: exifDistanceMouse.containsMouse && exifDistanceLabel.visible + ToolTip.text: "Distance in meters between this image and the previous one (calculated from GPS EXIF data)" + ToolTip.delay: 500 + + MouseArea { + id: exifDistanceMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } Label { id: directoryPathLabel visible: root.uiStateRef && root.uiStateRef.currentDirectory !== "" @@ -1734,6 +1860,7 @@ ApplicationWindow { "Viewing:
" + "  Mouse Wheel: Zoom in/out
" + "  Left-click + Drag: Pan image
" + + "  Hold Space: Show original with current crop
" + "  Ctrl+0: Reset zoom and pan to fit window
" + "  Ctrl+1/2/3/4: Zoom to 100%/200%/300%/400%

" + "Stacking:
" + @@ -1768,9 +1895,15 @@ ApplicationWindow { "Image Editing:
" + "  E: Toggle Image Editor
" + "  Ctrl+S (in editor): Save current live edits
" + + "  Compact Editor (when focused):
" + + "    Left / Right: Previous / Next image
" + + "    Up / Down: Raise / lower the highlighted slider
" + + "    Click a slider label: Highlight it for Up/Down
" + + "    S: Save  E / Esc: Close  O: Crop
" + + "    B, F, D, I, etc. work as in the main view
" + "  A: Quick auto white balance (live)
" + - "  l: Quick auto levels (live)
" + - "  L: Quick auto white balance + auto levels (live)
" + + "  l: Quick auto levels + vibrance (live)
" + + "  L: Quick auto white balance + auto levels + vibrance (live)
" + "  -: Darken current auto-adjust highlights/whites (live)
" + "  _: Raise current auto-adjust whites (live)
" + "  +: Raise current auto-adjust shadows/blacks (live)
" + @@ -1893,12 +2026,12 @@ ApplicationWindow { } function show_jump_to_image_dialog() { - jumpToImageDialog.open() + root.openDialogSafely(jumpToImageDialog) } function show_delete_batch_dialog(count) { deleteBatchDialog.batchCount = count - deleteBatchDialog.open() + root.openDialogSafely(deleteBatchDialog) } ExifDialog { diff --git a/faststack/qml/MenuActionItem.qml b/faststack/qml/MenuActionItem.qml index c36488d..d021ad9 100644 --- a/faststack/qml/MenuActionItem.qml +++ b/faststack/qml/MenuActionItem.qml @@ -13,6 +13,11 @@ ItemDelegate { property bool useEnabledHover: true property real disabledTextOpacity: 0.6 property int textLeftPadding: 10 + // Optional leading checkbox glyph for toggle-style menu entries. Named to + // avoid AbstractButton.checked, whose setter force-enables checkable and + // lets clicks overwrite the caller's binding. + property bool showCheckbox: false + property bool checkboxChecked: false height: 36 hoverEnabled: true @@ -24,7 +29,9 @@ ItemDelegate { } contentItem: Text { - text: menuActionItem.text + text: (menuActionItem.showCheckbox + ? (menuActionItem.checkboxChecked ? "☑ " : "☐ ") + : "") + menuActionItem.text font.bold: menuActionItem.boldWhenSelected && menuActionItem.selected color: menuActionItem.enabled ? menuActionItem.defaultTextColor : menuActionItem.disabledTextColor opacity: menuActionItem.enabled ? 1.0 : menuActionItem.disabledTextOpacity diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml index 9f67ba5..eb753bb 100644 --- a/faststack/qml/SettingsDialog.qml +++ b/faststack/qml/SettingsDialog.qml @@ -26,6 +26,12 @@ Window { property double autoLevelClippingThreshold: 0.1 property double autoLevelStrength: 1.0 property bool autoLevelStrengthAuto: false + property bool autoVibranceEnabled: true + property bool autoLevelMidtone: true + property double autoLevelMidtoneTarget: 0.38 + property double autoLevelChannelBudget: 3.0 + property bool levelsSoftKnee: true + property bool exportDither: true property int prefetchRadius: 4 property int theme: 0 property string defaultDirectory: "" @@ -37,6 +43,7 @@ Window { property double awbStrength: 0.7 property int awbWarmBias: 6 property int awbTintBias: 0 + property double awbTintDamp: 0.6 property int awbLumaLowerBound: 30 property int awbLumaUpperBound: 220 @@ -57,6 +64,7 @@ Window { readonly property color controlBg: "#10ffffff" readonly property color controlBorder: "#30ffffff" readonly property color separatorColor: "#20ffffff" + readonly property string autoVibranceTooltip: "When enabled, FastStack may add a small vibrance boost to quick auto levels (l) and quick auto adjust (Shift+L)." Material.theme: Material.Dark Material.accent: accentColor @@ -137,10 +145,17 @@ Window { settingsDialog.autoLevelClippingThreshold = settingsDialog.uiStateRef.autoLevelClippingThreshold settingsDialog.autoLevelStrength = settingsDialog.uiStateRef.autoLevelStrength settingsDialog.autoLevelStrengthAuto = settingsDialog.uiStateRef.autoLevelStrengthAuto + settingsDialog.autoVibranceEnabled = settingsDialog.uiStateRef.autoVibranceEnabled + settingsDialog.autoLevelMidtone = settingsDialog.uiStateRef.autoLevelMidtone + settingsDialog.autoLevelMidtoneTarget = settingsDialog.uiStateRef.autoLevelMidtoneTarget + settingsDialog.autoLevelChannelBudget = settingsDialog.uiStateRef.autoLevelChannelBudget + settingsDialog.levelsSoftKnee = settingsDialog.uiStateRef.levelsSoftKnee + settingsDialog.exportDither = settingsDialog.uiStateRef.exportDither settingsDialog.awbMode = settingsDialog.uiStateRef.awbMode settingsDialog.awbStrength = settingsDialog.uiStateRef.awbStrength settingsDialog.awbWarmBias = settingsDialog.uiStateRef.awbWarmBias settingsDialog.awbTintBias = settingsDialog.uiStateRef.awbTintBias + settingsDialog.awbTintDamp = settingsDialog.uiStateRef.awbTintDamp settingsDialog.awbLumaLowerBound = settingsDialog.uiStateRef.awbLumaLowerBound settingsDialog.awbLumaUpperBound = settingsDialog.uiStateRef.awbLumaUpperBound settingsDialog.awbRgbLowerBound = settingsDialog.uiStateRef.awbRgbLowerBound @@ -189,11 +204,18 @@ Window { state.autoLevelClippingThreshold = settingsDialog.autoLevelClippingThreshold state.autoLevelStrength = settingsDialog.autoLevelStrength state.autoLevelStrengthAuto = settingsDialog.autoLevelStrengthAuto + state.autoVibranceEnabled = settingsDialog.autoVibranceEnabled + state.autoLevelMidtone = settingsDialog.autoLevelMidtone + state.autoLevelMidtoneTarget = settingsDialog.autoLevelMidtoneTarget + state.autoLevelChannelBudget = settingsDialog.autoLevelChannelBudget + state.levelsSoftKnee = settingsDialog.levelsSoftKnee + state.exportDither = settingsDialog.exportDither state.awbMode = settingsDialog.awbMode state.awbStrength = settingsDialog.awbStrength state.awbWarmBias = settingsDialog.awbWarmBias state.awbTintBias = settingsDialog.awbTintBias + state.awbTintDamp = settingsDialog.awbTintDamp state.awbLumaLowerBound = settingsDialog.awbLumaLowerBound state.awbLumaUpperBound = settingsDialog.awbLumaUpperBound @@ -851,6 +873,190 @@ Window { } } } + + Label { + text: "Auto Vibrance" + color: settingsDialog.textColor + } + CheckBox { + id: autoVibranceCheck + text: "Enabled" + checked: settingsDialog.autoVibranceEnabled + hoverEnabled: true + onCheckedChanged: settingsDialog.autoVibranceEnabled = checked + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: settingsDialog.autoVibranceTooltip + contentItem: Text { text: autoVibranceCheck.text; color: settingsDialog.textColor; leftPadding: autoVibranceCheck.indicator.width + autoVibranceCheck.spacing; verticalAlignment: Text.AlignVCenter } + indicator: Rectangle { + implicitWidth: 18; implicitHeight: 18 + x: autoVibranceCheck.leftPadding; y: parent.height / 2 - height / 2 + radius: 3 + border.color: settingsDialog.accentColor + color: autoVibranceCheck.checked ? settingsDialog.accentColor : "transparent" + Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: autoVibranceCheck.checked; font.bold: true } + } + } + + Label { + text: "Midtone Correction" + color: settingsDialog.textColor + + MouseArea { + id: midtoneHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: midtoneHover.containsMouse + ToolTip.text: "When enabled, auto-levels also nudges brightness so a full-range but underexposed (or overexposed) image is corrected toward the midtone target instead of being left untouched. Default: on" + } + CheckBox { + id: midtoneCheck + text: "Enabled" + checked: settingsDialog.autoLevelMidtone + onCheckedChanged: settingsDialog.autoLevelMidtone = checked + contentItem: Text { text: midtoneCheck.text; color: settingsDialog.textColor; leftPadding: midtoneCheck.indicator.width + midtoneCheck.spacing; verticalAlignment: Text.AlignVCenter } + indicator: Rectangle { + implicitWidth: 18; implicitHeight: 18 + x: midtoneCheck.leftPadding; y: parent.height / 2 - height / 2 + radius: 3 + border.color: settingsDialog.accentColor + color: midtoneCheck.checked ? settingsDialog.accentColor : "transparent" + Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: midtoneCheck.checked; font.bold: true } + } + } + + Label { + text: "Midtone Target" + color: settingsDialog.textColor + opacity: settingsDialog.autoLevelMidtone ? 1.0 : 0.5 + + MouseArea { + id: midtoneTargetHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: midtoneTargetHover.containsMouse + ToolTip.text: "Median brightness (0.2-0.6) that midtone correction aims for after the levels stretch. Images whose brightness is already within a dead band of this target are left alone; others are nudged only partway toward it, so well-exposed and intentionally dark/bright photos are preserved. Higher values produce brighter results. Default: 0.38" + } + Loader { + id: midtoneTargetLoader + sourceComponent: styledTextField + Layout.preferredWidth: 80 + opacity: settingsDialog.autoLevelMidtone ? 1.0 : 0.5 + enabled: settingsDialog.autoLevelMidtone + onLoaded: { + settingsDialog.setLoaderProperty(midtoneTargetLoader, "text", settingsDialog.autoLevelMidtoneTarget.toFixed(2)) + settingsDialog.connectLoaderSignal(midtoneTargetLoader, "editingFinished", function() { + var value = parseFloat(settingsDialog.loaderProperty(midtoneTargetLoader, "text", settingsDialog.autoLevelMidtoneTarget.toFixed(2))) + if (!isNaN(value) && value >= 0.2 && value <= 0.6) { + settingsDialog.autoLevelMidtoneTarget = value + } + settingsDialog.setLoaderProperty(midtoneTargetLoader, "text", settingsDialog.autoLevelMidtoneTarget.toFixed(2)) + }) + } + Binding { + target: midtoneTargetLoader.item + property: "text" + value: settingsDialog.autoLevelMidtoneTarget.toFixed(2) + when: midtoneTargetLoader.item && !settingsDialog.loaderProperty(midtoneTargetLoader, "activeFocus", false) + } + } + + Label { + text: "Channel Clip Budget" + color: settingsDialog.textColor + + MouseArea { + id: channelBudgetHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: channelBudgetHover.containsMouse + ToolTip.text: "How much each individual color channel may clip relative to the overall clip threshold (1-10x). 1.0 never clips any channel beyond the threshold (most conservative, a single saturated channel like a blue sky can block the stretch). Higher values allow a stronger stretch with mild per-channel clipping, softened by the highlight rolloff. Default: 3.0" + } + Loader { + id: channelBudgetLoader + sourceComponent: styledTextField + Layout.preferredWidth: 80 + onLoaded: { + settingsDialog.setLoaderProperty(channelBudgetLoader, "text", settingsDialog.autoLevelChannelBudget.toFixed(1)) + settingsDialog.connectLoaderSignal(channelBudgetLoader, "editingFinished", function() { + var value = parseFloat(settingsDialog.loaderProperty(channelBudgetLoader, "text", settingsDialog.autoLevelChannelBudget.toFixed(1))) + if (!isNaN(value) && value >= 1.0 && value <= 10.0) { + settingsDialog.autoLevelChannelBudget = value + } + settingsDialog.setLoaderProperty(channelBudgetLoader, "text", settingsDialog.autoLevelChannelBudget.toFixed(1)) + }) + } + Binding { + target: channelBudgetLoader.item + property: "text" + value: settingsDialog.autoLevelChannelBudget.toFixed(1) + when: channelBudgetLoader.item && !settingsDialog.loaderProperty(channelBudgetLoader, "activeFocus", false) + } + } + + Label { + text: "Highlight Soft Rolloff" + color: settingsDialog.textColor + + MouseArea { + id: softKneeHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: softKneeHover.containsMouse + ToolTip.text: "Compress stretched highlights and shadows smoothly instead of hard-clipping them. Preserves tonal separation and color in bright saturated areas (e.g. sunset clouds) when the blacks/whites sliders or auto-levels are active. Default: on" + } + CheckBox { + id: softKneeCheck + text: "Enabled" + checked: settingsDialog.levelsSoftKnee + onCheckedChanged: settingsDialog.levelsSoftKnee = checked + contentItem: Text { text: softKneeCheck.text; color: settingsDialog.textColor; leftPadding: softKneeCheck.indicator.width + softKneeCheck.spacing; verticalAlignment: Text.AlignVCenter } + indicator: Rectangle { + implicitWidth: 18; implicitHeight: 18 + x: softKneeCheck.leftPadding; y: parent.height / 2 - height / 2 + radius: 3 + border.color: settingsDialog.accentColor + color: softKneeCheck.checked ? settingsDialog.accentColor : "transparent" + Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: softKneeCheck.checked; font.bold: true } + } + } + + Label { + text: "Export Dithering" + color: settingsDialog.textColor + + MouseArea { + id: exportDitherHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: exportDitherHover.containsMouse + ToolTip.text: "Add imperceptible noise when saving JPEGs with a strong levels stretch. Prevents visible banding in skies and smooth gradients caused by amplifying the source's 8-bit steps. Default: on" + } + CheckBox { + id: exportDitherCheck + text: "Enabled" + checked: settingsDialog.exportDither + onCheckedChanged: settingsDialog.exportDither = checked + contentItem: Text { text: exportDitherCheck.text; color: settingsDialog.textColor; leftPadding: exportDitherCheck.indicator.width + exportDitherCheck.spacing; verticalAlignment: Text.AlignVCenter } + indicator: Rectangle { + implicitWidth: 18; implicitHeight: 18 + x: exportDitherCheck.leftPadding; y: parent.height / 2 - height / 2 + radius: 3 + border.color: settingsDialog.accentColor + color: exportDitherCheck.checked ? settingsDialog.accentColor : "transparent" + Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: exportDitherCheck.checked; font.bold: true } + } + } } Loader { sourceComponent: sectionSeparator } @@ -932,6 +1138,41 @@ Window { } } + // Tint Damping + Label { + text: "Tint Correction (" + Math.round(settingsDialog.loaderProperty(awbTintDampSlider, "value", 0) * 100) + "%)" + color: settingsDialog.textColor + + MouseArea { + id: awbTintDampHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: awbTintDampHover.containsMouse + ToolTip.text: "How much of the detected magenta/green (tint) cast to correct. Real light sources vary mostly along blue/yellow, so a large tint component is often subject color (foliage, flowers) rather than a cast. Lower values protect scenes without true neutrals; 100% applies the full tint correction. Default: 60%" + } + Loader { + id: awbTintDampSlider + sourceComponent: styledSlider + Layout.fillWidth: true + onLoaded: { + settingsDialog.setLoaderProperty(awbTintDampSlider, "from", 0.0) + settingsDialog.setLoaderProperty(awbTintDampSlider, "to", 1.0) + settingsDialog.setLoaderProperty(awbTintDampSlider, "stepSize", 0.05) + settingsDialog.setLoaderProperty(awbTintDampSlider, "value", settingsDialog.awbTintDamp) + settingsDialog.connectLoaderSignal(awbTintDampSlider, "valueChanged", function() { + settingsDialog.awbTintDamp = settingsDialog.loaderProperty(awbTintDampSlider, "value", settingsDialog.awbTintDamp) + }) + } + Binding { + target: awbTintDampSlider.item + property: "value" + value: settingsDialog.awbTintDamp + when: awbTintDampSlider.item && !settingsDialog.loaderProperty(awbTintDampSlider, "pressed", false) + } + } + // Warm Bias Label { text: "Warm Bias (Yel/Blu)" diff --git a/faststack/resources.py b/faststack/resources.py new file mode 100644 index 0000000..9ee06e7 --- /dev/null +++ b/faststack/resources.py @@ -0,0 +1,42 @@ +"""Runtime resource lookup helpers.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import Optional + +import PySide6 + +log = logging.getLogger(__name__) + + +def faststack_package_dir() -> Path: + """Return the package directory in source, installed, or frozen layouts.""" + if getattr(sys, "frozen", False): + bundle_root = Path(getattr(sys, "_MEIPASS", "")) + bundled_package = bundle_root / "faststack" + if bundled_package.is_dir(): + return bundled_package + + return Path(__file__).resolve().parent + + +def faststack_qml_dir() -> Path: + """Return the directory containing FastStack QML files.""" + qml_dir = faststack_package_dir() / "qml" + if not qml_dir.is_dir(): + raise FileNotFoundError(f"FastStack QML directory not found: {qml_dir}") + return qml_dir + + +def pyside_qml_dir() -> Optional[Path]: + """Return the PySide6 Qt QML import directory when it is available.""" + pyside_dir = Path(PySide6.__file__).resolve().parent + for candidate in (pyside_dir / "Qt" / "qml", pyside_dir / "qml"): + if candidate.is_dir(): + return candidate + + log.warning("PySide6 QML import directory was not found under %s", pyside_dir) + return None diff --git a/faststack/tests/dummy_images/faststack.json b/faststack/tests/dummy_images/faststack.json index 63a91b1..dabe593 100644 --- a/faststack/tests/dummy_images/faststack.json +++ b/faststack/tests/dummy_images/faststack.json @@ -2,7 +2,7 @@ "version": 2, "last_index": 0, "entries": { - "test": { + "faststack/tests/dummy_images/test": { "stack_id": null, "stacked": false, "stacked_date": null, @@ -11,7 +11,10 @@ "edited": false, "edited_date": null, "restacked": false, - "restacked_date": null + "restacked_date": null, + "favorite": false, + "todo": true, + "todo_date": "2026-06-09" } }, "stacks": [] diff --git a/faststack/tests/test_prefetch_concurrency.py b/faststack/tests/test_prefetch_concurrency.py index 59f8360..3fa8344 100644 --- a/faststack/tests/test_prefetch_concurrency.py +++ b/faststack/tests/test_prefetch_concurrency.py @@ -17,7 +17,7 @@ def mock_get_display_info(): return 1920, 1080, 1 -def mock_cache_put(key, value): +def mock_cache_put(key, value, path=None, decode_started=None): pass diff --git a/faststack/thumbnail_view/model.py b/faststack/thumbnail_view/model.py index 787619b..6cee2b2 100644 --- a/faststack/thumbnail_view/model.py +++ b/faststack/thumbnail_view/model.py @@ -461,6 +461,23 @@ def refresh(self): len(images), ) + def notify_batch_state_changed(self) -> None: + """Refresh batch badges without resetting the model. + + IsInBatchRole is computed live in data() via the controller callback, + so a dataChanged ping is all QML needs. A full refresh() resets the + model, destroys every delegate, re-reads sidecar metadata for all + entries, and re-requests visible thumbnails — ~1s of UI-thread work + for a state change that isn't even stored in the entries. + """ + if not self._entries: + return + self.dataChanged.emit( + self.index(0, 0), + self.index(len(self._entries) - 1, 0), + [self.IsInBatchRole], + ) + def remove_rows_by_path(self, paths: List[Path]) -> None: """Targeted removal of rows by path without full model reset.""" if not paths or not self._entries: diff --git a/faststack/ui/keystrokes.py b/faststack/ui/keystrokes.py index 8e6c499..fbf7818 100644 --- a/faststack/ui/keystrokes.py +++ b/faststack/ui/keystrokes.py @@ -31,7 +31,6 @@ def __init__(self, controller): # Batching Qt.Key_BraceLeft: "begin_new_batch", Qt.Key_BraceRight: "end_current_batch", - Qt.Key_Backslash: "clear_all_batches", Qt.Key_B: "toggle_batch_membership", # Remove from batch/stack Qt.Key_X: "remove_from_batch_or_stack", @@ -92,10 +91,14 @@ def handle_key_press(self, event): modifiers = event.modifiers() log.debug(f"Key pressed: {key} ({text!r}) with modifiers {modifiers}") + if text == "|" and modifiers == Qt.ShiftModifier: + self._call("clear_all_batches") + return True + # Check for modifier + key combinations for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): # Check if required modifier is present in event modifiers - if key == mapped_key and (modifiers & mapped_modifier): + if key == mapped_key and (modifiers & mapped_modifier) == mapped_modifier: log.debug( f"Matched modifier key: {key} + {mapped_modifier} -> {method_name}" ) @@ -161,8 +164,5 @@ def handle_key_press(self, event): if text == "}": self._call("end_current_batch") return True - if text == "\\": - self._call("clear_all_batches") - return True return False diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index b6de78d..e33055d 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -42,6 +42,29 @@ def __init__(self, app_controller): # Lock to protect keepalive deque from concurrent access by QML rendering threads self._keepalive_lock = threading.Lock() + def _fallback_image(self) -> QImage: + return self.placeholder.copy() + + def _log_provider_fallback( + self, + request_id: str, + reason: str, + *, + stale: bool, + exc_info: bool = False, + ) -> None: + if stale: + log.debug( + "Ignoring stale image provider request %s: %s", request_id, reason + ) + return + log.warning( + "Image provider could not satisfy current request %s: %s", + request_id, + reason, + exc_info=exc_info, + ) + def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: """Handles image requests from QML.""" import time @@ -52,8 +75,9 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: print(f"[DBGCACHE] {_t_start*1000:.3f} requestImage: START id={id}") if not id: - return self.placeholder + return self._fallback_image() + request_is_stale = False try: # Handle mask overlay requests if id.startswith("mask_overlay/"): @@ -69,6 +93,56 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: index = int(parts[0]) gen = int(parts[1]) if len(parts) > 1 else None + current_index = getattr(self.app_controller, "current_index", None) + current_generation = getattr( + self.app_controller, + "ui_refresh_generation", + None, + ) + ui_state = getattr(self.app_controller, "ui_state", None) + grid_active_value = getattr(ui_state, "isGridViewActive", False) + grid_active = ( + grid_active_value if isinstance(grid_active_value, bool) else False + ) + index_is_current = not isinstance(current_index, int) or ( + index == current_index + ) + generation_is_current = ( + gen is None + or not isinstance( + current_generation, + int, + ) + or (gen == current_generation) + ) + request_is_stale = ( + grid_active or not index_is_current or not generation_is_current + ) + + image_files = getattr(self.app_controller, "image_files", None) + image_count = len(image_files) if isinstance(image_files, list) else None + if image_count is not None and ( + index < 0 or index >= image_count or image_count == 0 + ): + stale_bounds_request = request_is_stale or image_count == 0 + self._log_provider_fallback( + id, + f"index {index} outside image list of {image_count}", + stale=stale_bounds_request, + ) + return self._fallback_image() + + if request_is_stale: + self._log_provider_fallback( + id, + ( + "request no longer matches current image source " + f"(current index={current_index}, generation={current_generation})" + ), + stale=True, + ) + return self._fallback_image() + # If editor is open, use the background-rendered preview buffer # BUT only if the requested index matches the currently edited index! # AND the generation matches (to avoid stale frames during rotation/param changes) @@ -111,6 +185,18 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: except Exception: current_preview_session_key = None + current_compare_session_key = None + get_compare_key = getattr( + self.app_controller, + "_get_current_original_compare_session_key", + None, + ) + if callable(get_compare_key): + try: + current_compare_session_key = get_compare_key() + except Exception: + current_compare_session_key = None + has_valid_preview_buffer = ( current_preview_session_key is not None and self.app_controller._last_rendered_preview is not None @@ -141,14 +227,34 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: and has_valid_preview_buffer ) + use_original_compare_preview = ( + getattr(self.app_controller, "_original_compare_active", False) + and index == self.app_controller.current_index + and current_compare_session_key is not None + and self.app_controller._original_compare_preview is not None + and self.app_controller._original_compare_index == index + and getattr( + self.app_controller, + "_original_compare_session_key", + None, + ) + == current_compare_session_key + and ( + gen is None + or getattr(self.app_controller, "_original_compare_gen", None) + == gen + ) + ) + if _debug: _t_get = time.perf_counter() - image_data = ( - self.app_controller._last_rendered_preview - if use_editor_preview - else self.app_controller.get_decoded_image(index) - ) + if use_original_compare_preview: + image_data = self.app_controller._original_compare_preview + elif use_editor_preview: + image_data = self.app_controller._last_rendered_preview + else: + image_data = self.app_controller.get_decoded_image(index) if _debug: _t_got = time.perf_counter() @@ -169,6 +275,13 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: image_data.bytes_per_line, fmt, ) + if qimg.isNull(): + self._log_provider_fallback( + id, + "decoded buffer produced a null QImage", + stale=request_is_stale, + ) + return self._fallback_image() # Detach from Python buffer to prevent ownership issues and force proper texture upload # OPTIMIZATION: Only do this expensive copy when serving the live editor preview, @@ -178,6 +291,7 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: self.app_controller.ui_state.isEditorOpen or has_active_auto_adjust or has_current_live_preview + or use_original_compare_preview ) and index == self.app_controller.current_index: qimg = qimg.copy() else: @@ -214,9 +328,23 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: return qimg except (ValueError, IndexError) as e: - log.error(f"Invalid image ID requested from QML: {id}. Error: {e}") + log.warning("Invalid image ID requested from QML: %s. Error: %s", id, e) + return self._fallback_image() + except Exception: + self._log_provider_fallback( + id, + "unexpected provider error", + stale=request_is_stale, + exc_info=True, + ) + return self._fallback_image() - return self.placeholder + self._log_provider_fallback( + id, + "decode returned no image data", + stale=request_is_stale, + ) + return self._fallback_image() class UIState(QObject): @@ -261,12 +389,20 @@ class UIState(QObject): autoLevelClippingThresholdChanged = Signal(float) autoLevelStrengthChanged = Signal(float) autoLevelStrengthAutoChanged = Signal(bool) + autoVibranceEnabledChanged = Signal(bool) + autoLevelMidtoneChanged = Signal(bool) + autoLevelMidtoneTargetChanged = Signal(float) + autoLevelChannelBudgetChanged = Signal(float) + levelsSoftKneeChanged = Signal(bool) + exportDitherChanged = Signal(bool) + awbTintDampChanged = Signal(float) # Image Editor Signals is_editor_open_changed = Signal(bool) is_editor_expanded_changed = Signal(bool) editorImageChanged = ( Signal() ) # New signal for when the image loaded in editor changes + originalCompareActiveChanged = Signal(bool) is_cropping_changed = Signal(bool) is_crop_rotating_changed = Signal(bool) @@ -320,10 +456,12 @@ class UIState(QObject): debugThumbTimingChanged = Signal(bool) # Thumbnail pipeline timing isDialogOpenChanged = Signal(bool) # New signal for dialog state editSourceModeChanged = Signal(str) # Notify when JPEG/RAW mode changes + rawDevelopmentStateChanged = Signal() saveBehaviorMessageChanged = Signal() # Signal for save behavior message updates isSavingChanged = Signal(bool) # Signal for save operation in progress batchAutoLevelsProgressChanged = Signal() batchAutoLevelsActiveChanged = Signal() + autoAddEditedToBatchChanged = Signal() # Variant badges variantBadgesChanged = Signal() @@ -346,6 +484,7 @@ def __init__(self, app_controller, clock_func=None): # Image Editor State self._is_editor_open = False self._is_editor_expanded = False + self._original_compare_active = False self._is_cropping = False self._is_crop_rotating = False self._is_histogram_visible = False @@ -406,6 +545,7 @@ def __init__(self, app_controller, clock_func=None): self._batch_al_current = 0 self._batch_al_total = 0 self._batch_al_active = False + self._auto_add_edited_to_batch = True # Load from config in app_controller # Connect to controller's dialog state signal self.app_controller.dialogStateChanged.connect(self._on_dialog_state_changed) @@ -422,6 +562,16 @@ def __init__(self, app_controller, clock_func=None): self.app_controller.editSourceModeChanged.connect( lambda _: self.metadataChanged.emit() ) # Also update metadata binding if needed + if hasattr(self.app_controller, "rawDevelopmentStateChanged"): + self.app_controller.rawDevelopmentStateChanged.connect( + self.rawDevelopmentStateChanged + ) + self.app_controller.rawDevelopmentStateChanged.connect( + self.saveBehaviorMessageChanged.emit + ) + self.app_controller.rawDevelopmentStateChanged.connect( + self.metadataChanged.emit + ) # Connect batch auto levels progress signals if hasattr(self.app_controller, "batchAutoLevelsProgress"): @@ -655,6 +805,12 @@ def isRawActive(self): return self.app_controller.current_edit_source_mode == "raw" return False + @Property(bool, notify=rawDevelopmentStateChanged) + def isRawDeveloping(self): + if hasattr(self.app_controller, "is_raw_developing_current"): + return self.app_controller.is_raw_developing_current() + return False + @Slot(result=bool) def load_image_for_editing(self): """Loads the currently viewed image into the editor.""" @@ -681,10 +837,16 @@ def saveBehaviorMessage(self): if not hasattr(self.app_controller, "current_edit_source_mode"): return "" + if getattr(self.app_controller, "view_override_kind", None) == "developed": + return "Editing: developed JPG (saves in-place to the developed file)" + if self.app_controller.current_edit_source_mode == "raw": - return "Editing: RAW (writes working .tif + creates -developed.jpg; original JPG untouched)" - else: - return "Editing: JPEG (will overwrite JPG)" + if self.isRawDeveloping: + return "Editing: RAW (developing working .tif...)" + if self.hasWorkingTif: + return "Editing: RAW (writes working .tif + creates -developed.jpg; original JPG untouched)" + return "Editing: RAW selected (develop RAW before saving)" + return "Editing: JPEG (will overwrite JPG)" @Property(str, notify=statusMessageChanged) def statusMessage(self): @@ -956,6 +1118,69 @@ def autoLevelStrengthAuto(self, value): self.app_controller.set_auto_level_strength_auto(value) self.autoLevelStrengthAutoChanged.emit(value) + @Property(bool, notify=autoVibranceEnabledChanged) + def autoVibranceEnabled(self): + return self.app_controller.get_auto_vibrance_enabled() + + @autoVibranceEnabled.setter + def autoVibranceEnabled(self, value): + self.app_controller.set_auto_vibrance_enabled(value) + self.autoVibranceEnabledChanged.emit(value) + + @Property(bool, notify=autoLevelMidtoneChanged) + def autoLevelMidtone(self): + return self.app_controller.get_auto_level_midtone() + + @autoLevelMidtone.setter + def autoLevelMidtone(self, value): + self.app_controller.set_auto_level_midtone(value) + self.autoLevelMidtoneChanged.emit(value) + + @Property(float, notify=autoLevelMidtoneTargetChanged) + def autoLevelMidtoneTarget(self): + return self.app_controller.get_auto_level_midtone_target() + + @autoLevelMidtoneTarget.setter + def autoLevelMidtoneTarget(self, value): + self.app_controller.set_auto_level_midtone_target(value) + self.autoLevelMidtoneTargetChanged.emit(value) + + @Property(float, notify=autoLevelChannelBudgetChanged) + def autoLevelChannelBudget(self): + return self.app_controller.get_auto_level_channel_budget() + + @autoLevelChannelBudget.setter + def autoLevelChannelBudget(self, value): + self.app_controller.set_auto_level_channel_budget(value) + self.autoLevelChannelBudgetChanged.emit(value) + + @Property(bool, notify=levelsSoftKneeChanged) + def levelsSoftKnee(self): + return self.app_controller.get_levels_soft_knee() + + @levelsSoftKnee.setter + def levelsSoftKnee(self, value): + self.app_controller.set_levels_soft_knee(value) + self.levelsSoftKneeChanged.emit(value) + + @Property(bool, notify=exportDitherChanged) + def exportDither(self): + return self.app_controller.get_export_dither() + + @exportDither.setter + def exportDither(self, value): + self.app_controller.set_export_dither(value) + self.exportDitherChanged.emit(value) + + @Property(float, notify=awbTintDampChanged) + def awbTintDamp(self): + return self.app_controller.get_awb_tint_damp() + + @awbTintDamp.setter + def awbTintDamp(self, value): + self.app_controller.set_awb_tint_damp(value) + self.awbTintDampChanged.emit(value) + @Slot() def open_folder(self): self.app_controller.open_folder() @@ -1005,6 +1230,25 @@ def isEditorExpanded(self, new_value: bool): self._is_editor_expanded = new_value self.is_editor_expanded_changed.emit(new_value) + @Property(bool, notify=originalCompareActiveChanged) + def originalCompareActive(self) -> bool: + return bool( + getattr( + self.app_controller, + "_original_compare_active", + self._original_compare_active, + ) + ) + + @originalCompareActive.setter + def originalCompareActive(self, new_value: bool): + active = bool( + getattr(self.app_controller, "_original_compare_active", new_value) + ) + if self._original_compare_active != active: + self._original_compare_active = active + self.originalCompareActiveChanged.emit(active) + @Property(str, notify=editorImageChanged) def editorFilename(self) -> str: """Returns the filename of the image currently being edited (may be .tif for developed RAW).""" @@ -1393,7 +1637,22 @@ def currentCropBox(self, new_value): if new_value is None: return if self._set_current_crop_box_value(new_value): - # Sync with ImageEditor + # During crop mode this is draft overlay state only; the committed + # crop box is applied explicitly on Enter. + if getattr(self.app_controller.ui_state, "isCropping", False): + try: + left, top, right, bottom = new_value + if (right - left) < 20 or (bottom - top) < 20: + return + except (TypeError, ValueError): + return + kick_preview = getattr( + self.app_controller, "_kick_preview_worker", None + ) + if callable(kick_preview): + kick_preview() + return + # Sync with ImageEditor outside crop mode. if ( hasattr(self.app_controller, "image_editor") and self.app_controller.image_editor @@ -1699,6 +1958,18 @@ def debugThumbTiming(self, value: bool): self._debug_thumb_timing = value self.debugThumbTimingChanged.emit(value) + @Property(bool, notify=autoAddEditedToBatchChanged) + def autoAddEditedToBatch(self) -> bool: + return self._auto_add_edited_to_batch + + @autoAddEditedToBatch.setter + def autoAddEditedToBatch(self, value: bool): + if self._auto_add_edited_to_batch != value: + self._auto_add_edited_to_batch = value + self.autoAddEditedToBatchChanged.emit() + if hasattr(self.app_controller, "save_config"): + self.app_controller.save_config() + # --- RAW / Editor Source Logic --- # --- Variant Badge Properties --- diff --git a/packaging/faststack.spec b/packaging/faststack.spec new file mode 100644 index 0000000..88ac3f5 --- /dev/null +++ b/packaging/faststack.spec @@ -0,0 +1,128 @@ +# -*- mode: python ; coding: utf-8 -*- + +from __future__ import annotations + +import os +import sys +import tomllib +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files + + +ROOT = Path(SPECPATH).parent.parent +APP_NAME = "FastStack" +BUNDLE_ID = "dev.faststack.FastStack" + + +def project_version() -> str: + with (ROOT / "pyproject.toml").open("rb") as f: + return tomllib.load(f)["project"]["version"] + + +def existing_binary( + path: str | os.PathLike[str], + dest: str = ".", +) -> tuple[str, str] | None: + candidate = Path(path) + if candidate.is_file(): + return str(candidate), dest + return None + + +datas = [ + (str(ROOT / "faststack" / "qml"), "faststack/qml"), +] +datas += collect_data_files( + "PySide6", + includes=[ + "Qt/qml/Qt/**", + "Qt/qml/QtCore/**", + "Qt/qml/QtQml/**", + "Qt/qml/QtQuick/**", + "Qt/qml/Qt5Compat/**", + ], +) + +binaries = [] +for candidate in ( + os.environ.get("FASTSTACK_TURBOJPEG_LIB"), + os.environ.get("TURBOJPEG_LIB"), + "C:/libjpeg-turbo64/bin/turbojpeg.dll", + "C:/Program Files/libjpeg-turbo/bin/turbojpeg.dll", + "/opt/homebrew/opt/jpeg-turbo/lib/libturbojpeg.dylib", + "/usr/local/opt/jpeg-turbo/lib/libturbojpeg.dylib", +): + if candidate: + binary = existing_binary(candidate) + if binary is not None: + binaries.append(binary) + break + +hiddenimports = [ + "PIL.ImageCms", + "PySide6.QtCore", + "PySide6.QtGui", + "PySide6.QtQml", + "PySide6.QtQuick", + "PySide6.QtQuickControls2", + "PySide6.QtWidgets", + "cv2", + "turbojpeg", +] + +a = Analysis( + [str(ROOT / "faststack" / "__main__.py")], + pathex=[str(ROOT)], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=APP_NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=APP_NAME, +) + +if sys.platform == "darwin": + app = BUNDLE( + coll, + name=f"{APP_NAME}.app", + icon=None, + bundle_identifier=BUNDLE_ID, + info_plist={ + "CFBundleDisplayName": APP_NAME, + "CFBundleShortVersionString": project_version(), + "CFBundleVersion": project_version(), + "NSHighResolutionCapable": True, + }, + ) diff --git a/pyproject.toml b/pyproject.toml index f156b2c..7900c4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.6.3" +version = "1.6.4" authors = [ { name = "Alan Rockefeller" }, ] @@ -37,6 +37,9 @@ dev = [ "build", "twine", ] +bundle = [ + "pyinstaller>=6.15,<7.0", +] [project.scripts] faststack = "faststack.app:cli"