From fdb62978c54c1fdac95b1065919acf80a637982c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 10:50:06 -0400 Subject: [PATCH] Finishing filters: Vignette, Bloom / Glow, Tonal Contrast (12.9a) --- AGENTS.md | 1 + CMakeLists.txt | 1 + TASKS.md | 7 +- kernels/AdjustPixels.c | 80 ++++- kernels/AdjustPixels.h | 10 + src/Document/ColorPalette+Session.h | 4 + src/Document/ColorPalette.cpp | 30 ++ src/Document/ColorPalette.h | 4 +- src/Document/EditorSession+Jobs.h | 2 + src/Document/Filters+Session.cpp | 40 ++- src/Document/Filters.cpp | 47 +++ src/Document/Filters.h | 23 +- src/UI/FilterSheet+Finishing.cpp | 50 +++ src/UI/FilterSheet.cpp | 6 + src/UI/FilterSheet.h | 3 + tests/CompositorMenusTests.cpp | 2 +- tests/FilterModelTests.cpp | 3 +- tests/FinishingFilterTests.cpp | 460 ++++++++++++++++++++++++++++ tests/LayerEffectsTests.cpp | 15 +- tests/Tests.cmake | 3 + 20 files changed, 756 insertions(+), 35 deletions(-) create mode 100644 src/UI/FilterSheet+Finishing.cpp create mode 100644 tests/FinishingFilterTests.cpp diff --git a/AGENTS.md b/AGENTS.md index 45fe8f1..6d82d2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,7 @@ Platform adaptations, each forced by a missing Qt analog: - The Camera Raw panel's other groups (12.8b2, Swift 1.2.3): `UI/CameraRawColorControls.h/.cpp` (Curve), `+Mixer.cpp` and `+Grading.cpp`, `UI/CameraRawDetailOpticsControls` and `UI/CameraRawGeometryCalibrationControls` twin Swift's three files, each view a `QWidget` over the session, synchronized on `changed()`. `UI/CameraRawRow` is Linux-only: the row each Swift view builds for itself (`sharpenSlider`, `opticsSlider`, `geometrySlider`, `calibrationSlider`, the curve's and mixer's sliders, 12.8b1's `slider`): a title of at least or exactly a width, `CameraRawSlider`, a field or none, a double click on the title resetting where Swift's main groups do; each view rounds the slider's value as its Swift twin does (Detail to whole numbers, Optics when its range starts at zero, Geometry and Calibration always, the rest not). Swift's segmented pickers are `cameraRawSegments`, touching checkable buttons in an exclusive `QButtonGroup`. The curve graph: a still press and release adds a point, a drag past two points moves the nearest divider or inner point, a double click removes the nearest inner point within 0.04 and its trailing release adds nothing, so the net is Swift's: SwiftUI adds on each click, `repair` drops the second as a duplicate and the double tap removes one. The graph is `CameraRawCurveGraph`: a class defined in a `.cpp` outside an anonymous namespace is global, and the Curves sheet's `CurveGraph` would share its vtable across the link. The mixer's rows whose help or track follow its tab, swatch or chosen point are made anew when those change, as SwiftUI renders them anew; a picked colour's swatch is shown at once, since a layout shows late children only later. The grading wheel fills a `QConicalGradient`, which runs counterclockwise from the right as the hue does (1.2.9's `96edd5f`), takes presses and double clicks inside its circle alone (Swift's `contentShape`), and Swift's `theDockedPanelFitsTheGradingWheels` holds; its stops are 96edd5f's own, pure HSV hues every 30 degrees (v1.2.3's six SwiftUI system colours predate that fix). Detail's idle noise rows are disabled under a 45% `QGraphicsOpacityEffect`, Swift's `.opacity(0.45).disabled`. Targeted Adjustment and Draw Guides are checkable push buttons, checked while armed, where Swift tints them (known difference); their SF Symbols, `scope` and `line.diagonal`, are `LayerIcons` paths. The Filter menu lists Camera Raw Filter… after Lens Correction, as Swift's. Known differences: a mixer family's field still being typed in loses its typing when the tab changes (the row is made anew), where SwiftUI's field would commit into the new tab; Equivalent mutants: the wheel's saturation cap (`normalized` clamps it), the picked swatches made anew only when their count changes (it spares rebuilds; Qt guards a swatch deleted in its own click) and the page menu's size policy (layout). The point-colour eyedropper is flat like the other two, where Swift leaves it bordered (cosmetic). Cosmetic, Swift's values and unpinned: the swatches' 18 points, Detail's idle 45%, the hue ranges' inset (title width and 10), the rows' spacing of 10 and the 11-pixel subheadlines. Tests: `CameraRawRowTable.h` walked by `CameraRawGroupRowsTests` (every row against Swift's values), `CameraRawGroupDrawTests` (drawing, pages, gestures, the panel through `FilterSheet`), `CameraRawGroupWordsTests` (every text, captions, buddies, the pickers' states, the mixer's pages, the wheel's lower half and rim, the curve's line, repaint and selected point), `CameraRawCurveMixerTests`, `CameraRawGradingLensTests`, the menu's entries in `CompositorMenusTests`. - Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held. - Blur and noise adjustment layers, Image Trim and Inner Glow (12.8c, Swift 1.2.3: `98ad817`, `24052b5`, `feb39f3`, `cd0ca3c`). `AdjustmentKind` gains Add Noise, Gaussian Blur and Motion Blur after Grain, v1.2.3's order (it moved Add Noise ahead of the blurs), with `circle.dotted`, `drop.fill` and `wind` as `LayerIcons` paths. `LayerAdjustment` keeps Swift's seven optionals (`blurRadius`, `motionAngle`, `motionDistance`, `noiseAmount`, `noiseGaussian`, `noiseMonochromatic`, `noiseSeed`), their resolved getters and setters with Swift's defaults (10, 0, 10, 10, off, off, 0), `isValid`'s bounds on every kind, as Swift's, and `samplingMargin`; the manifest writes each only when set, the seed a `UInt32` read as Grain's; the store takes the three kinds from version 9, which it now writes (versions 1–9). `apply` runs the three through `PixelFilter::run` with the layer's own seed; `FilterJob::noiseOrigin` sits last, so the positional initialisers keep their order, and `kernels/NoisePixels.c` is v1.2.3's (`noise_add_at`, whose position hash reshuffles the Add Noise filter's grain too). A new Add Noise layer rolls its seed, as Grain does; the editor opens the kind's filter panel on the layer's settings and writes back that kind's alone. Adaptation: Swift's canvas surface holds a pixel a point, so its region's origin and `scale` are already the surface's pixels; the twin's surfaces hold device pixels, so `LiveMaskRenderer::adjust` passes `adjustmentScale` (the canvas's zoom, Swift's, 1 elsewhere) times the device's pixels a region unit, and `apply` counts the noise origin in the image's pixels, so a HiDPI canvas blurs at its own density and the noise stays one field across dirty rects. `AdjustmentSurface::draw` takes Swift's `padding` in the painter's units: the halo is allocated past the shown area, not cut to the device (the canvas's outer surface is dirty-sized), and drawn back under the painter's clip. The canvas pads by the widest visible `samplingMargin` times the zoom, and its crisp path (from 200%) now draws the layers through a padded surface as well, as Swift's `drawLayers` wraps its own there. Known difference, Swift's too: on the smooth path the canvas's noise follows the view, as Grain's does; on the crisp path the surface's region starts at the repaint region, so each partial repaint there draws a noise field of its own. The crisp path wraps its surface even without an adjustment, where Swift's `drawLayers` wraps only for one: pixel-identical (source-over onto a cleared raster), one allocation a repaint region, as the smooth path already does. The HiDPI product is pinned by its two factors (the zoom on the canvas, the device ratio on a scaled composite), not at a device ratio of two itself. Image Trim: `Document/ImageTrim` twins Swift's file: `TrimBasedOn` by raw value, `TrimOptions` (a tolerance per premultiplied channel, alpha included, which the sheet leaves at 0), `calculateTrimRect` (the alpha bounds, or each row scanned in from both ends against the corner's pixel) and `trim` (the export's render, then `CanvasResizer` by the content offset, or the snapshot itself when nothing is cut). `UI/TrimSheet` is Swift's sheet: 320 wide, the title, Based On as radio buttons, Trim Away's four checkboxes, OK resting while none is checked, Return and Escape through `NativeShortcut`. `ProjectController::trim` runs as Canvas Size does, through `resizeProject`, whose resize may now return nothing: Swift's trim with nothing left returns with no alert and no step. The Image menu's Trim… follows Image Size…, with no shortcut. Left out as dead: `TrimError` (never thrown), `trimImage` and the session's `trim` (called by Swift's tests alone; the twin tests the rect and the controller's two steps), the kept rect's `maxX > minX` guard (the early returns leave content, so left < right and top < bottom) and the colour scan's `last > first` (the row's first unmatched pixel stops it). A fix beyond Swift: a raster that cannot be converted is `ExportError(render)`, explained, where Swift's `try?` reports nothing left; that failure path is untested, since only a failed allocation reaches it. Inner Glow joins as Outer Glow did (12.5b): `InnerGlowEffect` (10 pixels, white, 75%; 0–500, 0–1), `LayerEffectKind::innerGlow` last; `LayerEffectsKernel` blurs the shape at sigma size ÷ 2 and keeps shape × (1 − blur), composed over the colour overlay and under the inner shadow, Metal's order; no margin (Swift's `innerGlowRendersInsideSourceWithoutBoundsExpansion`), the stroke surface's reach three sizes and two. Swift's CPU `innerGlowCoverage` is dead here, as Outer Glow's is, so its parity test has no twin. A fix beyond Swift: a reduced preview scales the glow's size. The sheet shows its swatch, Size (0–100, 500 typed, px) and Opacity. Equivalent mutants: the canvas's margin counting hidden layers (it only widens the halo), the glow's `opacity > 0` (a clear glow composes nothing; the term spares the blur) and Trim's return of an uncut snapshot (the canvas resizer returns one unchanged at the same size and place). The padding branch keeps an unpadded surface to the shown pixels: mapped there and back, a rect rounds a pixel wider. Tests: `BlurNoiseAdjustmentTests` (the model, manifest, store, seeds, the editor, the filters at a scale, the noise field, a scaled target, the canvas's zoom and a partial repaint's halo at 100%, 150% and 400%), `ImageTrimTests` (Swift's six, tolerance, the sheet, the controller through its sheet, a failure's alert), `InnerGlowTests` (Swift's, but the parity test, and the order), the new kinds in `LayerAdjustmentTests`, `AdjustmentEditorTests`, `AdjustmentLayerTests` and `AdjustmentPanelTests`, the glow in `LayerEffectsKernelTests`' replay, `LayerEffectsSurfaceTests`, `EffectsPreviewCacheTests`, `LayerEffectsTests` and `EffectsPanelTests`, version 9 across the store's tests, Trim in `CompositorMenusTests`. +- The finishing filters (12.9a, Swift 1.2.4, `e08dd8c`): `kernels/AdjustPixels.c/.h` are v1.2.4's, with `adjust_colored_vignette` (Camera Raw's falloff pulled out as `vignette_mask`, Camera Raw's own vignette unchanged) and `adjust_tonal_contrast`. `FilterKind` gains Vignette, Bloom / Glow and Tonal Contrast after Add Noise, Swift's order, so the Filter menu lists them; `FilterSettings` keeps Swift's thirteen fields, defaults and clamps (the colour through `AdjustmentColor::clamped`). Vignette and Tonal Contrast hand the kernels every setting; Tonal Contrast's base is `PixelAdjust::gaussianBlur` at radius × scale, clear past the edge, Swift's unclamped `applyingGaussianBlur`. Known difference: Core Image's `CIBloom` is private, so the twin's Bloom / Glow is the pixels plus their Gaussian glow (radius × scale, clear past the edge) at amount ÷ 50, Swift's intensity, added byte by byte on the premultiplied values and rounded once, alpha included, so the glow shows past a clear layer's light as Swift's test asks; Core Image's own curve may differ. Bloom grows the grid by three radii and two and is trimmed on commit, as the blurs are. A commit with nothing to change closes as Cancel does: Vignette or Bloom at amount 0, Tonal Contrast at amount 0 or with all three tones at 0. Every filter's commit now keeps the layer's effects (Swift's rebuild gained `effects: current.effects`); shape and text still drop. `FilterEdit::previewSourceVersion` is Swift's: `prepare` moves it with each new grid, the worker hands back the version it rendered from, and a result for an older grid lands nothing, its waiting job rendering next (a render begun before `growForBlur` would otherwise show the old grid's pixels in the new placement). The twin's one watcher can hold a job waiting behind another edit's worker, which Swift's per-edit task never does, and Preview off keeps that job: a growth therefore rebuilds a waiting job on the new grid, so the version it starts with names its image. The picker gains Swift's Vignette target (`openVignetteColorPicker`, `previewVignetteColor`, "Color Picker (Vignette Color)"), closed with its colour by OK and without by Cancel, as the Gradient Map's; Swift's `committing` terms are left out as the Gradient Map's are (a commit keeps the project busy and closes the picker first), and so is `setVignetteColor`'s edit guard, read through `value()`: the Vignette picker lives only while its edit is open (Cancel and OK close it first), where the Gradient Map's may outlive an adjustment's editor. `UI/FilterSheet+Finishing.cpp` holds the three kinds' rows, FilterSheet.cpp running to 442 lines: Vignette's Color row (a 95-point title, the Gradient Map's 24-point swatch), Amount and Highlights with Swift's help, Midpoint, Roundness (no unit), Feather; Bloom's Amount and a logarithmic Radius; Tonal Contrast's Amount, Shadows, Midtones, Highlights and a logarithmic Radius. The sheet hands the picker's working colour to the Vignette and repaints the swatch on every change. Tests: `FinishingFilterTests` (Swift's five, then the kernels at a scale, the settings' bounds, the idle commits, Bloom's grid, a preview for an older grid, the picker, the sheet), the kinds in `FilterModelTests`, a filter keeping effects in `LayerEffectsTests`, the Filter menu in `CompositorMenusTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is the mean of its edge pixels, as Swift's `edgeTone`. `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index c7cdc82..0f4e35e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,7 @@ add_library(compositor_core STATIC src/UI/CameraRawSlider.cpp src/UI/FilterSheet.cpp src/UI/FilterSheet+Adjustments.cpp + src/UI/FilterSheet+Finishing.cpp src/UI/FloatingPanel.cpp src/UI/KeyboardShortcuts.cpp src/UI/KeyboardShortcuts+Sheet.cpp diff --git a/TASKS.md b/TASKS.md index 6c573de..67ad7a4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -369,8 +369,11 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i - 12.8r release run: failed on every package: Qt 6.9 and later refuse `findChild` on a class without `Q_OBJECT`, and 12.8b's `ClipButton` and mixer `Swatch` were found that way (Qt 6.4 in the dev image allows it). Both are now found by their Qt base and name; `scripts/distro-check.sh arch` (Qt 6.11) built and passed 248 of 248. The tag moved to the fix, since nothing was published; the arch check now runs before every release tag. - [x] 12.8d A `std::bad_alloc` aborted `ProjectExportFailureTests` under a parallel run (seen in 12.7b and 12.8a; alone it passed ten of ten): the panel tests held a memory limit while dialogs painted, and the panel limits sat at the edge of what the panel and its snapshot need. - 12.8d round 1: PASS. Its notes taken: the snapshot's measured peak named in AGENTS.md, `malloc_trim` in `limited` named equivalent. -- [ ] 12.9 Version 1.2.4: the finishing filters, stepped keyboard zoom, a steady tool rail and tab strip, the project format's version in one place. -- [ ] 12.10 Versions 1.2.5–1.2.7: the layer context menu without its dropped Layer Effects entry (`5d7adba`), copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews. +- [x] 12.9a Version 1.2.4, part one: the finishing filters (Vignette with its colour picker, Bloom / Glow, Tonal Contrast), a preview kept to the grid it was rendered for, effects kept by a filter's commit (`e08dd8c`). +- 12.9a round 1 (codex): FAIL, two findings: a job waiting behind another edit's worker kept its old grid's image while Preview off grew the grid, so its result landed stretched into the new placement (reproduced); the sheet's test could not tell which setting a control wrote. Fixed: a growth rebuilds a waiting job on the new grid; each row names its member and only that member may move. Each fix's mutant caught. +- 12.9a round 2: PASS, no findings. +- [ ] 12.9b Version 1.2.4, part two: stepped keyboard zoom (`383548c`, `8053fb6`), a steady tool rail and tab strip (`75c4219`). The project format's version in one place is 1.2.5's (`4cc9951`), moved to 12.10. +- [ ] 12.10 Versions 1.2.5–1.2.7: the project format's version in one place (`4cc9951`), the layer context menu without its dropped Layer Effects entry (`5d7adba`), copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews. - [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG). - [ ] 12.12 Version 1.2.11: masks painted anywhere on the canvas, moved unlinked masks under effects, effects kept on screen while text is edited, masks painted and steps undone (`b683acb`), mask thumbnails' ground, text shown as its committed pixels, numeric label scrubbing. - [ ] 12.13 Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), editing while a project saves, images decoded from memory, the guide for AI agents writing projects. diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c index b82e2e9..5230ec5 100644 --- a/kernels/AdjustPixels.c +++ b/kernels/AdjustPixels.c @@ -408,10 +408,8 @@ static void effects_dehaze(double *r, double *g, double *b, double amount) { *b = camera_clamp(y2 + (*b - y2) * sat); } -static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y, size_t width, size_t height, - double amount, double midpoint, double roundness, double feather, double highlights, - int style) { - if (amount == 0 || width == 0 || height == 0) return; +static double vignette_mask(size_t x, size_t y, size_t width, size_t height, + double midpoint, double roundness, double feather) { double nx = ((double)x + 0.5) / (double)width * 2.0 - 1.0; double ny = ((double)y + 0.5) / (double)height * 2.0 - 1.0; double square = fmax(fabs(nx), fabs(ny)); @@ -423,7 +421,14 @@ static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y if (soft < 0.05) soft = 0.05; double t = (dist - start) / soft; t = camera_clamp(t); - double mask = t * t * (3.0 - 2.0 * t); + return t * t * (3.0 - 2.0 * t); +} + +static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y, size_t width, size_t height, + double amount, double midpoint, double roundness, double feather, double highlights, + int style) { + if (amount == 0 || width == 0 || height == 0) return; + double mask = vignette_mask(x, y, width, height, midpoint, roundness, feather); double effect = (amount / 100.0) * mask; // Highlight Priority eases a darkening vignette off bright pixels. The other styles do not. if (effect < 0 && style == 0) { @@ -447,6 +452,33 @@ static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y } } +void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t stride, + double amount, double midpoint, double roundness, double feather, + double highlights, double red, double green, double blue) { + if (!rgba || amount <= 0 || width == 0 || height == 0) return; + double strength = camera_clamp(amount / 100.0); + red = camera_clamp(red); green = camera_clamp(green); blue = camera_clamp(blue); + for (size_t y = 0; y < height; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) continue; + double mask = vignette_mask(x, y, width, height, midpoint, roundness, feather); + if (mask <= 0) continue; + double r = fmin(1.0, p[0] / alpha); + double g = fmin(1.0, p[1] / alpha); + double b = fmin(1.0, p[2] / alpha); + double bright = camera_clamp((rec709(r, g, b) - 0.45) / 0.55); + double effect = strength * mask * (1.0 - (highlights / 100.0) * bright); + r += (red - r) * effect; + g += (green - g) * effect; + b += (blue - b) * effect; + write_premultiplied(p, r, g, b, alpha); + } + } +} + void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights) { if (!shadows && !highlights) return; for (size_t y = 0; y < height; ++y) { @@ -581,6 +613,44 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_ free(glowPlane); } +static double tonal_smooth(double low, double high, double value) { + double t = camera_clamp((value - low) / (high - low)); + return t * t * (3.0 - 2.0 * t); +} + +void adjust_tonal_contrast(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height, + size_t stride, size_t blurredStride, double amount, + double shadows, double midtones, double highlights) { + if (amount <= 0 || (shadows == 0 && midtones == 0 && highlights == 0)) return; + double strength = amount / 50.0; + for (size_t y = 0; y < height; ++y) { + uint8_t *row = rgba + y * stride; + const uint8_t *baseRow = blurred + y * blurredStride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + const uint8_t *base = baseRow + x * 4; + double alpha = p[3]; + if (alpha == 0 || base[3] == 0) continue; + double r = fmin(1.0, p[0] / alpha); + double g = fmin(1.0, p[1] / alpha); + double b = fmin(1.0, p[2] / alpha); + double lum = rec709(r, g, b); + double baseLum = rec709(fmin(1.0, base[0] / (double)base[3]), + fmin(1.0, base[1] / (double)base[3]), + fmin(1.0, base[2] / (double)base[3])); + double shadowWeight = 1.0 - tonal_smooth(0.15, 0.5, baseLum); + double highlightWeight = tonal_smooth(0.5, 0.85, baseLum); + double midtoneWeight = 1.0 - shadowWeight - highlightWeight; + double weight = (shadows * shadowWeight + midtones * midtoneWeight + + highlights * highlightWeight) / 100.0; + double detail = lum - baseLum; + double delta = 0.18 * tanh(detail * 6.0) * weight * strength * (4.0 * lum * (1.0 - lum)); + write_premultiplied(p, camera_clamp(r + delta), camera_clamp(g + delta), + camera_clamp(b + delta), alpha); + } + } +} + static double lut_at(const float *lut, double value) { double scaled = camera_clamp(value) * 255.0; int lo = (int)scaled; diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h index fc73d75..936a2e8 100644 --- a/kernels/AdjustPixels.h +++ b/kernels/AdjustPixels.h @@ -62,6 +62,16 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_ double vignetteAmount, double vignetteMidpoint, double vignetteRoundness, double vignetteFeather, double vignetteHighlights, int vignetteStyle, double scale); +// Standalone Vignette: blends straight sRGB toward the selected edge color using Camera Raw's +// falloff shape and Highlight Priority. Preserves the source alpha and premultiplied storage. +void adjust_colored_vignette(uint8_t *rgba, size_t width, size_t height, size_t stride, + double amount, double midpoint, double roundness, double feather, + double highlights, double red, double green, double blue); +// Local luminance contrast with independent shadow, midtone, and highlight gains. +// `blurred` is the same premultiplied RGBA image blurred at the chosen detail radius. +void adjust_tonal_contrast(uint8_t *rgba, const uint8_t *blurred, size_t width, size_t height, + size_t stride, size_t blurredStride, double amount, + double shadows, double midtones, double highlights); // Manual noise reduction, then sharpening. `scale` maps radius to preview pixels. Applied after the creative grade. void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride, double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking, diff --git a/src/Document/ColorPalette+Session.h b/src/Document/ColorPalette+Session.h index 5732773..92e2826 100644 --- a/src/Document/ColorPalette+Session.h +++ b/src/Document/ColorPalette+Session.h @@ -18,6 +18,9 @@ public: void openGradientMapColorPicker(bool highlights); // The Gradient Map follows the picker's working colour. void previewGradientMapColor(); + // The picker on the open Vignette's colour, which follows it. + void openVignetteColorPicker(); + void previewVignetteColor(); // The picker on an effect of the open panel. void openEffectColorPicker(LayerEffectKind kind); // The effect follows the picker's working colour. @@ -30,3 +33,4 @@ public: private: void setGradientMapColor(const PaletteColor &color, bool highlights); + void setVignetteColor(const PaletteColor &color); diff --git a/src/Document/ColorPalette.cpp b/src/Document/ColorPalette.cpp index c97dffa..b7d740b 100644 --- a/src/Document/ColorPalette.cpp +++ b/src/Document/ColorPalette.cpp @@ -105,6 +105,8 @@ QString ColorPickerTarget::title() const return highlights ? QStringLiteral("Color Picker (Gradient Map Highlights)") : QStringLiteral("Color Picker (Gradient Map Shadows)"); if (kind == Kind::effect) return QStringLiteral("Color Picker (%1 Color)").arg(rawValue(effect)); + if (kind == Kind::vignette) + return QStringLiteral("Color Picker (Vignette Color)"); return background ? QStringLiteral("Color Picker (Background Color)") : QStringLiteral("Color Picker (Foreground Color)"); } @@ -241,6 +243,8 @@ void EditorSession::closeColorPicker(bool commit) } else if (picker.target.kind == ColorPickerTarget::Kind::gradientMap) { // The end previewed the working colour; Cancel puts it back. setGradientMapColor(commit ? color : picker.original, picker.target.highlights); + } else if (picker.target.kind == ColorPickerTarget::Kind::vignette) { + setVignetteColor(commit ? color : picker.original); } else if (picker.target.kind == ColorPickerTarget::Kind::effect) { const PaletteColor chosen = commit ? color : picker.original; changeEffects([&](LayerEffects &effects) { effects.setColor(chosen, picker.target.effect); }); @@ -258,6 +262,16 @@ void EditorSession::openGradientMapColorPicker(bool highlights) notify(); } +void EditorSession::openVignetteColorPicker() +{ + // A commit keeps the project busy: no `committing` term. + if (!canEditPalette() || m_colorPicker || !m_filterEdit || m_filterEdit->kind != FilterKind::vignette) + return; + const AdjustmentColor value = m_filterEdit->settings.vignetteColor; + m_colorPicker = ColorPickerState({ColorPickerTarget::Kind::vignette}, PaletteColor{value.red, value.green, value.blue}); + notify(); +} + void EditorSession::openEffectColorPicker(LayerEffectKind kind) { if (!canEditPalette() || m_colorPicker || !m_effectsEditing) @@ -281,6 +295,22 @@ void EditorSession::previewGradientMapColor() setGradientMapColor(m_colorPicker->color(), m_colorPicker->target.highlights); } +void EditorSession::previewVignetteColor() +{ + if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::vignette) + setVignetteColor(m_colorPicker->color()); +} + +// Its picker lives only while its Vignette edit is open. +void EditorSession::setVignetteColor(const PaletteColor &color) +{ + FilterSettings settings = m_filterEdit.value().settings; + settings.vignetteColor = AdjustmentColor(color); + if (settings == m_filterEdit->settings) + return; + updateFilter(settings, m_filterEdit->preview); +} + void EditorSession::setGradientMapColor(const PaletteColor &color, bool highlights) { // An adjustment's editor can close under its picker (9.5c). diff --git a/src/Document/ColorPalette.h b/src/Document/ColorPalette.h index e0ff6a5..2c96a54 100644 --- a/src/Document/ColorPalette.h +++ b/src/Document/ColorPalette.h @@ -35,9 +35,9 @@ struct PickerHSB { enum class LayerEffectKind; -// The picker's targets: palette, text, effect, Gradient Map end. +// The picker's targets: palette, text, effect, a filter's colour. struct ColorPickerTarget { - enum class Kind { palette, text, gradientMap, effect }; + enum class Kind { palette, text, gradientMap, effect, vignette }; Kind kind; bool background = false; std::optional<QUuid> draftID = std::nullopt; diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h index a0f626a..e745708 100644 --- a/src/Document/EditorSession+Jobs.h +++ b/src/Document/EditorSession+Jobs.h @@ -102,6 +102,8 @@ struct SessionJobs { FilterSettings settings; // Camera Raw's scope of the grade itself. std::optional<CameraRawScope> scope = std::nullopt; + // Its grid's version: a newer grid drops it. + quint64 sourceVersion = 0; }; struct FilterMade { std::optional<ImportedImage> asset; diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp index d007030..f64fcf3 100644 --- a/src/Document/Filters+Session.cpp +++ b/src/Document/Filters+Session.cpp @@ -69,6 +69,9 @@ void EditorSession::updateFilter(const FilterSettings &settings, bool preview) try { edit.growForBlur(); edit.preparedPreview.reset(); + // A waiting job moves to the grid its version names. + if (edit.pending) + edit.pending = edit.previewJob(); } catch (const ProjectError &error) { setBrushError(QString::fromUtf8(error.what())); } catch (const ExportError &error) { @@ -112,22 +115,22 @@ void EditorSession::renderFilterPreview() const FilterJob job = *std::exchange(edit.pending, std::nullopt); m_filterPreviewFor = edit.id; m_filterRendering = true; - m_filterPreview.setFuture(QtConcurrent::run([job]() -> Filtered { + m_filterPreview.setFuture(QtConcurrent::run([job, version = edit.previewSourceVersion]() -> Filtered { try { if (job.kind == FilterKind::cameraRaw) { auto [image, scope] = CameraRawScope::preview(job); - return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope)}; + return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope), version}; } - return Filtered{PixelFilter::run(job), std::nullopt, job.settings}; + return Filtered{PixelFilter::run(job), std::nullopt, job.settings, std::nullopt, version}; } catch (const ContentFillError &error) { - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; } catch (const SubjectRemovalError &error) { - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; } catch (const ExportError &error) { - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; } catch (const ProjectError &error) { // Settings past their bounds: Black & White, Color Balance. - return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings}; + return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings, std::nullopt, version}; } })); } @@ -138,8 +141,11 @@ void EditorSession::finishFilterPreview() { m_filterRendering = false; const Filtered result = m_filterPreview.result(); - // An edit cancelled meanwhile takes nothing; a new one waited. - if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) { + // A cancelled edit takes nothing; an older grid renders again. + if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor && result.sourceVersion != m_filterEdit->previewSourceVersion) { + m_filterEdit->preparing = false; + notify(); + } else if (m_filterEdit && m_filterEdit->id == m_filterPreviewFor) { FilterEdit &edit = *m_filterEdit; edit.preparing = false; edit.previewError = result.failure; @@ -164,8 +170,8 @@ void EditorSession::finishFilterPreview() void EditorSession::cancelFilter() { - // A Gradient Map colour being picked goes with the panel. - if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap) + // A filter colour being picked goes with the panel. + if (m_colorPicker && (m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap || m_colorPicker->target.kind == ColorPickerTarget::Kind::vignette)) closeColorPicker(false); if (finishAdjustmentEditing(false) || !m_filterEdit || m_filterEdit->committing) return; @@ -181,7 +187,7 @@ void EditorSession::commitFilter(std::function<void()> done) if (done) QMetaObject::invokeMethod(this, done, Qt::QueuedConnection); }; - if (m_colorPicker && m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap) + if (m_colorPicker && (m_colorPicker->target.kind == ColorPickerTarget::Kind::gradientMap || m_colorPicker->target.kind == ColorPickerTarget::Kind::vignette)) closeColorPicker(true); if (finishAdjustmentEditing(true) || !m_filterEdit || m_filterEdit->committing) { finish(); @@ -204,6 +210,11 @@ void EditorSession::commitFilter(std::function<void()> done) // Nothing to change closes as Cancel does, with no step. if ((edit.kind == FilterKind::lensCorrection && edit.settings.distortion == 0) || (edit.kind == FilterKind::exposure && edit.settings.exposure == ExposureSettings()) + || (edit.kind == FilterKind::vignette && edit.settings.vignetteAmount == 0) + || (edit.kind == FilterKind::bloomGlow && edit.settings.bloomAmount == 0) + || (edit.kind == FilterKind::tonalContrast + && (edit.settings.tonalAmount == 0 + || (edit.settings.tonalShadows == 0 && edit.settings.tonalMidtones == 0 && edit.settings.tonalHighlights == 0))) || (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0) || (edit.kind == FilterKind::cameraRaw && rendered.cameraRaw.isIdentity())) { cancelFilter(); @@ -224,7 +235,7 @@ void EditorSession::commitFilter(std::function<void()> done) return; } const std::optional<QImage> cached = isAutomatic(edit.kind) && edit.preparedSettings == edit.settings ? edit.preparedPreview : std::nullopt; - const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur; + const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur || edit.kind == FilterKind::bloomGlow; // A painted layer flattens in the worker, which catches failures. m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = rendered, selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached, @@ -339,9 +350,8 @@ void EditorSession::finishFilterCommit() layer.asset = made.asset; layer.transform = made.transform.value_or(current.transform); layer.mask = mask; - // Swift rebuilds the layer without shape, effects and text. + // Swift rebuilds the layer without shape and text; effects stay. layer.shape = std::nullopt; - layer.effects = std::nullopt; layer.text = std::nullopt; m_document->layers[index] = layer; endEdit(); diff --git a/src/Document/Filters.cpp b/src/Document/Filters.cpp index a21215e..8ed7bdd 100644 --- a/src/Document/Filters.cpp +++ b/src/Document/Filters.cpp @@ -12,6 +12,7 @@ #include <numbers> extern "C" { +#include "AdjustPixels.h" #include "BrushPixels.h" #include "LensPixels.h" #include "NoisePixels.h" @@ -23,6 +24,9 @@ QString rawValue(FilterKind kind) case FilterKind::gaussianBlur: return QStringLiteral("Gaussian Blur"); case FilterKind::motionBlur: return QStringLiteral("Motion Blur"); case FilterKind::addNoise: return QStringLiteral("Add Noise"); + case FilterKind::vignette: return QStringLiteral("Vignette"); + case FilterKind::bloomGlow: return QStringLiteral("Bloom / Glow"); + case FilterKind::tonalContrast: return QStringLiteral("Tonal Contrast"); case FilterKind::lensCorrection: return QStringLiteral("Lens Correction"); case FilterKind::cameraRaw: return QStringLiteral("Camera Raw Filter"); case FilterKind::removeBackground: return QStringLiteral("Remove Background"); @@ -61,6 +65,19 @@ FilterSettings FilterSettings::normalized() const result.angle = clamp(angle, -90, 90, 0); result.distance = clamp(distance, 1, 2000, 10); result.amount = clamp(amount, 0.1, 400, 10); + result.vignetteAmount = clamp(vignetteAmount, 0, 100, 35); + result.vignetteColor = vignetteColor.clamped(); + result.vignetteMidpoint = clamp(vignetteMidpoint, 0, 100, 50); + result.vignetteRoundness = clamp(vignetteRoundness, -100, 100, 0); + result.vignetteFeather = clamp(vignetteFeather, 0, 100, 60); + result.vignetteHighlights = clamp(vignetteHighlights, 0, 100, 25); + result.bloomAmount = clamp(bloomAmount, 0, 100, 40); + result.bloomRadius = clamp(bloomRadius, 1, 150, 24); + result.tonalAmount = clamp(tonalAmount, 0, 100, 50); + result.tonalRadius = clamp(tonalRadius, 1, 100, 16); + result.tonalShadows = clamp(tonalShadows, -100, 100, 40); + result.tonalMidtones = clamp(tonalMidtones, -100, 100, 60); + result.tonalHighlights = clamp(tonalHighlights, -100, 100, 30); result.distortion = clamp(distortion, -100, 100, 0); result.refineEdges = clamp(refineEdges, 0, 40, 12); result.matteContrast = clamp(matteContrast, 0, 100, 25); @@ -107,6 +124,19 @@ QImage drawn(const QImage &image) BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter); return context; } + +// CIBloom is private: the pixels plus their Gaussian glow, scaled. +QImage bloom(QImage pixels, double sigma, double intensity) +{ + const QImage glow = PixelAdjust::gaussianBlur(pixels, sigma, false); + for (int y = 0; y < pixels.height(); ++y) { + uchar *row = pixels.scanLine(y); + const uchar *light = glow.constScanLine(y); + for (int index = 0; index < pixels.width() * 4; ++index) + row[index] = uchar(std::min(255.0, std::round(row[index] + intensity * light[index]))); + } + return pixels; +} } QImage PixelFilter::run(const FilterJob &job) @@ -137,6 +167,21 @@ QImage PixelFilter::run(const FilterJob &job) noise_add_at(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), float(settings.amount), settings.gaussian ? 1 : 0, settings.monochromatic ? 1 : 0, job.seed, int64_t(std::floor(job.noiseOrigin.x())), int64_t(std::floor(job.noiseOrigin.y()))); break; + case FilterKind::vignette: + image = drawn(job.image); + adjust_colored_vignette(image.bits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), settings.vignetteAmount, + settings.vignetteMidpoint, settings.vignetteRoundness, settings.vignetteFeather, settings.vignetteHighlights, + settings.vignetteColor.red, settings.vignetteColor.green, settings.vignetteColor.blue); + break; + case FilterKind::bloomGlow: image = bloom(drawn(job.image), settings.bloomRadius * job.scale, settings.bloomAmount / 50); break; + case FilterKind::tonalContrast: { + // The detail's base: the layer softened, clear past its edge. + const QImage base = PixelAdjust::gaussianBlur(drawn(job.image), settings.tonalRadius * job.scale, false); + image = drawn(job.image); + adjust_tonal_contrast(image.bits(), base.constBits(), size_t(width), size_t(height), size_t(image.bytesPerLine()), size_t(base.bytesPerLine()), + settings.tonalAmount, settings.tonalShadows, settings.tonalMidtones, settings.tonalHighlights); + break; + } case FilterKind::lensCorrection: { // Relative to the image's size, so previews bend alike. const QImage source = drawn(job.image); @@ -173,6 +218,7 @@ double FilterEdit::blurMargin(FilterKind kind, const FilterSettings &settings) switch (kind) { case FilterKind::gaussianBlur: return settings.radius * 3 + 2; case FilterKind::motionBlur: return settings.distance / 2 + 2; + case FilterKind::bloomGlow: return settings.bloomRadius * 3 + 2; default: return 0; } } @@ -218,6 +264,7 @@ void FilterEdit::grow(const QRectF &extent) void FilterEdit::prepare(const ImportedImage &source, const LayerTransform &placed) { const QSize size = source.size(); + ++previewSourceVersion; mapping = BrushRaster::pixelToDocument(placed, size.width(), size.height()); // Noise and grain preview whole: enlarged, grain looks coarse. const bool whole = kind == FilterKind::addNoise || kind == FilterKind::grain || kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground; diff --git a/src/Document/Filters.h b/src/Document/Filters.h index 95bbb98..fc9c197 100644 --- a/src/Document/Filters.h +++ b/src/Document/Filters.h @@ -16,8 +16,8 @@ struct ImageLayer; // The Filter and Image menus' filters. enum class FilterKind { - gaussianBlur, motionBlur, addNoise, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain, blackWhite, - colorBalance + gaussianBlur, motionBlur, addNoise, vignette, bloomGlow, tonalContrast, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves, + exposure, gradientMap, grain, blackWhite, colorBalance }; // The menu's title, Swift's rawValue. QString rawValue(FilterKind kind); @@ -27,6 +27,7 @@ bool isAutomatic(FilterKind kind); bool isImageAdjustment(FilterKind kind); // Swift's allCases, the menus' order. inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise, + FilterKind::vignette, FilterKind::bloomGlow, FilterKind::tonalContrast, FilterKind::lensCorrection, FilterKind::cameraRaw, FilterKind::removeBackground, FilterKind::contentAwareFill, FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap, @@ -49,6 +50,22 @@ struct FilterSettings { bool gaussian = false; // The same noise on every channel: brightness alone. bool monochromatic = false; + // Vignette: the edge colour, its strength and falloff. + double vignetteAmount = 35; + AdjustmentColor vignetteColor = AdjustmentColor(0, 0, 0); + double vignetteMidpoint = 50; + double vignetteRoundness = 0; + double vignetteFeather = 60; + double vignetteHighlights = 25; + // Bloom / Glow: strength, and blur radius in layer pixels. + double bloomAmount = 40; + double bloomRadius = 24; + // Tonal Contrast: one detail radius, a strength per tone. + double tonalAmount = 50; + double tonalRadius = 16; + double tonalShadows = 40; + double tonalMidtones = 60; + double tonalHighlights = 30; // Positive straightens barrel distortion, negative pincushion. double distortion = 0; CurvesSettings curves{}; @@ -151,6 +168,8 @@ public: std::optional<QImage> preparedPreview; // The settings the prepared preview was made with. std::optional<FilterSettings> preparedSettings; + // Moves with each new grid: an older render lands nothing. + quint64 previewSourceVersion = 0; std::optional<FilterJob> pending; CameraRawPanel rawPanel; diff --git a/src/UI/FilterSheet+Finishing.cpp b/src/UI/FilterSheet+Finishing.cpp new file mode 100644 index 0000000..a1adc47 --- /dev/null +++ b/src/UI/FilterSheet+Finishing.cpp @@ -0,0 +1,50 @@ +#include "UI/ColorPaletteControls.h" +#include "UI/FilterSheet.h" +#include <QHBoxLayout> +#include <QLabel> +#include <QSlider> +#include <QVBoxLayout> + +// Swift's finishing filters: Vignette, Bloom / Glow, Tonal Contrast. +void FilterSheet::finishing(FilterKind kind) +{ + const auto key = [](double FilterSettings::*field) { return [field](FilterSettings &settings) -> double & { return settings.*field; }; }; + const auto help = [this](const QString &words) { m_controls.back().slider->parentWidget()->setToolTip(words); }; + if (kind == FilterKind::vignette) { + auto *box = new QWidget(this); + auto *row = new QHBoxLayout(box); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(8); + auto *title = new QLabel(QStringLiteral("Color"), box); + title->setFixedWidth(95); + m_vignetteSwatch = new SwatchButton([this] { + const AdjustmentColor colour = settings().vignetteColor; + return PaletteColor{colour.red, colour.green, colour.blue}; + }, 6, 1.5, 1, box); + m_vignetteSwatch->setObjectName(QStringLiteral("vignetteSwatch")); + m_vignetteSwatch->setFixedSize(24, 24); + m_vignetteSwatch->setToolTip(QStringLiteral("Choose the vignette color")); + m_vignetteSwatch->setAccessibleName(QStringLiteral("Vignette color")); + connect(m_vignetteSwatch, &QAbstractButton::clicked, this, [this] { m_session.openVignetteColorPicker(); }); + row->addWidget(title); + row->addWidget(m_vignetteSwatch); + row->addStretch(1); + m_column->addWidget(box); + control(QStringLiteral("Amount"), key(&FilterSettings::vignetteAmount), 0, 100, QStringLiteral("%"), 0, false); + help(QStringLiteral("Blend the chosen color into the edges while keeping the center unchanged")); + control(QStringLiteral("Midpoint"), key(&FilterSettings::vignetteMidpoint), 0, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Roundness"), key(&FilterSettings::vignetteRoundness), -100, 100, QString(), 0, false); + control(QStringLiteral("Feather"), key(&FilterSettings::vignetteFeather), 0, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Highlights"), key(&FilterSettings::vignetteHighlights), 0, 100, QStringLiteral("%"), 0, false); + help(QStringLiteral("Protect bright areas near the edge")); + } else if (kind == FilterKind::bloomGlow) { + control(QStringLiteral("Amount"), key(&FilterSettings::bloomAmount), 0, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Radius"), key(&FilterSettings::bloomRadius), 1, 150, QStringLiteral("px"), 0, true); + } else { + control(QStringLiteral("Amount"), key(&FilterSettings::tonalAmount), 0, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Shadows"), key(&FilterSettings::tonalShadows), -100, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Midtones"), key(&FilterSettings::tonalMidtones), -100, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Highlights"), key(&FilterSettings::tonalHighlights), -100, 100, QStringLiteral("%"), 0, false); + control(QStringLiteral("Radius"), key(&FilterSettings::tonalRadius), 1, 100, QStringLiteral("px"), 0, true); + } +} diff --git a/src/UI/FilterSheet.cpp b/src/UI/FilterSheet.cpp index 65b4736..3c211ca 100644 --- a/src/UI/FilterSheet.cpp +++ b/src/UI/FilterSheet.cpp @@ -201,6 +201,9 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent) control(QStringLiteral("Amount"), key(&FilterSettings::amount), 0.1, 400, QStringLiteral("%"), 1, true); noise(); break; + case FilterKind::vignette: + case FilterKind::bloomGlow: + case FilterKind::tonalContrast: finishing(m_session.filterEdit()->kind); break; case FilterKind::lensCorrection: control(QStringLiteral("Remove Distortion"), key(&FilterSettings::distortion), -100, 100, QString(), 0, false); m_column->addWidget(words(QStringLiteral("Positive straightens lines that bow outward (barrel); negative, lines that bow inward (pincushion)."), @@ -388,7 +391,10 @@ void FilterSheet::synchronize() if (picked != m_pickerColour) { m_pickerColour = picked; m_session.previewGradientMapColor(); + m_session.previewVignetteColor(); } + if (m_vignetteSwatch) + m_vignetteSwatch->update(); const FilterEdit &edit = m_session.filterEdit().value(); for (Control &control : m_controls) { FilterSettings copy = edit.settings; diff --git a/src/UI/FilterSheet.h b/src/UI/FilterSheet.h index 1402e47..8c38088 100644 --- a/src/UI/FilterSheet.h +++ b/src/UI/FilterSheet.h @@ -53,6 +53,8 @@ private: void control(const QString &title, std::function<double &(FilterSettings &)> key, double low, double high, const QString &unit, int decimals, bool logarithmic); void noise(); + // Vignette's colour and rows, Bloom / Glow's, Tonal Contrast's. + void finishing(FilterKind kind); // Remove Background's words, Quality, and Advanced's three rows. void background(); // Black & White's six families and Tint; Color Balance's nine. @@ -72,6 +74,7 @@ private: std::vector<Control> m_controls; CurvesControls *m_curves = nullptr; GradientMapControls *m_gradientMap = nullptr; + SwatchButton *m_vignetteSwatch = nullptr; QButtonGroup *m_distribution = nullptr; QCheckBox *m_monochromatic = nullptr; QButtonGroup *m_quality = nullptr; diff --git a/tests/CompositorMenusTests.cpp b/tests/CompositorMenusTests.cpp index 529481d..382fb04 100644 --- a/tests/CompositorMenusTests.cpp +++ b/tests/CompositorMenusTests.cpp @@ -68,7 +68,7 @@ void CompositorMenusTests::everyEntryHasSwiftsShortcutWithCtrlForCommand() QStringList filters; for (QAction *entry : menus[5]->actions()) filters << entry->text(); - QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"})); + QCOMPARE(filters, (QStringList{"Gaussian Blur…", "Motion Blur…", "Add Noise…", "Vignette…", "Bloom / Glow…", "Tonal Contrast…", "Lens Correction…", "Camera Raw Filter…", "Remove Background…"})); QCOMPARE(menus[6]->actions().size(), 22); } diff --git a/tests/FilterModelTests.cpp b/tests/FilterModelTests.cpp index 52d750e..807ce09 100644 --- a/tests/FilterModelTests.cpp +++ b/tests/FilterModelTests.cpp @@ -66,6 +66,7 @@ void FilterModelTests::kindsKeepSwiftsNames() { const std::pair<FilterKind, const char *> kinds[] = { {FilterKind::gaussianBlur, "Gaussian Blur"}, {FilterKind::motionBlur, "Motion Blur"}, {FilterKind::addNoise, "Add Noise"}, + {FilterKind::vignette, "Vignette"}, {FilterKind::bloomGlow, "Bloom / Glow"}, {FilterKind::tonalContrast, "Tonal Contrast"}, {FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::cameraRaw, "Camera Raw Filter"}, {FilterKind::removeBackground, "Remove Background"}, {FilterKind::contentAwareFill, "Content-Aware Fill"}, {FilterKind::curves, "Curves"}, {FilterKind::exposure, "Exposure"}, @@ -78,7 +79,7 @@ void FilterModelTests::kindsKeepSwiftsNames() QVERIFY(allFilterKinds[index] == kind); QCOMPARE(rawValue(kind), QString(name)); QCOMPARE(isAutomatic(kind), kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground); - QCOMPARE(isImageAdjustment(kind), index >= 7); + QCOMPARE(isImageAdjustment(kind), index >= 10); } // Remove Background's qualities, Swift's words and order. QVERIFY(allBackgroundQualities.size() == 2 && allBackgroundQualities[0] == BackgroundQuality::basic); diff --git a/tests/FinishingFilterTests.cpp b/tests/FinishingFilterTests.cpp new file mode 100644 index 0000000..8a04245 --- /dev/null +++ b/tests/FinishingFilterTests.cpp @@ -0,0 +1,460 @@ +#include "Document/BrushStroke.h" +#include "Document/EditorSession.h" +#include "Document/PixelAdjust.h" +#include "UI/ColorPaletteControls.h" +#include "UI/FilterSheet.h" +#include <QLabel> +#include <QLineEdit> +#include <QSignalSpy> +#include <QSlider> +#include <QTimer> +#include <QtTest> +#include <limits> + +extern "C" { +#include "AdjustPixels.h" +} + +// Swift's FinishingFilterTests: Vignette, Bloom / Glow, Tonal Contrast. +namespace { +QImage filled(int width, int height, const QColor &colour) +{ + QImage image = BrushRaster::context(width, height, false); + image.fill(colour); + return image; +} + +QImage run(FilterKind kind, const QImage &source, const FilterSettings &settings, double scale = 1) +{ + return PixelFilter::run(FilterJob{kind, source, settings, scale, std::nullopt, QTransform()}); +} + +bool opaque(const QImage &image) +{ + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (qAlpha(image.pixel(x, y)) != 255) + return false; + } + } + return true; +} + +// A 24-point layer, a grey square, a 48 canvas. +EditorSession &sample(EditorSession &session) +{ + session.createDocument(48, 48); + QImage image = BrushRaster::context(24, 24, false); + image.fill(0); + QPainter(&image).fillRect(QRect(3, 3, 18, 18), QColor::fromRgbF(0.7, 0.7, 0.7)); + session.insert(ImportedImage(image, image, QStringLiteral("Sample"))); + return session; +} + +bool committed(EditorSession &session) +{ + bool done = false; + session.commitFilter([&done] { done = true; }); + return QTest::qWaitFor([&done] { return done; }, 10'000); +} +} + +class FinishingFilterTests : public QObject { + Q_OBJECT +private slots: + void vignetteDarkensCornersWhileKeepingCenterAndAlpha(); + void vignetteBlendsSelectedColorOnlyAtEdges(); + void bloomSpreadsLightFromBrightPixels(); + void tonalContrastIncreasesMidtoneDetailWithoutChangingAlpha(); + void eachFilterCommitsAsOneUndoStepAndKeepsLayerEffects(); + void theKernelsTakeTheSettingsAtTheScale(); + void settingsKeepSwiftsDefaultsAndBounds(); + void nothingToChangeClosesWithoutAStep(); + void bloomGrowsItsGridAndIsTrimmed(); + void aPreviewForAnOlderGridLandsNothing(); + void aJobWaitingBehindAnotherEditTakesTheNewGrid(); + void theVignettePickerPreviewsAndPutsBack(); + void theSheetShowsSwiftsControls(); +}; + +void FinishingFilterTests::vignetteDarkensCornersWhileKeepingCenterAndAlpha() +{ + FilterSettings settings; + settings.vignetteAmount = 80; + const QImage result = run(FilterKind::vignette, filled(41, 41, QColor::fromRgbF(0.8, 0.8, 0.8)), settings); + QVERIFY(qRed(result.pixel(20, 20)) > qRed(result.pixel(0, 0)) + 50); + QVERIFY(std::abs(qRed(result.pixel(20, 20)) - 204) <= 2); + QVERIFY(opaque(result)); +} + +void FinishingFilterTests::vignetteBlendsSelectedColorOnlyAtEdges() +{ + FilterSettings settings; + settings.vignetteAmount = 100; + settings.vignetteHighlights = 0; + settings.vignetteColor = AdjustmentColor(1, 0, 0); + const QImage result = run(FilterKind::vignette, filled(41, 41, QColor::fromRgbF(0.5, 0.5, 0.5)), settings); + const QRgb corner = result.pixel(0, 0), centre = result.pixel(20, 20); + QVERIFY(qRed(corner) > qGreen(corner) + 80); + QVERIFY(std::abs(qRed(centre) - qGreen(centre)) <= 2 && std::abs(qRed(centre) - 128) <= 2); + QVERIFY(opaque(result)); +} + +void FinishingFilterTests::bloomSpreadsLightFromBrightPixels() +{ + QImage source = filled(65, 65, Qt::black); + QPainter(&source).fillRect(QRect(30, 30, 5, 5), Qt::white); + FilterSettings settings; + settings.bloomAmount = 100; + settings.bloomRadius = 12; + const QImage result = run(FilterKind::bloomGlow, source, settings); + QVERIFY(qRed(result.pixel(40, 32)) > qRed(result.pixel(2, 2)) && qRed(result.pixel(40, 32)) > 0); + QVERIFY(opaque(result)); + // Past a clear layer's light, the glow still shows. + QImage isolated = filled(65, 65, Qt::transparent); + QPainter(&isolated).fillRect(QRect(30, 30, 5, 5), Qt::white); + QVERIFY(qAlpha(run(FilterKind::bloomGlow, isolated, settings).pixel(40, 32)) > 0); +} + +void FinishingFilterTests::tonalContrastIncreasesMidtoneDetailWithoutChangingAlpha() +{ + QImage source = filled(64, 16, Qt::black); + { + QPainter painter(&source); + for (int stripe = 0; stripe < 8; ++stripe) + painter.fillRect(QRect(stripe * 8, 0, 8, 16), QColor::fromRgbF(stripe % 2 ? 0.6 : 0.4, stripe % 2 ? 0.6 : 0.4, stripe % 2 ? 0.6 : 0.4)); + } + const FilterSettings settings{.tonalAmount = 100, .tonalRadius = 6, .tonalShadows = 0, .tonalMidtones = 100, .tonalHighlights = 0}; + const QImage result = run(FilterKind::tonalContrast, source, settings); + QVERIFY(qRed(result.pixel(5, 8)) < qRed(source.pixel(5, 8))); + QVERIFY(qRed(result.pixel(13, 8)) > qRed(source.pixel(13, 8))); + QVERIFY(opaque(result)); +} + +void FinishingFilterTests::eachFilterCommitsAsOneUndoStepAndKeepsLayerEffects() +{ + // Swift's three, and a blur: every filter keeps effects now. + for (const FilterKind kind : {FilterKind::vignette, FilterKind::bloomGlow, FilterKind::tonalContrast, FilterKind::gaussianBlur}) { + EditorSession session; + sample(session); + const LayerEffects effects{.stroke = StrokeEffect{.size = 3}}; + session.setEffects(effects); + session.beginFilter(kind); + QVERIFY(session.filterEdit().value().kind == kind); + const int steps = session.history.undoCount(); + QVERIFY(committed(session)); + QVERIFY(!session.filterEdit()); + QCOMPARE(session.history.undoCount(), steps + 1); + QCOMPARE(session.history.undoName(), rawValue(kind)); + QVERIFY(session.activeLayer().value().effects == effects); + } +} + +void FinishingFilterTests::theKernelsTakeTheSettingsAtTheScale() +{ + QImage source = filled(40, 30, QColor(40, 120, 200)); + QPainter(&source).fillRect(QRect(10, 8, 12, 10), QColor(250, 240, 90)); + // Vignette: Swift's kernel with every setting handed on. + FilterSettings vignette{.vignetteAmount = 70, .vignetteColor = AdjustmentColor(0.2, 0.9, 0.4), .vignetteMidpoint = 30, + .vignetteRoundness = -40, .vignetteFeather = 20, .vignetteHighlights = 60}; + QImage expected = source.copy(); + adjust_colored_vignette(expected.bits(), 40, 30, size_t(expected.bytesPerLine()), 70, 30, -40, 20, 60, 0.2, 0.9, 0.4); + QCOMPARE(run(FilterKind::vignette, source, vignette, 3), expected); + // Tonal Contrast: its base blurred at radius times scale. + const FilterSettings tonal{.tonalAmount = 80, .tonalRadius = 3, .tonalShadows = -30, .tonalMidtones = 70, .tonalHighlights = 45}; + const QImage base = PixelAdjust::gaussianBlur(source, 6, false); + expected = source.copy(); + adjust_tonal_contrast(expected.bits(), base.constBits(), 40, 30, size_t(expected.bytesPerLine()), size_t(base.bytesPerLine()), 80, -30, 70, 45); + QCOMPARE(run(FilterKind::tonalContrast, source, tonal, 2), expected); + // Bloom: the pixels plus their glow at amount / 50. + const FilterSettings bloom{.bloomAmount = 30, .bloomRadius = 2}; + const QImage glow = PixelAdjust::gaussianBlur(source, 4, false); + expected = source.copy(); + for (int y = 0; y < 30; ++y) { + for (int index = 0; index < 160; ++index) + expected.scanLine(y)[index] = uchar(std::min(255.0, std::round(source.constScanLine(y)[index] + 0.6 * glow.constScanLine(y)[index]))); + } + QCOMPARE(run(FilterKind::bloomGlow, source, bloom, 2), expected); +} + +void FinishingFilterTests::settingsKeepSwiftsDefaultsAndBounds() +{ + const FilterSettings defaults; + QVERIFY(defaults.vignetteAmount == 35 && defaults.vignetteColor == AdjustmentColor(0, 0, 0) && defaults.vignetteMidpoint == 50); + QVERIFY(defaults.vignetteRoundness == 0 && defaults.vignetteFeather == 60 && defaults.vignetteHighlights == 25); + QVERIFY(defaults.bloomAmount == 40 && defaults.bloomRadius == 24); + QVERIFY(defaults.tonalAmount == 50 && defaults.tonalRadius == 16 && defaults.tonalShadows == 40 && defaults.tonalMidtones == 60); + QCOMPARE(defaults.tonalHighlights, 30.0); + // Past the ends, clamped; no number, the default. + const double nan = std::numeric_limits<double>::quiet_NaN(); + const FilterSettings high = FilterSettings{.vignetteAmount = 101, .vignetteColor = AdjustmentColor(2, -1, 0.5), .vignetteMidpoint = 101, + .vignetteRoundness = 101, .vignetteFeather = 101, .vignetteHighlights = 101, .bloomAmount = 101, + .bloomRadius = 151, .tonalAmount = 101, .tonalRadius = 101, .tonalShadows = 101, + .tonalMidtones = 101, .tonalHighlights = 101} + .normalized(); + QVERIFY(high.vignetteAmount == 100 && high.vignetteColor == AdjustmentColor(1, 0, 0.5) && high.vignetteMidpoint == 100); + QVERIFY(high.vignetteRoundness == 100 && high.vignetteFeather == 100 && high.vignetteHighlights == 100 && high.bloomAmount == 100); + QVERIFY(high.bloomRadius == 150 && high.tonalAmount == 100 && high.tonalRadius == 100 && high.tonalShadows == 100); + QVERIFY(high.tonalMidtones == 100 && high.tonalHighlights == 100); + const FilterSettings low = FilterSettings{.vignetteAmount = -1, .vignetteMidpoint = -1, .vignetteRoundness = -101, .vignetteFeather = -1, + .vignetteHighlights = -1, .bloomAmount = -1, .bloomRadius = 0.5, .tonalAmount = -1, .tonalRadius = 0.5, + .tonalShadows = -101, .tonalMidtones = -101, .tonalHighlights = -101} + .normalized(); + QVERIFY(low.vignetteAmount == 0 && low.vignetteMidpoint == 0 && low.vignetteRoundness == -100 && low.vignetteFeather == 0); + QVERIFY(low.vignetteHighlights == 0 && low.bloomAmount == 0 && low.bloomRadius == 1 && low.tonalAmount == 0 && low.tonalRadius == 1); + QVERIFY(low.tonalShadows == -100 && low.tonalMidtones == -100 && low.tonalHighlights == -100); + const FilterSettings none = FilterSettings{.vignetteAmount = nan, .vignetteMidpoint = nan, .vignetteRoundness = nan, .vignetteFeather = nan, + .vignetteHighlights = nan, .bloomAmount = nan, .bloomRadius = nan, .tonalAmount = nan, + .tonalRadius = nan, .tonalShadows = nan, .tonalMidtones = nan, .tonalHighlights = nan} + .normalized(); + QVERIFY(none == FilterSettings()); + // The Filter menu's order: after Add Noise, before Lens Correction. + QCOMPARE(rawValue(FilterKind::vignette), QString("Vignette")); + QCOMPARE(rawValue(FilterKind::bloomGlow), QString("Bloom / Glow")); + QCOMPARE(rawValue(FilterKind::tonalContrast), QString("Tonal Contrast")); + QVERIFY(allFilterKinds[3] == FilterKind::vignette && allFilterKinds[4] == FilterKind::bloomGlow && allFilterKinds[5] == FilterKind::tonalContrast); + QVERIFY(!isImageAdjustment(FilterKind::bloomGlow) && !isAutomatic(FilterKind::tonalContrast) && !isAutomatic(FilterKind::vignette)); +} + +void FinishingFilterTests::nothingToChangeClosesWithoutAStep() +{ + const std::pair<FilterKind, FilterSettings> idle[] = { + {FilterKind::vignette, FilterSettings{.vignetteAmount = 0}}, + {FilterKind::bloomGlow, FilterSettings{.bloomAmount = 0}}, + {FilterKind::tonalContrast, FilterSettings{.tonalAmount = 0}}, + {FilterKind::tonalContrast, FilterSettings{.tonalShadows = 0, .tonalMidtones = 0, .tonalHighlights = 0}}}; + for (const auto &[kind, settings] : idle) { + EditorSession session; + sample(session); + const ImportedImage before = session.activeLayer().value().asset.value(); + session.beginFilter(kind); + session.updateFilter(settings, true); + const int steps = session.history.undoCount(); + QVERIFY(committed(session)); + QVERIFY(!session.filterEdit() && session.history.undoCount() == steps); + QVERIFY(session.activeLayer().value().asset.value().identity() == before.identity()); + } + // One tone alone still changes the pixels. + EditorSession session; + sample(session); + session.beginFilter(FilterKind::tonalContrast); + session.updateFilter(FilterSettings{.tonalShadows = 0, .tonalMidtones = 0, .tonalHighlights = 50}, true); + const int steps = session.history.undoCount(); + QVERIFY(committed(session)); + QCOMPARE(session.history.undoCount(), steps + 1); +} + +void FinishingFilterTests::bloomGrowsItsGridAndIsTrimmed() +{ + QCOMPARE(FilterEdit::blurMargin(FilterKind::bloomGlow, FilterSettings{.bloomRadius = 10}), 32.0); + QCOMPARE(FilterEdit::blurMargin(FilterKind::tonalContrast, FilterSettings{.tonalRadius = 10}), 0.0); + QCOMPARE(FilterEdit::blurMargin(FilterKind::vignette, FilterSettings()), 0.0); + EditorSession session; + sample(session); + const LayerTransform before = session.activeLayer().value().transform; + session.beginFilter(FilterKind::bloomGlow); + session.updateFilter(FilterSettings{.bloomAmount = 100, .bloomRadius = 4}, true); + // Room for the default radius, 24: three sigmas and two. + QCOMPARE(session.filterEdit().value().grownMargin, 74.0); + QVERIFY(committed(session)); + // The glow reaches past the layer; clear edges go. + const LayerTransform after = session.activeLayer().value().transform; + QVERIFY(after.size.width() > before.size.width() && after.size.width() < before.size.width() + 2 * 74); +} + +void FinishingFilterTests::aPreviewForAnOlderGridLandsNothing() +{ + EditorSession session; + session.createDocument(1200, 1200); + session.insert(ImportedImage(filled(1200, 1200, QColor(90, 90, 90)), QImage(), QStringLiteral("Big"))); + session.beginFilter(FilterKind::bloomGlow); + QTRY_VERIFY(!session.filterEdit().value().preparing); + // Every landed preview matches the grid it shows on. + bool mismatched = false; + QObject::connect(&session, &EditorSession::changed, &session, [&] { + const std::optional<FilterEdit> &edit = session.filterEdit(); + if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size()) + mismatched = true; + }); + session.updateFilter(FilterSettings{.bloomRadius = 5}, true); + const quint64 version = session.filterEdit().value().previewSourceVersion; + // Wider than the grid: it grows mid-render. + session.updateFilter(FilterSettings{.bloomRadius = 40}, true); + QCOMPARE(session.filterEdit().value().previewSourceVersion, version + 1); + QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing); + QVERIFY(!mismatched); + QCOMPARE(session.filterEdit().value().preparedPreview->size(), session.filterEdit().value().previewSource.size()); + // Preview off: nothing waits; the stale render ends it. + session.updateFilter(FilterSettings{.bloomRadius = 5}, true); + session.updateFilter(FilterSettings{.bloomRadius = 60}, false); + QTRY_VERIFY(!session.filterEdit().value().preparing); + QVERIFY(!session.filterEdit().value().preparedPreview); +} + +void FinishingFilterTests::aJobWaitingBehindAnotherEditTakesTheNewGrid() +{ + // The reviewer's sequence: a cancelled edit's worker still runs. + EditorSession session; + session.createDocument(1200, 1200); + session.insert(ImportedImage(filled(1200, 1200, QColor(90, 90, 90)), QImage(), QStringLiteral("Big"))); + session.beginFilter(FilterKind::gaussianBlur); + QVERIFY(session.filterEdit().value().preparing); + session.cancelFilter(); + // Bloom's first job waits; Preview off grows its grid meanwhile. + session.beginFilter(FilterKind::bloomGlow); + session.updateFilter(FilterSettings{.bloomRadius = 50}, false); + bool mismatched = false, armed = true; + QObject::connect(&session, &EditorSession::changed, &session, [&] { + const std::optional<FilterEdit> &edit = session.filterEdit(); + if (edit && edit->preparedPreview && edit->preparedPreview->size() != edit->previewSource.size()) + mismatched = true; + // The waiting job starts: Preview comes back next turn. + if (std::exchange(armed, false)) + QTimer::singleShot(0, &session, [&session] { session.updateFilter(FilterSettings{.bloomRadius = 50}, true); }); + }); + QTRY_VERIFY(!armed); + QTRY_VERIFY(session.filterEdit().value().preparedPreview && !session.filterEdit().value().preparing); + QVERIFY(!mismatched); +} + +void FinishingFilterTests::theVignettePickerPreviewsAndPutsBack() +{ + EditorSession session; + sample(session); + // Only the open Vignette takes it. + session.beginFilter(FilterKind::bloomGlow); + session.openVignetteColorPicker(); + QVERIFY(!session.colorPicker()); + session.cancelFilter(); + session.beginFilter(FilterKind::vignette); + session.updateFilter(FilterSettings{.vignetteColor = AdjustmentColor(0, 0, 1)}, true); + session.openVignetteColorPicker(); + const ColorPickerState opened = session.colorPicker().value(); + QCOMPARE(opened.target.title(), QString("Color Picker (Vignette Color)")); + QVERIFY(opened.original == (PaletteColor{0, 0, 1})); + // The working colour previews; Cancel puts the original back. + PickerHSB hsb = opened.hsb; + hsb.setRGB(PaletteColor{1, 0, 0}); + session.setColorPickerHSB(hsb); + session.previewVignetteColor(); + QCOMPARE(session.filterEdit().value().settings.vignetteColor, AdjustmentColor(1, 0, 0)); + session.closeColorPicker(false); + QCOMPARE(session.filterEdit().value().settings.vignetteColor, AdjustmentColor(0, 0, 1)); + // OK keeps the colour; a picker already open refuses another. + session.openVignetteColorPicker(); + hsb.setRGB(PaletteColor{0, 1, 0}); + session.setColorPickerHSB(hsb); + const QUuid id = session.colorPicker().value().id; + session.openVignetteColorPicker(); + QCOMPARE(session.colorPicker().value().id, id); + session.closeColorPicker(true); + QCOMPARE(session.filterEdit().value().settings.vignetteColor, AdjustmentColor(0, 1, 0)); + // The panel's Cancel closes the picker unkept, its OK kept. + session.openVignetteColorPicker(); + hsb.setRGB(PaletteColor{1, 1, 0}); + session.setColorPickerHSB(hsb); + session.cancelFilter(); + QVERIFY(!session.colorPicker() && !session.filterEdit()); + session.beginFilter(FilterKind::vignette); + session.openVignetteColorPicker(); + hsb.setRGB(PaletteColor{1, 1, 0}); + session.setColorPickerHSB(hsb); + QVERIFY(committed(session)); + QVERIFY(!session.colorPicker()); + QCOMPARE(session.filterSettings().vignetteColor, AdjustmentColor(1, 1, 0)); +} + +void FinishingFilterTests::theSheetShowsSwiftsControls() +{ + // Each row: title, range, unit and scale, as Swift. + struct Row { + const char *name; + double FilterSettings::*member; + double low, high; + QString unit; + bool logarithmic; + }; + const std::pair<FilterKind, std::vector<Row>> kinds[] = { + {FilterKind::vignette, + {{"amount", &FilterSettings::vignetteAmount, 0, 100, "%", false}, + {"midpoint", &FilterSettings::vignetteMidpoint, 0, 100, "%", false}, + {"roundness", &FilterSettings::vignetteRoundness, -100, 100, "", false}, + {"feather", &FilterSettings::vignetteFeather, 0, 100, "%", false}, + {"highlights", &FilterSettings::vignetteHighlights, 0, 100, "%", false}}}, + {FilterKind::bloomGlow, + {{"amount", &FilterSettings::bloomAmount, 0, 100, "%", false}, {"radius", &FilterSettings::bloomRadius, 1, 150, "px", true}}}, + {FilterKind::tonalContrast, + {{"amount", &FilterSettings::tonalAmount, 0, 100, "%", false}, {"shadows", &FilterSettings::tonalShadows, -100, 100, "%", false}, + {"midtones", &FilterSettings::tonalMidtones, -100, 100, "%", false}, + {"highlights", &FilterSettings::tonalHighlights, -100, 100, "%", false}, + {"radius", &FilterSettings::tonalRadius, 1, 100, "px", true}}}}; + for (const auto &[kind, rows] : kinds) { + EditorSession session; + sample(session); + session.beginFilter(kind); + FilterSheet sheet(session); + QCOMPARE(sheet.findChildren<QSlider *>().size(), qsizetype(rows.size())); + for (const Row &row : rows) { + auto *slider = sheet.findChild<QSlider *>(QString::fromLatin1(row.name) + "Slider"); + auto *field = sheet.findChild<QLineEdit *>(QString::fromLatin1(row.name) + "Field"); + QVERIFY2(slider && field, row.name); + const QList<QLabel *> labels = field->parentWidget()->findChildren<QLabel *>(QString(), Qt::FindDirectChildrenOnly); + QCOMPARE(labels.last()->text(), row.unit); + // The ends, then the middle: linear, or the geometric mean. + const double middle = row.logarithmic ? std::round(std::sqrt(row.low * row.high)) : (row.low + row.high) / 2; + for (const auto &[position, value] : {std::pair(0, row.low), std::pair(1000, row.high), std::pair(500, middle)}) { + // Its own setting alone moves, and the field shows it. + FilterSettings expected = session.filterEdit().value().settings; + expected.*row.member = value; + slider->setValue(position); + QVERIFY2(session.filterEdit().value().settings == expected, row.name); + QCOMPARE(field->text().toDouble(), value); + } + } + QCOMPARE(sheet.findChild<SwatchButton *>("vignetteSwatch") != nullptr, kind == FilterKind::vignette); + } + EditorSession session; + sample(session); + session.beginFilter(FilterKind::vignette); + FilterSheet sheet(session); + QCOMPARE(sheet.findChild<QSlider *>("amountSlider")->parentWidget()->toolTip(), + QString("Blend the chosen color into the edges while keeping the center unchanged")); + QCOMPARE(sheet.findChild<QSlider *>("highlightsSlider")->parentWidget()->toolTip(), QString("Protect bright areas near the edge")); + QVERIFY(sheet.findChild<QSlider *>("midpointSlider")->parentWidget()->toolTip().isEmpty()); + // The swatch: 24 points, its words, picker and colour. + SwatchButton &swatch = *sheet.findChild<SwatchButton *>("vignetteSwatch"); + QCOMPARE(swatch.size(), QSize(24, 24)); + QCOMPARE(swatch.toolTip(), QString("Choose the vignette color")); + QCOMPARE(swatch.accessibleName(), QString("Vignette color")); + const QList<QLabel *> labels = swatch.parentWidget()->findChildren<QLabel *>(); + QVERIFY(labels.size() == 1 && labels[0]->text() == QString("Color") && labels[0]->width() == 95); + QCOMPARE(swatch.grab().toImage().pixelColor(12, 12).rgb(), QColor(Qt::black).rgb()); + swatch.click(); + QVERIFY(session.colorPicker().value().target.kind == ColorPickerTarget::Kind::vignette); + // The sheet hands the picker's colour on and repaints. + PickerHSB hsb = session.colorPicker().value().hsb; + hsb.setRGB(PaletteColor{1, 0, 0}); + session.setColorPickerHSB(hsb); + QCOMPARE(session.filterEdit().value().settings.vignetteColor, AdjustmentColor(1, 0, 0)); + QCOMPARE(swatch.grab().toImage().pixelColor(12, 12).rgb(), QColor(Qt::red).rgb()); + // Shown, the swatch repaints on a change unasked. + sheet.show(); + QVERIFY(QTest::qWaitForWindowExposed(&sheet)); + struct Paints : QObject { + int count = 0; + bool eventFilter(QObject *, QEvent *event) override + { + count += event->type() == QEvent::Paint; + return false; + } + } paints; + swatch.installEventFilter(&paints); + QTest::qWait(100); + paints.count = 0; + hsb.setRGB(PaletteColor{0, 0, 1}); + session.setColorPickerHSB(hsb); + QTRY_VERIFY(paints.count > 0); +} + +QTEST_MAIN(FinishingFilterTests) +#include "FinishingFilterTests.moc" diff --git a/tests/LayerEffectsTests.cpp b/tests/LayerEffectsTests.cpp index 464b3f8..34259a4 100644 --- a/tests/LayerEffectsTests.cpp +++ b/tests/LayerEffectsTests.cpp @@ -298,6 +298,14 @@ void LayerEffectsTests::duplicatesAndBrushCommitsKeepThem() QVERIFY(awaited([&](std::function<void()> done) { session->fillSelection(EditorSession::FillSource::foreground, std::move(done)); })); QCOMPARE(session->history.undoName(), QString("Fill")); QVERIFY(session->activeLayer().value().effects == every()); + // Since 1.2.4 (`e08dd8c`) a filter's commit keeps them. + session->beginFilter(FilterKind::exposure); + FilterSettings settings = session->filterEdit().value().settings; + settings.exposure.exposure = 1; + session->updateFilter(settings, false); + QVERIFY(awaited([&](std::function<void()> done) { session->commitFilter(std::move(done)); })); + QCOMPARE(session->history.undoName(), QString("Exposure")); + QVERIFY(session->activeLayer().value().effects == every()); } void LayerEffectsTests::rebuildsCopiesAndResizesDropThem() @@ -321,13 +329,6 @@ void LayerEffectsTests::rebuildsCopiesAndResizesDropThem() session.updateHueSaturation(HueSaturationSettings(90), false); QVERIFY(awaited([&](std::function<void()> done) { session.commitHueSaturation(std::move(done)); })); }, "Hue/Saturation")); - QVERIFY(dropped([](EditorSession &session) { - session.beginFilter(FilterKind::exposure); - FilterSettings settings = session.filterEdit().value().settings; - settings.exposure.exposure = 1; - session.updateFilter(settings, false); - QVERIFY(awaited([&](std::function<void()> done) { session.commitFilter(std::move(done)); })); - }, "Exposure")); QVERIFY(dropped([](EditorSession &session) { session.applySelection(rectPath(QRectF(2, 2, 6, 4)), SelectionMode::replace, QStringLiteral("Select")); QVERIFY(awaited([&](std::function<void()> done) { session.beginSelectionTransform(std::move(done)); })); diff --git a/tests/Tests.cmake b/tests/Tests.cmake index e0a7182..662ea65 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -249,6 +249,9 @@ compositor_test(SelectionClipboardTests) compositor_test(SmartEditTests) compositor_test(FilterSheetTests) compositor_test(FilterControlsTests) +compositor_test(FinishingFilterTests) +# It checks the filters against their kernels. +target_include_directories(FinishingFilterTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(FloatingSelectionTests) compositor_test(CropTests) compositor_test(GuideTests)