Camera RAW import through LibRaw with a develop sheet (12.6a)

This commit is contained in:
雷电芽衣 committed 2026-09-27 09:19:59 -04:00
1 parent 374ba8ec73
commit 5a2fa91bc7
29 files changed
+1794 -257

No files matched your search

+1
View File
@@ -119,6 +119,7 @@ Platform adaptations, each forced by a missing Qt analog:
- The Magic tool's Object mode (12.4, Swift 1.1.8): `Document/ObjectSelection.h/.cpp` twin Swift's file, `ObjectSelection+Session.h` its session half; `WandMode` (Swift's raw values, `allWandModes`) sits in `Selection.h`. Vision's foreground instances have no Linux twin (the user's decision): `SubjectRemoval::foreground` hands back U²-Net's mask at the image's size (the Remove Background model and cache, 9.7), which `select` scales to the model's 320 grid, cuts at half, and splits into 8-connected regions (`region`, the neighbours a traced outline joins); the region under the clicked cell is the instance, background none, and a picture where the model finds no subject none, as Vision's empty result. Its cells are enlarged smoothly to the image and refined by `GuidedMatte::refine` (radius 5) on the image, for Swift's edge-preserving upsample, then cut at half; `adjusted` erodes or dilates a pixel ring a step (eight neighbours, ten steps at most, the image's edge counting as inside, Swift's `eroded` and `dilated`), `MagicWand::outline` traces it and `smoothed` rounds the stairs as Swift's (the start Qt's close repeats dropped first, since a CGPath's close adds no point, then a subpath under three points dropped, Douglas–Peucker at 1.6 from the leftmost point, Chaikin three times, the fill rule kept; Swift's later drops of a repeated end and Chaikin's guard under three points are dead after that and left out). A `bad_alloc` is `ExportError(render)`; Swift's `unsupported` has no twin. The worker catches `SubjectRemovalError`, `MagicWandError` (the trace's refusals) and `ExportError`, each landing in `brushError` as Swift's `catch`. `selectionSample` is the wand's sample made public as Swift's; `selectObject` runs through the wand's watcher and landing (`Wanding` carries the step's name, `Object Selection`), with a completion callback, the offset clamped to ten either way and the anti-alias setting as its smoothing. `pressWandKey` chooses the tool, W on the canvas and in the list, a repeat ignored; Tab steps the mode; the tool's label, its shortcut definition (`Magic`) and the bar's title say Magic. The bar (`LassoControls`) shows Wand and Object as checkable buttons for Magic alone, the colour wand's controls in Wand mode, and in Object mode This Layer and All Layers and an Edge field of −10 to 10 px (`SelectionAmountField` takes a sign when its range goes below zero; a lone minus changes nothing, an overflow reads as its end), with Swift's help; the anti-alias help follows the mode. The canvas's press runs `selectObject` ahead of the colour wand, a click inside the selection picks afresh, the cursor is the crosshair with `SelectionIcon::objectSelection` (haloed by eight offsets as every selection icon, where Swift strokes it white), and the rail draws the icon for Object mode (`ToolIconKind::object`); the status hint follows the mode. Known difference: U²-Net finds one salient mask, so objects touching in it are one region where Vision may tell instances apart. Left out as dead: the mode picker's `cancelLasso` (Magic never holds a draft: `beginLasso` refuses it and `selectTool` cancels one), `selectObject`'s busy term (`canEditSelection` reads it) and its clamp of the offset (`adjusted` clamps), and a null check of the enlarged cells (`GuidedMatte::refine` checks its own). Equivalent mutants: the grid's cut at exactly 128, `simplifyClosed`'s guard under four points (the fallback returns the same points), `finish`'s three-point threshold at four (a traced loop has four corners), the Chaikin reserve, the edge's rounding (typing and arrows give whole numbers), W calling `selectTool` (`pressWandKey` is that call). Untested failure paths: the region's own 320-cell image, 100 KB, which no limit starves without starving the grid before it, and the worker's `MagicWandError`: a refined mask never nears eight million edges, and the trace's image-sized buffer asks less than the guided filter before it. Tests: `ObjectSelectionTests` (regions, edges, smoothing, the model on red discs, the session, keys, the canvas), the bar in `LassoControlsTests`, the icon sheet, the rail and hints in `ContentViewTests` and `ContentViewStatusTests`.
- Photoshop import (12.5a, Swift 1.2.0): `src/IO/PSD/` twins Swift's `IO/PSD` folder. `PSDTypes` holds `PSDError` (Swift's five cases and words), `PSDConversion`, `PSDRecord`, `PSDDocument` and `fromPSD` (Swift's `LayerBlendMode.fromPSD`); Swift's `PSDLayerKind.other` is never assigned, so the case and its note are left out. `PSDChannelCoder` decodes raw and PackBits planes (a no-op run skipped, every overrun `truncated`) and builds `RGBA8888_Premultiplied` pixels tagged sRGB, rounding as Swift's `(c × a + 127) / 255`, and `Grayscale8` masks; a surface that cannot be allocated is `ExportError(render)` through `BrushRaster::context`, where Swift says `truncated`. `PSDReader` reads Swift's fields in Swift's order over a `Cursor` whose bounds check cannot overflow (an 8B64 length near `qint64`'s top reads as truncated, where Swift's `offset + count` traps), maps the file where it can and reads it where it cannot (a pipe, a mount without `mmap`), as `mappedIfSafe` does (a file that cannot be opened or read is `unreadable`, where Swift passes on the Cocoa error), logs every refusal on `omaphoto.io` and rethrows it. Pascal names are Mac OS Roman through a table of the 128 high code points taken from Python's `mac_roman` (Qt decodes it only through ICU); keys are ASCII or nothing, as Swift's `.ascii`. `PSDAdjustments::parse` (in `PSDReader+Adjustments.cpp`) reads Levels, Curves and Hue/Saturation as Swift does. `PSDVector` draws vector masks with `QPainterPath` (tight `boundingRect`, a path empty only without elements as a `CGPath`), fills by nonzero winding and strokes with `Qt::SvgMiterJoin` at 10, SVG's miter rule being CoreGraphics' (probed: kept to a ratio of 9.57, bevelled from 10.43; `Qt::MiterJoin` measures from the join point and clips instead); `integral` stays in doubles, so a wild box reaches the size check instead of an overflowing `QRect`. `PSDDocumentBuilder` makes the layers and conversions; a shape's identity is its asset's. `ImageImporter::loadPhotoshop` and `photoshopAssets` run on the import's worker, `makeImport` on the UI thread. In the session (`EditorSession+Imports.cpp`) a file whose first bytes are `8BPS` takes the Photoshop path: `beginPSDReading` shows the sheet while a `QFutureWatcher` reads it, `finishPSDReading` fills it or takes it away, `finishConversion` answers from the event loop (a resumed task), Cancel while reading drops the file silently, a failure joins the import's failures, and a confirmed file lands through `insertPhotoshop` (a new canvas at the file's size and resolution, or one folder named for the file inside the active folder, centred on a drop point, 10,000 layers at most). A request of Photoshop files alone is one `Import Photoshop File` step. Fixes beyond Swift: `insertPhotoshop` validates the hierarchy first, so folders nested past its depth refuse the file (`truncated`), and a folder clipped to the layer below takes no link but the base note, where Swift links it; either way Swift admits a document its store refuses. Left out as dead: the gates' `showsConversionSheet` terms and its `didSet` (the sheet shows only while importing, which the gates refuse already), Swift's test hook `confirmConversions`, `confirmPSDConversions` and `NativeLayerList.moveLayer` (no caller), the request's `id`, the builder's folder and adjustment checks on a clipping base (only pixel layers become bases), the reader's `layerSectionEnd`, the default mask colour, the reader's plane-size checks (a plane decodes to its size), resolution's finiteness test and its floor, the vector code's finiteness tests on boxes built from finite numbers and the origination's notes (never set), Curves' two length checks past the first, `pixelSize`'s area in doubles (its boxes are whole, so the whole pixels decide alone), the session's catch around `makeImport` (every image arrives with its asset, so nothing there allocates), and the new folder's removal from the collapsed set (a fresh id is in no set). `UI/PSDConversionSheet` is Swift's sheet in a window-modal `QDialog` (`conversionSheet`) that `ContentView` shows from the session's request: the title at 17 pixels bold, the words, a spinner while reading or the list of layer names over their notes, Cancel and the default confirm button, resting while reading; Escape and the close button cancel, and a sheet the session takes away answers nothing; `open()` makes the dialog window-modal. The request shown is kept, so an unrelated change keeps the content, and replaced content hides at once and is deleted later. The importer's filter and the desktop entry take `.psd` (`image/vnd.adobe.photoshop`); a dropped picture is copied as `Dropped.<suffix>` into a folder of its own, as Swift 1.2.0 keeps a dropped file's name. Equivalent mutants: the cursor's own bounds refusal and `skip`'s (every read past the end meets another refusal: a short signature, a short plane), PackBits' count and row-end checks (the branches refuse the same bytes; they spare reading past theLine truncated
- Outer Glow and brush Smoothing (12.5b, Swift 1.2.0): `OuterGlowEffect` joins the effects with Swift's defaults (20 pixels, white, 75%) and bounds (0–500, 0–1), `LayerEffectKind::outerGlow` ("Outer Glow") last, so the footer's menu, the list's rows and every helper take it; the manifest's `outerGlow` is Swift's `Codable` (`size`, `opacity`, the colour, `enabled` only while set). `LayerEffectsKernel` blurs the shape's coverage at sigma size ÷ 2, Metal's glow buffer (a size under 0.02 copies it, as Swift's zero shift does), and composes it over the drop shadow and under an outside stroke, clamped to glow × (1 − shape) × opacity; the renderer's margin takes three sizes, the stroke surface's reach three sizes and two. Swift's CPU `outerGlowCoverage` serves machines without Metal and is dead here, as the other CPU coverages are (9.6b), so Swift's CPU and Metal parity test has no twin. The preview cache keeps the last preview while one effect is hidden or shown on the same pixels (Swift's `dbe82b0`). A fix beyond Swift: a reduced preview scales the glow's size, as it scales the inner shadow's lengths (9.6c). The sheet shows the glow's swatch, Size (0–100 on the slider, 500 typed, px) and Opacity. Smoothing is Swift's string: `BrushSettings::smoothing` (0–100); while the Brush paints (either mode) with smoothing, the brush (`m_brushAnchor`) moves only once the pointer (`m_brushPointer`) pulls further than smoothing ÷ the viewport's zoom, and then by the excess along the string; the release paints on to the pointer; a cancel drops both. The other brush tools follow the pointer. Equivalent mutants: the press's pointer and the cancel's two resets (each makes the others' work redundant), `>` at the string's exact length (a move of zero), the release's tool and smoothing terms (otherwise it appends the last point again), and the surface's glow reach between the blur's three sigmas and Swift's three sizes. `BrushControls` shows Smoothing for the Brush alone: a 100-point slider in tenths and a 42-point `SelectionAmountField` of 0–100 with Swift's help. The rest of 1.2.0 is held already (TASKS.md 12.0, each note now saying Swift agrees) or not code (`NativeLayerList.moveLayer` has no caller; the spelling touches shader comments), but for two follow-ups of `90ee29f` and `7c4512a`: Select All never rests and reaches a focused layer list (7.1's note), and the invert test holds no wall clock. Tests: `OuterGlowTests` (Swift's, but the parity test), the glow in `LayerEffectsKernelTests`' shader replay, `LayerEffectsSurfaceTests`' rows and `EffectsPreviewCacheTests`' scaling, the kinds, manifest and menu in their files; `BrushSmoothingTests` and the bar in `BrushControlsTests`.
- Camera RAW (12.6a, Swift 1.2.1): Core Image's RAW filter has no Linux twin, so `IO/RawImporter` develops through LibRaw (the user's decision), its thread-safe `libraw_r` found by pkg-config; `Dockerfile.dev` installs `libraw-dev`, the PKGBUILD and the flake depend on `libraw`, the distro images carry it. A RAW is any file whose extension names a type inheriting `image/x-dcraw` in the MIME database (`matches`, `isRawType` for a dropped picture, `globs` for the importer's filter), Swift's `UTType.rawImage`. `RawDevelopSettings` keeps Swift's fields, `isAsShot` and `reset`; `RawDevelopRequest` is Swift's tuple. White balance: `planckian` is Kim et al.'s cubic (1667–25000 K), `whitePoint` moves off the locus along its greener uv normal by tint ÷ 3000, `temperatureOf` inverts it by a mired search and golden sections: Adobe's scale and sign (the DNG SDK's `kTintScale`), so a positive tint is a greener white and turns the picture magenta, and D65 reads about +10, as Lightroom's Daylight. Known difference: CIRAWFilter's temperature and tint follow Apple's own model; these follow Adobe's convention on CIE 1960 uv. `asShot` reads the camera's multipliers (`cam_mul`, else dcraw's daylight `pre_mul`) and turns the neutral into xy through LibRaw's `rgb_cam` (a DNG's `cam_xyz` stays zero) with `pre_mul` put back; `develop` works back from the chosen white to camera multipliers (`user_mul`, the second green the first's), reading the camera's `pre_mul`, `rgb_cam` and size from unpack's copy (`rawdata`), since a process overwrites `color` with its own multipliers and a half-size one halves `sizes`; a white the matrix maps to a response at or below zero is refused, logged, where LibRaw would drop the balance in silence; the refusal is `ImageImportError(whiteBalance)`, a kind of Linux's own with words of its own, since `CIRAWFilter` takes every white and real cameras' matrices reach it inside the sliders (the warm and green corner). It takes LibRaw's 16-bit sRGB at a power of one (linear), with neither auto-brightening nor the maximum's adjustment (`adjust_maximum_thr` 0, so a frame near white keeps its level), clipped at white in camera space (`highlight` 0, so a clipped photosite stays neutral: unclipped, the matrix turns it magenta), where LibRaw divides by the smallest multiplier; the twin multiplies the smallest back, so the balance is anchored on green, as Core Image's, where dcraw alone brightens the frame as the temperature moves. Known difference: where a multiplier falls below green's (the slider's far ends), the frame's white falls to that multiplier. It makes the look itself, rows side by side: exposure multiplies linear light by 2^EV, clamped, then Boost mixes sRGB's curve (0) with LibRaw's BT.709 curve (1). Known difference: Apple's Boost curve is undisclosed; at 1 this is LibRaw's default curve. The result is opaque `RGBA8888_Premultiplied` tagged sRGB, oriented by LibRaw. Only three-colour sensors develop; a four-colour one is `unsupported` (dcraw's matrices hold no fourth to invert). A preview past its limit decodes at half size, then fits smoothly. `Queue` is Swift's actor: one develop at a time under a mutex, the unpacked frame kept for the previews of one file, a full develop always fresh, `release` letting go; Swift keys the cache by the limit too, since its filter's scale is fixed when made, where LibRaw halves per develop. LibRaw's refusals are `unreadable`, logged with its message on `omaphoto.io`; a surface that cannot be made is `ExportError(render)`. In the session (`EditorSession+Imports.cpp`) a RAW goes before the Photoshop check: `pixelSize` (unreadable when it fails) and Swift's budget (`tooLarge` past 30,000 a side or the pixels left), then `developRaw` shows the sheet with the camera's settings (the defaults when they cannot be read) and keeps the answer, `finishRawDevelop` clears the request, releases the queue on a worker and answers from the event loop (a resumed task): nothing skips the file, settings develop the whole frame on the import's worker, which catches `ImageImportError` and `ExportError`, into a layer named for the file. `rawDevelop` alone stands for Swift's `rawDevelop` and `showsRawDevelop`, which are set and cleared together; the latter's `didSet` is dead (the sheet shows only while importing, which holds file requests anyway), and so is the test hook `confirmRawDevelop`. `UI/RawDevelopSheet` is Swift's sheet in a window-modal `QDialog` (`rawDevelopSheet`) that `ContentView` shows while the request stands, its close button and Escape cancelling and a sheet the session takes away answering nothing: the title at 17 pixels bold, the 560 by 340 preview on 35% black rounded 6 with a spinner while a develop runs, four rows (a 90-point title as the buddy of a 300-point slider, the value 80 points wide in `PlaceholderText`: Exposure in hundredths, Temperature and Tint whole, Boost in thousandths, printed as Swift's `%.Nf`), Reset (resting while as shot), Cancel and the default Line truncated
- 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 <title>"), 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.
+7 -243
View File
@@ -15,6 +15,9 @@ find_package(Qt6 6.4 REQUIRED COMPONENTS Widgets Concurrent Test)
find_package(libheif REQUIRED)
find_package(Fontconfig REQUIRED)
find_package(onnxruntime REQUIRED)
find_package(PkgConfig REQUIRED)
# The thread-safe build: previews develop on workers.
pkg_check_modules(LIBRAW REQUIRED IMPORTED_TARGET libraw_r)
add_library(compositor_core STATIC
src/Logging.cpp
@@ -116,6 +119,7 @@ add_library(compositor_core STATIC
src/UI/KeyboardShortcuts+Sheet.cpp
src/UI/NewCanvasSheet.cpp
src/UI/PSDConversionSheet.cpp
src/UI/RawDevelopSheet.cpp
src/UI/OmarchyTheme.cpp
src/UI/ProjectTabs.cpp
src/UI/ProjectWorkspaceView.cpp
@@ -124,6 +128,7 @@ add_library(compositor_core STATIC
src/IO/CanvasResizer.cpp
src/IO/ImageExporter.cpp
src/IO/ImageImporter.cpp
src/IO/RawImporter.cpp
src/IO/ImageResizer.cpp
src/IO/ProjectController.cpp
src/IO/ProjectController+Resize.cpp
@@ -186,7 +191,7 @@ set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNEL
# The per-pixel loops vectorise only at -O3.
set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3)
target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS})
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE heif Fontconfig::Fontconfig onnxruntime::onnxruntime)
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW)
target_compile_options(compositor_core PUBLIC -Wall -Wextra
$<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>)
# Empty optionals and bad indexes abort, not pass by luck.
@@ -254,245 +259,4 @@ if(OMAPHOTO_MODEL)
install(FILES ${model} DESTINATION ${CMAKE_INSTALL_DATADIR}/omaphoto RENAME u2net.onnx)
endif()
enable_testing()
function(compositor_test name)
add_executable(${name} tests/${name}.cpp)
target_link_libraries(${name} PRIVATE compositor_core Qt6::Test)
add_test(NAME ${name} COMMAND ${name})
set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
endfunction()
compositor_test(AdjustmentSurfaceTests)
compositor_test(BlendModePickerTests)
compositor_test(BlendShortcutTests)
compositor_test(LayersPanelTests)
compositor_test(LayerListFocusTests)
compositor_test(LayerDragTests)
compositor_test(LayerDragGestureTests)
compositor_test(ProjectDropTests)
compositor_test(BlendModeTests)
compositor_test(BrushCanvasTests)
compositor_test(BrushGestureTests)
compositor_test(BrushKeyTests)
compositor_test(BrushCursorOverlayTests)
compositor_test(BrushControlsTests)
compositor_test(BrushPerformanceTests)
compositor_test(CanvasPerformanceTests)
compositor_test(PoolMapTests)
compositor_test(DesktopIntegrationTests)
# The install it checks needs the app built.
add_dependencies(DesktopIntegrationTests omaphoto)
# What this build installs, as CMake itself reads it.
target_compile_definitions(DesktopIntegrationTests PRIVATE OMAPHOTO_BUNDLED=$<BOOL:${OMAPHOTO_BUNDLE_ORT}>
OMAPHOTO_LIBDIR="${CMAKE_INSTALL_LIBDIR}" OMAPHOTO_MODEL_PATH="${OMAPHOTO_MODEL}")
compositor_test(BrushIntersectionTests)
compositor_test(BrushSessionTests)
compositor_test(BrushSmoothingTests)
compositor_test(BrushPreviewTests)
compositor_test(BrushRasterTests)
compositor_test(BrushTests)
compositor_test(BrushCoverageTests)
compositor_test(SpotHealingTests)
compositor_test(CloneStampTests)
compositor_test(CloneStampCanvasTests)
compositor_test(SmudgeLiquifyTests)
compositor_test(BlurToolTests)
compositor_test(SmearCanvasTests)
compositor_test(GradientTests)
compositor_test(GradientCanvasTests)
compositor_test(ShapeToolTests)
compositor_test(ShapeLayerTests)
compositor_test(ShapeCanvasTests)
compositor_test(TypeToolTests)
compositor_test(TextLayoutTests)
compositor_test(TypeSessionTests)
compositor_test(TypeStoreTests)
compositor_test(TypeControlsTests)
compositor_test(TypeFontPickerTests)
compositor_test(InlineTextEditorTests)
compositor_test(InlineTextInputTests)
compositor_test(InlineTextDrawTests)
compositor_test(InlineTextPreeditTests)
compositor_test(InlineTextPointerTests)
compositor_test(InlineTextHandleTests)
compositor_test(ColorPickerTests)
compositor_test(FloatingPanelTests)
compositor_test(ColorSwatchTests)
compositor_test(ColorPaletteControlsTests)
compositor_test(ColorPickerSheetTests)
compositor_test(EyedropperTests)
compositor_test(PickingTests)
compositor_test(LevelsTests)
compositor_test(LevelsSessionTests)
compositor_test(LevelsModelTests)
compositor_test(LevelsEdgeTests)
compositor_test(LevelsFailureTests)
compositor_test(LevelsSheetTests)
compositor_test(LevelsHandleTests)
compositor_test(LevelsCanvasTests)
compositor_test(ImageAdjustmentTests)
compositor_test(ImageAdjustmentModelTests)
compositor_test(CurvesControlsTests)
compositor_test(HueSaturationTests)
compositor_test(HueSaturationModelTests)
compositor_test(HueSaturationSessionTests)
compositor_test(HueSaturationFailureTests)
compositor_test(HueSaturationSheetTests)
compositor_test(HueSaturationSpectrumTests)
compositor_test(HueSaturationCanvasTests)
compositor_test(CanvasDisplayTests)
compositor_test(CanvasNavigationTests)
compositor_test(CanvasSizeTests)
compositor_test(CanvasThumbnailTests)
compositor_test(ClippingStackTests)
compositor_test(CompositorTests)
compositor_test(CompositorMenusTests)
compositor_test(ViewMenuTests)
compositor_test(KeyboardShortcutsTests)
compositor_test(KeyboardShortcutsSheetTests)
compositor_test(RemappedKeysTests)
compositor_test(CompositorUITestsLaunchTests)
compositor_test(ContentViewTests)
compositor_test(ContentViewStatusTests)
compositor_test(DistortTests)
compositor_test(DownsampleTests)
compositor_test(DrawCoverageTests)
compositor_test(EditorSessionSignalTests)
compositor_test(EditorSessionTests)
compositor_test(LayerDuplicateTests)
compositor_test(ExportTests)
compositor_test(FolderMaskClipTests)
compositor_test(EditorCanvasTests)
compositor_test(GroupSessionTests)
compositor_test(GroupTests)
compositor_test(GroupingSelectionTests)
compositor_test(HighDpiCanvasTests)
compositor_test(HistoryTests)
compositor_test(ImageImportSessionTests)
compositor_test(ImageImportTests)
compositor_test(ImageSizeTests)
compositor_test(JPEGExportTests)
compositor_test(LayerAppearanceControlsTests)
compositor_test(LayerAppearanceTests)
compositor_test(FolderOpacityTests)
compositor_test(LayerMaskEditTests)
compositor_test(LayerMaskSessionTests)
compositor_test(LayerMaskTests)
compositor_test(LayerRendererTests)
compositor_test(LayerTests)
compositor_test(LiveMaskRendererTests)
compositor_test(LiveMaskBakeTests)
compositor_test(LiveMaskDeleteTests)
compositor_test(LiveMaskEditTests)
compositor_test(LiveMaskSessionTests)
compositor_test(LiveMaskTests)
compositor_test(MaskPlacementTests)
compositor_test(MaskSignalTests)
compositor_test(MaskTracingTests)
compositor_test(MaskTransformTests)
compositor_test(ProjectManifestTests)
compositor_test(NativeLayerListTests)
compositor_test(NavigationToolHeaderTests)
compositor_test(NewCanvasSheetTests)
compositor_test(OmarchyThemeTests)
compositor_test(ProjectControllerTests)
compositor_test(ProjectCopyTests)
compositor_test(ProjectOpenTests)
compositor_test(ProjectReceiveTests)
compositor_test(ProjectReplaceTests)
compositor_test(ProjectWorkspaceFlowTests)
compositor_test(ProjectWorkspaceSignalTests)
compositor_test(ProjectWorkspaceTests)
compositor_test(ProjectWorkspaceViewTests)
# It starts the real app beside itself.
add_dependencies(ProjectWorkspaceViewTests omaphoto)
compositor_test(ProjectSessionTests)
compositor_test(ProjectTests)
compositor_test(RasterSnapshotSessionTests)
compositor_test(RasterSnapshotTests)
compositor_test(SessionGateTests)
compositor_test(TiledLayerTests)
compositor_test(TiledPieceTests)
compositor_test(TiledStrokeTests)
compositor_test(TransformSessionTests)
compositor_test(ToolIconTests)
compositor_test(TransformTests)
compositor_test(TransformOverlayTests)
compositor_test(TransformPressTests)
compositor_test(TransformInspectorTests)
compositor_test(TransformSnapTests)
compositor_test(CursorTests)
compositor_test(DistortCanvasTests)
compositor_test(DistortMaskTests)
compositor_test(FiltersTests)
compositor_test(FilterTests)
compositor_test(FilterModelTests)
# It checks what the filters hand Swift's C kernels.
target_include_directories(FilterModelTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(FilterSessionTests)
compositor_test(FilterFailureTests)
compositor_test(LayerAdjustmentTests)
compositor_test(AdjustmentLayerTests)
compositor_test(AdjustmentEditorTests)
compositor_test(AdjustmentPanelTests)
compositor_test(LayerEffectsTests)
compositor_test(OuterGlowTests)
compositor_test(LayerEffectsSessionTests)
compositor_test(LayerEffectsKernelTests)
compositor_test(EffectsPreviewCacheTests)
compositor_test(LayerEffectsSurfaceTests)
compositor_test(EffectsCanvasTests)
compositor_test(EffectsFailureTests)
compositor_test(EffectsPanelTests)
compositor_test(LayerEffectRowTests)
compositor_test(GuidedMatteTests)
compositor_test(SubjectRemovalTests)
compositor_test(SubjectRemovalModelTests)
compositor_test(SubjectRemovalFailureTests)
compositor_test(RemoveBackgroundTests)
compositor_test(SelectSubjectTests)
compositor_test(ObjectSelectionTests)
compositor_test(PSDRoundTripTests)
compositor_test(PSDVectorTests)
compositor_test(PSDReaderTests)
compositor_test(PSDReaderLayerTests)
compositor_test(PSDImportTests)
compositor_test(LayerEffectsRendererTests)
compositor_test(LayerFlipTests)
compositor_test(LayerMergeTests)
compositor_test(PixelEditTests)
compositor_test(PixelInvertTests)
compositor_test(PixelMoveTests)
compositor_test(SelectionTests)
compositor_test(SelectionFeatherTests)
compositor_test(SelectionSourceTests)
compositor_test(SelectionEditTests)
compositor_test(SelectionSignalTests)
compositor_test(SelectMenuTests)
compositor_test(SelectionCanvasTests)
compositor_test(SelectionOverlayTests)
compositor_test(LassoControlsTests)
compositor_test(SelectionAmountSheetTests)
compositor_test(MagicWandTests)
compositor_test(SelectionClipboardTests)
compositor_test(SmartEditTests)
compositor_test(FilterSheetTests)
compositor_test(FilterControlsTests)
compositor_test(FloatingSelectionTests)
compositor_test(CropTests)
compositor_test(GuideTests)
compositor_test(GuideCanvasTests)
compositor_test(CropSessionTests)
compositor_test(CropCanvasTests)
compositor_test(CropControlsTests)
compositor_test(CropFailureTests)
compositor_test(CanvasSizeSheetTests)
compositor_test(ImageSizeSheetTests)
compositor_test(ProjectResizeTests)
compositor_test(JPEGExportSheetTests)
compositor_test(ProjectExportTests)
compositor_test(ProjectExportFailureTests)
compositor_test(ByteCountsTests)
compositor_test(ImageFileDropTests)
compositor_test(SliderSnapTests)
compositor_test(ProjectResizeFailureTests)
include(tests/Tests.cmake)
+1 -1
View File
@@ -3,7 +3,7 @@ FROM $BASE
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ cmake ninja-build qt6-base-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \
qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev \
qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev \
ca-certificates curl dpkg-dev file fakeroot \
&& rm -rf /var/lib/apt/lists/*
# The ONNX Runtime the packages carry: Microsoft's own build.
+2 -2
View File
@@ -14,7 +14,7 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
- Layer effects: stroke, drop shadow, color overlay, inner shadow
- Filters: Gaussian and motion blur, noise, lens correction, Content-Aware Fill and Remove Background (U²-Net on ONNX Runtime, offline)
- Crop, canvas size, image size; PNG and JPEG export
- Imports JPEG, PNG, TIFF and HEIC
- Imports JPEG, PNG, TIFF, HEIC, Photoshop files and camera RAW
## Install
@@ -64,7 +64,7 @@ scripts/dev.sh run # build, then the app on your Wayland display
`run` gives the container your home folder at its own path, so the app opens and saves your files and reads your Omarchy theme, and the GPU's render nodes (`/dev/dri/renderD*`), which Mesa's EGL wants. Files outside your home folder stay out of reach.
By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, fontconfig and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04).
By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, LibRaw, fontconfig and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04).
## Licence
+5 -1
View File
@@ -330,7 +330,11 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.5b round 1: FAIL, two findings. The notes TASKS.md 12.0 assigned here were unwritten: Swift agrees since 1.2.0 on Levels' soft edges (`f6a74c9`), effects through snapshots (`5775dd1`, `6230e41`), Color Dodge and Burn in sRGB (`121a15f`), M and L (`b5cb4fa`) and Select All (`90ee29f`), each note now saying so; the signal record lacked `smoothing` (read now).
- 12.5b round 2: FAIL, two findings. Select ▸ All rested without a canvas where Swift 1.2.0's never rests (`90ee29f`): the entry is always enabled now and selects a focused field's text (tested); the 9.4a note on Swift's Gaussian test was stale since `7c4512a` (says so now). With them, as `7c4512a` did, the invert test lost its wall-clock budget, which measured the parallel suite's load.
- 12.5b round 3: FAIL, one finding, the third and last round. With the layer list focused, Swift 1.2.0's Select All reaches its table, which selects every row with the top one primary; the twin selected the canvas. Fixed after the round: a focused list takes Select All (`NativeLayerList::selectAllRows`), tested, its two mutants caught (`mut-12.5b-r3.log`); the item closes on that, the review's three rounds spent.
- [ ] 12.6 Version 1.2.1: camera RAW import through LibRaw with its develop step, middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`.
- [x] 12.6a Version 1.2.1, part one: camera RAW import through LibRaw with its develop sheet.
- 12.6a round 1: FAIL, seven findings. The cached LibRaw read its own last multipliers and halved size (the camera's now come from unpack's copy, `rawdata`); the tint's sign was the inverse of Adobe's (positive is a greener white now, D65 reads +9.5); `adjust_maximum_thr` stretched frames near white (off); a white the camera cannot see was dropped by LibRaw in silence (refused); dcraw's normalization by the smallest multiplier moved brightness with temperature (anchored on green); a test comment was wrong.
- 12.6a round 2: FAIL, two findings. Unclipped highlights (round 1's anchor through `highlight` 1) turned magenta on real cameras: LibRaw clips again and the twin multiplies the smallest multiplier back, a clipped frame tested white; the refused white said Swift's words for a wrong file type: it is `ImageImportError(whiteBalance)` with words of its own.
- 12.6a round 3: PASS. One wording note taken (the frame's white at the slider's ends).
- [ ] 12.6b Version 1.2.1, part two: middle-button pan, persistent tool settings, Auto Select picking the foreground layer, the reshaped app icon (`45ab8fd`) in `packaging/icons`.
- [ ] 12.7 Version 1.2.2: Photoshop's remaining blend modes in its groups; Black & White, Color Balance and Invert adjustments.
- [ ] 12.8a Version 1.2.3, part one: the Camera Raw filter's model and pixel kernels.
- [ ] 12.8b Version 1.2.3, part two: the Camera Raw panel beside the canvas, its grading wheels turning the way their hue runs (1.2.9's `96edd5f`).
+1 -1
View File
@@ -20,7 +20,7 @@
filter = path: type: path != "${toString self}/build";
};
nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ];
buildInputs = with pkgs; [ qt6.qtbase qt6.qtimageformats libheif libde265 fontconfig onnxruntime ];
buildInputs = with pkgs; [ qt6.qtbase qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ];
cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ];
ninjaFlags = [ "omaphoto" ];
# Staged: profiles build MIME caches outside the store.
+1 -1
View File
@@ -6,7 +6,7 @@ pkgdesc='Layered image editing for Linux, a port of Compositor'
arch=('x86_64')
url='https://github.com/ZacharyZhang-NY/OmaPhoto'
license=('MIT')
depends=('qt6-base' 'qt6-imageformats' 'libheif' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info')
depends=('qt6-base' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info')
makedepends=('cmake' 'ninja')
# ONNX Runtime ships inside: Arch's changes its symbols each release.
source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx'
@@ -7,5 +7,5 @@ Exec=omaphoto %F
Icon=io.github.ZacharyZhang_NY.OmaPhoto
Terminal=false
Categories=Graphics;2DGraphics;RasterGraphics;
MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;
MimeType=application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-dcraw;
StartupWMClass=OmaPhoto
+1 -1
View File
@@ -1,6 +1,6 @@
# Arch and Omarchy: Qt and GCC as shipped.
FROM archlinux:latest
RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 \
RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 libraw \
fontconfig ttf-dejavu curl \
&& pacman -Scc --noconfirm
# The ONNX Runtime the packages carry: Microsoft's own build.
+1 -1
View File
@@ -1,7 +1,7 @@
# Fedora 43, the oldest still supported; HEVC from RPM Fusion.
FROM fedora:43
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-43.noarch.rpm \
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel libheif-freeworld \
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel LibRaw-devel libheif-freeworld \
fontconfig-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \
&& dnf clean all
# The ONNX Runtime the packages carry: Microsoft's own build.
+29 -1
View File
@@ -4,6 +4,7 @@
#include "UI/FilterSheet.h"
#include "UI/HueSaturationSheet.h"
#include "UI/LevelsSheet.h"
#include "UI/RawDevelopSheet.h"
#include <QFileDialog>
#include <QVBoxLayout>
#include <QMessageBox>
@@ -85,7 +86,7 @@ void ContentView::showImporter()
m_importer = new QFileDialog(this);
m_importer->setAttribute(Qt::WA_DeleteOnClose);
m_importer->setFileMode(QFileDialog::ExistingFiles);
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd)"));
m_importer->setNameFilter(QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd %1)").arg(RawImporter::globs().join(' ')));
connect(m_importer, &QDialog::finished, this, [this, panel = m_importer.data()](int result) {
// Ours no longer: the flag must not reject it again.
if (m_importer != panel)
@@ -160,3 +161,30 @@ void ContentView::showConversionSheet()
m_conversionSheet->layout()->addWidget(new PSDConversionSheet(*request, [this](bool confirmed) { m_session.finishConversion(confirmed); }, m_conversionSheet));
m_conversionSheet->open();
}
void ContentView::showRawDevelopSheet()
{
const std::optional<RawDevelopRequest> &request = m_session.rawDevelop();
if (!request) {
// Ours no longer, so the close answers nothing.
if (QDialog *shown = std::exchange(m_rawSheet, nullptr))
shown->reject();
return;
}
if (m_rawSheet)
return;
m_rawSheet = new QDialog(this);
m_rawSheet->setObjectName(QStringLiteral("rawDevelopSheet"));
m_rawSheet->setAttribute(Qt::WA_DeleteOnClose);
m_rawSheet->setWindowTitle(QStringLiteral("Develop RAW"));
auto *layout = new QVBoxLayout(m_rawSheet);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSizeConstraint(QLayout::SetFixedSize);
// Escape and the close button cancel, as Swift's dismissal.
connect(m_rawSheet, &QDialog::finished, this, [this, shown = m_rawSheet.data()] {
if (m_rawSheet == shown)
m_session.finishRawDevelop(std::nullopt);
});
layout->addWidget(new RawDevelopSheet(*request, [this](std::optional<RawDevelopSettings> settings) { m_session.finishRawDevelop(settings); }, m_rawSheet));
m_rawSheet->open();
}
+1
View File
@@ -399,6 +399,7 @@ void ContentView::synchronize()
: hint(m_session.tool(), iconKind(m_session.tool()), m_session.blurMode(), m_session.shapeKind()));
showImporter();
showConversionSheet();
showRawDevelopSheet();
showAlert(m_importAlert, QStringLiteral("Import couldn’t finish"), m_session.importError(), [this] { m_session.setImportError(std::nullopt); });
showAlert(m_brushAlert, QStringLiteral("Couldn’t paint"), m_session.brushError(), [this] { m_session.setBrushError(std::nullopt); });
showAlert(m_cropAlert, QStringLiteral("Couldn’t crop"), m_session.cropError(), [this] { m_session.setCropError(std::nullopt); });
+2
View File
@@ -53,6 +53,7 @@ private:
// Swift's onChange of levels: its panel opens and closes.
void synchronizePanels();
void showConversionSheet();
void showRawDevelopSheet();
ToolIconKind iconKind(NavigationTool tool) const;
void showHeader(NavigationTool tool);
void showWelcome(bool shown);
@@ -86,6 +87,7 @@ private:
// The Photoshop sheet and the request it shows.
QPointer<QDialog> m_conversionSheet;
std::optional<PSDConversionRequest> m_conversionShown;
QPointer<QDialog> m_rawSheet;
QPointer<QMessageBox> m_importAlert;
QPointer<QMessageBox> m_brushAlert;
QPointer<QMessageBox> m_cropAlert;
+57
View File
@@ -1,6 +1,8 @@
#include "Document/EditorSession.h"
#include "Document/ProjectWorkspace.h"
#include "Document/PixelAdjust.h"
#include "IO/PSD/PSDReader.h"
#include "IO/RawImporter.h"
#include "Logging.h"
#include <QtConcurrent>
@@ -105,6 +107,10 @@ void EditorSession::decodeNext()
}
}
const QUrl url = request.urls[m_importIndex];
if (url.isLocalFile() && RawImporter::matches(url.toLocalFile())) {
decodeRaw(url.toLocalFile(), 100'000'000 - usedPixels);
return;
}
if (url.isLocalFile() && PSDReader::matches(url.toLocalFile())) {
beginPSDReading(QStringLiteral("Open “%1”?").arg(url.fileName()), QStringLiteral("Import"));
m_photoshopReader.setFuture(QtConcurrent::run([path = url.toLocalFile(), remaining = 100'000'000 - usedPixels]() -> PhotoshopRead {
@@ -133,6 +139,57 @@ void EditorSession::decodeNext()
}));
}
void EditorSession::decodeRaw(const QString &path, qint64 remaining)
{
const QString name = QFileInfo(path).fileName();
const std::optional<QSize> size = RawImporter::pixelSize(path);
const auto refuse = [&](ImageImportError::Kind kind) {
m_importFailures << name + ": " + ImageImportError(kind).what();
m_importIndex += 1;
decodeNext();
};
if (!size)
return refuse(ImageImportError::Kind::unreadable);
if (size->width() > 30'000 || size->height() > 30'000 || qint64(size->width()) * size->height() > remaining)
return refuse(ImageImportError::Kind::tooLarge);
developRaw(path, [this, path, name](std::optional<RawDevelopSettings> settings) {
if (!settings) {
m_importIndex += 1;
decodeNext();
return;
}
// Seconds of work: a worker, so the window keeps drawing.
m_decoder.setFuture(QtConcurrent::run([path, name, settings = *settings]() -> Decoded {
try {
const QImage developed = RawImporter::Queue::shared().develop(path, settings, std::nullopt);
return {ImportedImage(developed, PixelAdjust::thumbnail(developed), ProjectTab::nameWithoutSuffix(path)), QString()};
} catch (const ImageImportError &error) {
return {std::nullopt, name + ": " + error.what()};
} catch (const ExportError &error) {
return {std::nullopt, name + ": " + error.what()};
}
}));
});
}
void EditorSession::developRaw(const QString &path, std::function<void(std::optional<RawDevelopSettings>)> answer)
{
m_rawAnswer = std::move(answer);
m_rawDevelop = RawDevelopRequest{path, RawImporter::asShot(path).value_or(RawDevelopSettings())};
notify();
}
void EditorSession::finishRawDevelop(std::optional<RawDevelopSettings> settings)
{
m_rawDevelop = std::nullopt;
notify();
// Frees the preview's decode on a worker, behind any preview.
(void)QtConcurrent::run([] { RawImporter::Queue::shared().release(); });
// The import resumes from the event loop, as a task.
if (const auto answer = std::exchange(m_rawAnswer, {}))
QMetaObject::invokeMethod(this, [answer, settings] { answer(settings); }, Qt::QueuedConnection);
}
void EditorSession::finishDecode()
{
const Decoded decoded = m_decoder.result();
+8
View File
@@ -17,6 +17,7 @@
#include "Document/EditorSession+Model.h"
#include "IO/ProjectStore.h"
#include "IO/PSD/PSDDocumentBuilder.h"
#include "IO/RawImporter.h"
#include "UI/PSDConversionSheet.h"
#include "Rendering/CanvasViewport.h"
#include "Rendering/EffectsPreviewCache.h"
@@ -70,6 +71,9 @@ public:
void setImportError(std::optional<QString> error);
bool showsConversionSheet() const { return m_showsConversionSheet; }
const std::optional<PSDConversionRequest> &conversionRequest() const { return m_conversionRequest; }
// The RAW file the develop sheet shows, while it shows.
const std::optional<RawDevelopRequest> &rawDevelop() const { return m_rawDevelop; }
void finishRawDevelop(std::optional<RawDevelopSettings> settings);
std::optional<QUuid> renamingLayerID() const { return m_renamingLayerID; }
void setRenamingLayerID(std::optional<QUuid> id);
@@ -242,6 +246,8 @@ public:
void finishPSDReading(const std::vector<PSDConversion> &conversions, std::function<void(bool)> answer);
void endPSDReading();
void finishConversion(bool confirmed);
void decodeRaw(const QString &path, qint64 remaining);
void developRaw(const QString &path, std::function<void(std::optional<RawDevelopSettings>)> answer);
void insertPhotoshop(const PSDImport &imported, const QString &named, std::optional<QPointF> centeredAt = std::nullopt);
void createDocument(int width, int height, bool emptyLayer = false);
void fit();
@@ -386,6 +392,8 @@ private:
bool m_showsConversionSheet = false;
std::optional<PSDConversionRequest> m_conversionRequest;
std::function<void(bool)> m_conversionAnswer;
std::optional<RawDevelopRequest> m_rawDevelop;
std::function<void(std::optional<RawDevelopSettings>)> m_rawAnswer;
// Cancel pressed while the file was still read.
bool m_conversionCancelled = false;
std::optional<QString> m_brushError;
+5 -1
View File
@@ -1,5 +1,6 @@
#include "IO/ImageFileDrop.h"
#include "Document/ProjectWorkspace.h"
#include "IO/RawImporter.h"
#include "Logging.h"
#include <QDir>
#include <QFile>
@@ -7,7 +8,7 @@
#include <algorithm>
namespace {
// Swift's order: PNG, JPEG, HEIC, TIFF, Photoshop, then any picture.
// Swift's order: PNG, JPEG, HEIC, TIFF, Photoshop, RAW, then any.
QStringList pictureFormats(const QMimeData &data)
{
QStringList formats;
@@ -15,6 +16,9 @@ QStringList pictureFormats(const QMimeData &data)
QStringLiteral("image/vnd.adobe.photoshop")})
if (data.hasFormat(format))
formats << format;
for (const QString &format : data.formats())
if (RawImporter::isRawType(format) && !formats.contains(format))
formats << format;
for (const QString &format : data.formats())
if (format.startsWith(QLatin1String("image/")) && !formats.contains(format))
formats << format;
+2
View File
@@ -21,6 +21,8 @@ QString description(ImageImportError::Kind kind)
return QStringLiteral("Choose a JPEG, PNG, HEIC, TIFF, or Photoshop (PSD) file.");
case ImageImportError::Kind::tooLarge:
return QStringLiteral("This import exceeds the current 100-megapixel document budget or 30,000-pixel side limit.");
case ImageImportError::Kind::whiteBalance:
return QStringLiteral("This camera can’t record that white balance. Move Temperature or Tint back toward the shot.");
}
Q_UNREACHABLE();
}
+2 -1
View File
@@ -38,7 +38,8 @@ private:
class ImageImportError : public std::runtime_error {
public:
enum class Kind { unreadable, unsupported, tooLarge };
// whiteBalance is Linux's: LibRaw cannot take every white.
enum class Kind { unreadable, unsupported, tooLarge, whiteBalance };
explicit ImageImportError(Kind kind);
Kind kind;
};
+336
View File
@@ -0,0 +1,336 @@
#include "IO/RawImporter.h"
#include "IO/ImageExporter.h"
#include "IO/ImageImporter.h"
#include "Logging.h"
#include "Rendering/PoolMap.h"
#include <QColorSpace>
#include <QFile>
#include <QMimeDatabase>
#include <algorithm>
#include <array>
#include <cmath>
#include <numeric>
#include <libraw.h>
namespace {
[[noreturn]] void refuse(const QString &path, const char *step, int code)
{
qCWarning(lcIO).noquote() << "cannot develop" << path << step << libraw_strerror(code);
throw ImageImportError(ImageImportError::Kind::unreadable);
}
// The file opened and identified; unpacked when asked.
std::shared_ptr<LibRaw> opened(const QString &path, bool unpack)
{
auto raw = std::make_shared<LibRaw>();
if (const int code = raw->open_file(QFile::encodeName(path).constData()); code != LIBRAW_SUCCESS)
refuse(path, "open", code);
if (unpack)
if (const int code = raw->unpack(); code != LIBRAW_SUCCESS)
refuse(path, "unpack", code);
return raw;
}
QPointF uv(QPointF xy)
{
const double d = -2 * xy.x() + 12 * xy.y() + 3;
return QPointF(4 * xy.x() / d, 6 * xy.y() / d);
}
QPointF xyOf(QPointF uv)
{
const double d = 2 * uv.x() - 8 * uv.y() + 4;
return QPointF(3 * uv.x() / d, 2 * uv.y() / d);
}
// The locus's unit normal in uv, pointing greener.
QPointF normal(double kelvin)
{
const QPointF tangent = uv(RawImporter::planckian(kelvin + 1)) - uv(RawImporter::planckian(kelvin - 1));
const double length = std::hypot(tangent.x(), tangent.y());
QPointF up(-tangent.y() / length, tangent.x() / length);
return up.y() < 0 ? -up : up;
}
using Matrix = std::array<std::array<double, 3>, 3>;
// XYZ and linear sRGB under D65, both ways.
constexpr Matrix xyzToSrgb{{{3.2404542, -1.5371385, -0.4985314}, {-0.9692660, 1.8760108, 0.0415560}, {0.0556434, -0.2040259, 1.0572252}}};
constexpr Matrix srgbToXyz{{{0.4124564, 0.3575761, 0.1804375}, {0.2126729, 0.7151522, 0.0721750}, {0.0193339, 0.1191920, 0.9503041}}};
std::array<double, 3> times(const Matrix &m, const std::array<double, 3> &v)
{
std::array<double, 3> out{};
for (size_t row = 0; row < 3; ++row)
out[row] = m[row][0] * v[0] + m[row][1] * v[1] + m[row][2] * v[2];
return out;
}
Matrix inverse(const Matrix &m)
{
const double det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
Matrix out;
for (size_t i = 0; i < 3; ++i)
for (size_t j = 0; j < 3; ++j) {
const size_t r0 = (j + 1) % 3, r1 = (j + 2) % 3, c0 = (i + 1) % 3, c1 = (i + 2) % 3;
out[i][j] = (m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]) / det;
}
return out;
}
// LibRaw's camera → sRGB matrix, filled for every camera.
Matrix rgbCam(const libraw_colordata_t &color)
{
Matrix m;
for (size_t row = 0; row < 3; ++row)
for (size_t column = 0; column < 3; ++column)
m[row][column] = color.rgb_cam[row][column];
return m;
}
// The raw camera response to a white: dcraw's matrices undone.
std::array<double, 3> response(const libraw_colordata_t &color, QPointF xy)
{
const std::array<double, 3> xyz{xy.x() / xy.y(), 1, (1 - xy.x() - xy.y()) / xy.y()};
std::array<double, 3> camera = times(inverse(rgbCam(color)), times(xyzToSrgb, xyz));
for (size_t channel = 0; channel < 3; ++channel)
camera[channel] /= color.pre_mul[channel];
return camera;
}
// The white a raw camera neutral stands for.
QPointF whiteOf(const libraw_colordata_t &color, std::array<double, 3> neutral)
{
for (size_t channel = 0; channel < 3; ++channel)
neutral[channel] *= color.pre_mul[channel];
const std::array<double, 3> xyz = times(srgbToXyz, times(rgbCam(color), neutral));
const double sum = xyz[0] + xyz[1] + xyz[2];
return QPointF(xyz[0] / sum, xyz[1] / sum);
}
// Three colours only: dcraw's matrices hold no fourth to invert.
void requireThreeColours(const LibRaw &raw, const QString &path)
{
if (raw.imgdata.idata.colors == 3)
return;
qCWarning(lcIO).noquote() << "cannot develop" << path << "a sensor of" << raw.imgdata.idata.colors << "colours";
throw ImageImportError(ImageImportError::Kind::unsupported);
}
// Green-anchored multipliers; the smallest, which LibRaw divides out.
double setWhite(LibRaw &raw, const QString &path, const RawDevelopSettings &settings)
{
// A process overwrites `color`; unpack's copy stays the camera's.
const std::array<double, 3> camera = response(raw.imgdata.rawdata.color, RawImporter::whitePoint(settings.temperature, settings.tint));
if (*std::min_element(camera.begin(), camera.end()) <= 0) {
qCWarning(lcIO).noquote() << "cannot develop" << path << "at" << settings.temperature << "K, tint" << settings.tint << ": the camera sees no such white";
throw ImageImportError(ImageImportError::Kind::whiteBalance);
}
float *const multipliers = raw.imgdata.params.user_mul;
for (size_t channel = 0; channel < 3; ++channel)
multipliers[channel] = float(camera[1] / camera[channel]);
return *std::min_element(multipliers, multipliers + 3);
}
double srgb(double linear)
{
return linear <= 0.0031308 ? 12.92 * linear : 1.055 * std::pow(linear, 1 / 2.4) - 0.055;
}
// LibRaw's default curve: BT.709, gamma 0.45 and slope 4.5.
double bt709(double linear)
{
return linear < 0.018 ? 4.5 * linear : 1.099 * std::pow(linear, 0.45) - 0.099;
}
QImage render(LibRaw &raw, const QString &path, const RawDevelopSettings &settings, std::optional<double> limit)
{
requireThreeColours(raw, path);
libraw_output_params_t &params = raw.imgdata.params;
// Linear sRGB in 16 bits: a power of one.
params.output_color = 1;
params.output_bps = 16;
params.gamm[0] = 1;
params.no_auto_bright = 1;
params.adjust_maximum_thr = 0;
params.use_camera_wb = 0;
params.use_auto_wb = 0;
// LibRaw clips at white in camera space: highlights stay neutral.
const double smallest = setWhite(raw, path, settings);
// A half-size process leaves `sizes` halved; unpack's copy stays.
const int longest = std::max(raw.imgdata.rawdata.sizes.width, raw.imgdata.rawdata.sizes.height);
params.half_size = limit && longest > *limit ? 1 : 0;
if (const int code = raw.dcraw_process(); code != LIBRAW_SUCCESS)
refuse(path, "process", code);
int code = LIBRAW_SUCCESS;
const std::unique_ptr<libraw_processed_image_t, void (*)(libraw_processed_image_t *)> made(raw.dcraw_make_mem_image(&code), LibRaw::dcraw_clear_mem);
if (!made)
refuse(path, "output", code);
QImage image(made->width, made->height, QImage::Format_RGBA8888_Premultiplied);
if (image.isNull())
throw ExportError(ExportError::Kind::render);
image.setColorSpace(QColorSpace::SRgb);
const double gain = std::pow(2.0, double(settings.exposure)) * smallest;
const auto *samples = reinterpret_cast<const quint16 *>(made->data);
uchar *const bits = image.bits();
const qsizetype stride = image.bytesPerLine();
std::vector<int> rows(size_t(image.height()));
std::iota(rows.begin(), rows.end(), 0);
PoolMap::blocking(rows, [&](int y) {
for (int x = 0; x < image.width(); ++x) {
uchar *out = bits + y * stride + x * 4;
for (int channel = 0; channel < 3; ++channel) {
const double linear = std::min(samples[(qsizetype(y) * image.width() + x) * 3 + channel] / 65535.0 * gain, 1.0);
const double flat = srgb(linear);
out[channel] = uchar(std::lround(255 * (flat + settings.boost * (bt709(linear) - flat))));
}
out[3] = 255;
}
});
if (!limit || std::max(image.width(), image.height()) <= *limit)
return image;
const QImage fitted = image.scaled(QSize(int(*limit), int(*limit)), Qt::KeepAspectRatio, Qt::SmoothTransformation);
if (fitted.isNull())
throw ExportError(ExportError::Kind::render);
return fitted;
}
}
bool RawDevelopSettings::isAsShot() const
{
return exposure == 0 && boost == 1 && temperature == asShotTemperature && tint == asShotTint;
}
void RawDevelopSettings::reset()
{
exposure = 0;
boost = 1;
temperature = asShotTemperature;
tint = asShotTint;
}
bool RawImporter::matches(const QString &path)
{
return QMimeDatabase().mimeTypeForFile(path, QMimeDatabase::MatchExtension).inherits(QStringLiteral("image/x-dcraw"));
}
bool RawImporter::isRawType(const QString &mimeName)
{
return QMimeDatabase().mimeTypeForName(mimeName).inherits(QStringLiteral("image/x-dcraw"));
}
QStringList RawImporter::globs()
{
QStringList globs;
for (const QMimeType &type : QMimeDatabase().allMimeTypes())
if (type.inherits(QStringLiteral("image/x-dcraw")))
globs << type.globPatterns();
globs.sort();
return globs;
}
QPointF RawImporter::planckian(double kelvin)
{
const double t = std::clamp(kelvin, 1667.0, 25000.0);
const double x = t <= 4000 ? -0.2661239e9 / (t * t * t) - 0.2343589e6 / (t * t) + 0.8776956e3 / t + 0.179910
: -3.0258469e9 / (t * t * t) + 2.1070379e6 / (t * t) + 0.2226347e3 / t + 0.240390;
const double y = t <= 2222 ? -1.1063814 * x * x * x - 1.34811020 * x * x + 2.18555832 * x - 0.20219683
: t <= 4000 ? -0.9549476 * x * x * x - 1.37418593 * x * x + 2.09137015 * x - 0.16748867
: 3.0817580 * x * x * x - 5.87338670 * x * x + 3.75112997 * x - 0.37001483;
return QPointF(x, y);
}
QPointF RawImporter::whitePoint(double kelvin, double tint)
{
// Adobe's tint: 3000 per Duv, positive a greener white.
return xyOf(uv(planckian(kelvin)) + normal(kelvin) * (tint / 3000));
}
std::pair<double, double> RawImporter::temperatureOf(QPointF xy)
{
const QPointF target = uv(xy);
const auto distance = [&](double mired) {
const QPointF on = uv(planckian(1e6 / mired));
return std::hypot(target.x() - on.x(), target.y() - on.y());
};
// Coarse steps in mireds, then golden sections round the best.
double best = 40;
for (double mired = 40; mired <= 600; mired += 1)
if (distance(mired) < distance(best))
best = mired;
double low = best - 1, high = best + 1;
const double ratio = (std::sqrt(5.0) - 1) / 2;
for (int step = 0; step < 60; ++step) {
const double a = high - ratio * (high - low), b = low + ratio * (high - low);
if (distance(a) < distance(b))
high = b;
else
low = a;
}
const double kelvin = 1e6 / ((low + high) / 2);
const QPointF offset = target - uv(planckian(kelvin));
const QPointF up = normal(kelvin);
return {kelvin, 3000 * (offset.x() * up.x() + offset.y() * up.y())};
}
std::optional<RawDevelopSettings> RawImporter::asShot(const QString &path)
{
try {
const std::shared_ptr<LibRaw> raw = opened(path, false);
const libraw_colordata_t &color = raw->imgdata.color;
requireThreeColours(*raw, path);
// Without the camera's reading, dcraw's daylight.
const float *shot = color.cam_mul[0] > 0 ? color.cam_mul : color.pre_mul;
const auto [kelvin, tint] = temperatureOf(whiteOf(color, {1.0 / shot[0], 1.0 / shot[1], 1.0 / shot[2]}));
RawDevelopSettings settings;
settings.temperature = settings.asShotTemperature = float(kelvin);
settings.tint = settings.asShotTint = float(tint);
return settings;
} catch (const ImageImportError &) {
return std::nullopt;
}
}
QImage RawImporter::develop(const QString &path, const RawDevelopSettings &settings, std::optional<double> limit)
{
const std::shared_ptr<LibRaw> raw = opened(path, true);
return render(*raw, path, settings, limit);
}
std::optional<QSize> RawImporter::pixelSize(const QString &path)
{
try {
const std::shared_ptr<LibRaw> raw = opened(path, false);
// It fails only when called before the file is identified.
raw->adjust_sizes_info_only();
return QSize(raw->imgdata.sizes.iwidth, raw->imgdata.sizes.iheight);
} catch (const ImageImportError &) {
return std::nullopt;
}
}
RawImporter::Queue &RawImporter::Queue::shared()
{
static Queue queue;
return queue;
}
QImage RawImporter::Queue::develop(const QString &path, const RawDevelopSettings &settings, std::optional<double> limit)
{
const std::lock_guard lock(m_mutex);
if (!limit)
return RawImporter::develop(path, settings, std::nullopt);
// One file's unpacked frame is kept; a move only processes.
if (!m_cached || m_path != path) {
m_cached = opened(path, true);
m_path = path;
}
return render(*m_cached, path, settings, limit);
}
void RawImporter::Queue::release()
{
const std::lock_guard lock(m_mutex);
m_cached.reset();
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <QImage>
#include <QPointF>
#include <QSize>
#include <QString>
#include <QStringList>
#include <memory>
#include <mutex>
#include <optional>
// What the camera recorded, before the look was decided.
struct RawDevelopSettings {
// Stops of exposure either side of what was recorded.
float exposure = 0;
// White balance in Kelvin, from the camera's own reading.
float temperature = 5000;
// Green–magenta balance, from the camera's own reading.
float tint = 0;
// The tone curve: 1 LibRaw's full curve, 0 flat.
float boost = 1;
// The camera's own choice, where Reset returns.
float asShotTemperature = 5000;
float asShotTint = 0;
bool isAsShot() const;
void reset();
friend bool operator==(const RawDevelopSettings &, const RawDevelopSettings &) = default;
};
// Swift's tuple: the file under the sheet, where it starts.
struct RawDevelopRequest {
QString path;
RawDevelopSettings settings;
};
class LibRaw;
// Swift's RawImporter: camera RAW developed through LibRaw.
namespace RawImporter {
// Every camera RAW the system names, by extension.
bool matches(const QString &path);
// The same by MIME name, for dropped pictures.
bool isRawType(const QString &mimeName);
// Their file patterns, for the importer's filter.
QStringList globs();
// The camera's white balance, where the sliders start.
std::optional<RawDevelopSettings> asShot(const QString &path);
// The developed image; `limit` caps the preview's long edge.
QImage develop(const QString &path, const RawDevelopSettings &settings, std::optional<double> limit = std::nullopt);
// The frame's size, oriented, without developing it.
std::optional<QSize> pixelSize(const QString &path);
// Planckian chromaticity, Kim et al., 1667–25000 K.
QPointF planckian(double kelvin);
// A white of this temperature and tint, CIE xy.
QPointF whitePoint(double kelvin, double tint);
// The temperature and tint of a white, the inverse.
std::pair<double, double> temperatureOf(QPointF xy);
// Swift's actor: one develop at a time, decode kept.
class Queue {
public:
static Queue &shared();
QImage develop(const QString &path, const RawDevelopSettings &settings, std::optional<double> limit);
// Lets go of the decoded frame when the sheet closes.
void release();
private:
std::mutex m_mutex;
QString m_path;
std::shared_ptr<LibRaw> m_cached;
};
}
+188
View File
@@ -0,0 +1,188 @@
#include "UI/RawDevelopSheet.h"
#include "IO/ImageExporter.h"
#include "IO/ImageImporter.h"
#include "Logging.h"
#include <QFileInfo>
#include <QFutureWatcher>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QProgressBar>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSlider>
#include <QTimer>
#include <QVBoxLayout>
#include <QtConcurrent>
#include <cmath>
// Swift's ZStack: 35% black, the fitted preview, rounded 6.
class RawDevelopSheet::Preview : public QWidget {
public:
using QWidget::QWidget;
QImage image;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
QPainterPath plate;
plate.addRoundedRect(QRectF(rect()), 6, 6);
painter.fillPath(plate, QColor::fromRgbF(0, 0, 0, 0.35f));
if (image.isNull())
return;
QRectF target(QPointF(), QSizeF(image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio));
target.moveCenter(QRectF(rect()).center());
QPainterPath clip;
clip.addRoundedRect(target, 6, 6);
painter.setClipPath(clip);
painter.drawImage(target, image);
}
};
RawDevelopSheet::RawDevelopSheet(RawDevelopRequest request, std::function<void(std::optional<RawDevelopSettings>)> finish, QWidget *parent)
: QWidget(parent), m_path(std::move(request.path)), m_finish(std::move(finish)), m_settings(request.settings), m_wait(new QTimer(this)),
m_preview(new Preview(this)), m_spinner(new QProgressBar(m_preview)), m_reset(new QPushButton(QStringLiteral("Reset"), this))
{
auto *column = new QVBoxLayout(this);
column->setContentsMargins(24, 24, 24, 24);
column->setSpacing(16);
column->setSizeConstraint(QLayout::SetFixedSize);
auto *title = new QLabel(QStringLiteral("Develop “%1”").arg(QFileInfo(m_path).fileName()), this);
title->setTextFormat(Qt::PlainText);
QFont font = title->font();
font.setPixelSize(17);
font.setBold(true);
title->setFont(font);
column->addWidget(title);
m_preview->setObjectName(QStringLiteral("rawPreview"));
m_preview->setFixedSize(560, 340);
m_spinner->setObjectName(QStringLiteral("rawSpinner"));
m_spinner->setRange(0, 0);
m_spinner->setTextVisible(false);
m_spinner->setGeometry(272, 162, 16, 16);
column->addWidget(m_preview);
// Swift's sliders are continuous; these step finer than shown.
addRow(QStringLiteral("Exposure"), &RawDevelopSettings::exposure, -3, 3, 100, 2, QStringLiteral(" EV"));
addRow(QStringLiteral("Temperature"), &RawDevelopSettings::temperature, 2000, 12000, 1, 0, QStringLiteral(" K"));
addRow(QStringLiteral("Tint"), &RawDevelopSettings::tint, -150, 150, 1, 0, QString());
addRow(QStringLiteral("Boost"), &RawDevelopSettings::boost, 0, 1, 1000, 2, QString());
for (const Row &row : m_rows) {
auto *line = new QHBoxLayout;
line->setSpacing(10);
auto *label = new QLabel(row.slider->accessibleName(), this);
label->setFixedWidth(90);
label->setBuddy(row.slider);
line->addWidget(label);
line->addWidget(row.slider);
line->addWidget(row.value);
column->addLayout(line);
}
auto *buttons = new QHBoxLayout;
m_reset->setObjectName(QStringLiteral("rawReset"));
m_reset->setAutoDefault(false);
connect(m_reset, &QPushButton::clicked, this, [this] {
m_settings.reset();
changed();
});
auto *cancel = new QPushButton(QStringLiteral("Cancel"), this);
cancel->setObjectName(QStringLiteral("rawCancel"));
cancel->setAutoDefault(false);
connect(cancel, &QPushButton::clicked, this, [this] { m_finish(std::nullopt); });
auto *importButton = new QPushButton(QStringLiteral("Import"), this);
importButton->setObjectName(QStringLiteral("rawImport"));
importButton->setDefault(true);
connect(importButton, &QPushButton::clicked, this, [this] { m_finish(m_settings); });
buttons->addWidget(m_reset);
buttons->addStretch(1);
buttons->addWidget(cancel);
buttons->addWidget(importButton);
column->addLayout(buttons);
// The first preview starts at once; slider bursts coalesce.
m_wait->setObjectName(QStringLiteral("rawWait"));
m_wait->setSingleShot(true);
m_wait->setTimerType(Qt::PreciseTimer);
connect(m_wait, &QTimer::timeout, this, &RawDevelopSheet::develop);
m_wait->setInterval(60);
QTimer::singleShot(0, this, &RawDevelopSheet::develop);
synchronize();
}
// SwiftUI cancels the task when the sheet goes.
RawDevelopSheet::~RawDevelopSheet()
{
if (m_cancelled)
m_cancelled->store(true);
}
void RawDevelopSheet::addRow(const QString &title, float RawDevelopSettings::*field, double low, double high, double scale, int precision, const QString &unit)
{
auto *slider = new QSlider(Qt::Horizontal, this);
slider->setObjectName(QStringLiteral("raw") + title);
slider->setAccessibleName(title);
slider->setFixedWidth(300);
slider->setRange(int(std::lround(low * scale)), int(std::lround(high * scale)));
auto *value = new QLabel(this);
value->setObjectName(slider->objectName() + QStringLiteral("Value"));
value->setFixedWidth(80);
value->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
value->setForegroundRole(QPalette::PlaceholderText);
connect(slider, &QSlider::valueChanged, this, [this, field, scale](int step) {
m_settings.*field = float(step / scale);
changed();
});
m_rows.push_back({slider, value, field, scale, precision, unit});
}
// Swift's onChange: a new revision, developed after 60 ms.
void RawDevelopSheet::changed()
{
if (m_cancelled)
m_cancelled->store(true);
m_wait->start();
synchronize();
}
void RawDevelopSheet::develop()
{
m_cancelled = std::make_shared<std::atomic_bool>(false);
m_spinner->show();
auto *watcher = new QFutureWatcher<QImage>(this);
connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, cancelled = m_cancelled] {
watcher->deleteLater();
if (cancelled->load())
return;
m_spinner->hide();
// A failed develop keeps the last preview, as Swift's.
if (const QImage image = watcher->future().takeResult(); !image.isNull()) {
m_preview->image = image;
m_preview->update();
}
});
watcher->setFuture(QtConcurrent::run([path = m_path, settings = m_settings] {
try {
return RawImporter::Queue::shared().develop(path, settings, 800);
} catch (const ImageImportError &error) {
qCWarning(lcIO) << "no RAW preview:" << error.what();
} catch (const ExportError &error) {
qCWarning(lcIO) << "no RAW preview:" << error.what();
}
return QImage();
}));
}
void RawDevelopSheet::synchronize()
{
for (const Row &row : m_rows) {
const QSignalBlocker blocker(row.slider);
const double value = m_settings.*row.field;
row.slider->setValue(int(std::lround(value * row.scale)));
row.value->setText(QString::asprintf("%.*f", row.precision, value) + row.unit);
}
m_reset->setEnabled(!m_settings.isAsShot());
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include "IO/RawImporter.h"
#include <QWidget>
#include <atomic>
#include <functional>
#include <memory>
#include <optional>
#include <vector>
class QLabel;
class QProgressBar;
class QPushButton;
class QSlider;
class QTimer;
// Swift's RawDevelopSheet: a screen-sized preview under four sliders.
class RawDevelopSheet : public QWidget {
Q_OBJECT
public:
RawDevelopSheet(RawDevelopRequest request, std::function<void(std::optional<RawDevelopSettings>)> finish, QWidget *parent = nullptr);
~RawDevelopSheet() override;
private:
class Preview;
struct Row {
QSlider *slider;
QLabel *value;
float RawDevelopSettings::*field;
double scale;
int precision;
QString unit;
};
void addRow(const QString &title, float RawDevelopSettings::*field, double low, double high, double scale, int precision, const QString &unit);
void changed();
void develop();
void synchronize();
const QString m_path;
const std::function<void(std::optional<RawDevelopSettings>)> m_finish;
RawDevelopSettings m_settings;
// The newest develop's flag, Swift's task cancellation.
std::shared_ptr<std::atomic_bool> m_cancelled;
QTimer *const m_wait;
Preview *const m_preview;
QProgressBar *const m_spinner;
std::vector<Row> m_rows;
QPushButton *const m_reset;
};
+2 -1
View File
@@ -1,4 +1,5 @@
#include "ContentView.h"
#include "IO/RawImporter.h"
#include "DialogDesk.h"
#include "UI/LayersPanel.h"
#include "UI/ToolHeaderStyle.h"
@@ -322,7 +323,7 @@ void ContentViewTests::theImporterPicksFilesAndClearsItsFlag()
QTest::qWait(50);
QCOMPARE(int(session.document().value().layers.size()), 2);
desk.typeBeforeCancel.clear();
const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd)";
const QString panel = "panel|Open|open|file|||1 many Images (*.jpg *.jpeg *.png *.heic *.tif *.tiff *.psd " + RawImporter::globs().join(" ") + ")";
QCOMPARE(desk.seen.mid(0, 2), (QStringList{panel, panel}));
QCOMPARE(desk.seen.size(), 3);
// The flag cleared from elsewhere takes the panel down.
+137
View File
@@ -0,0 +1,137 @@
#pragma once
#include <QByteArray>
#include <QSize>
#include <QFile>
#include <QString>
#include <QtEndian>
#include <array>
#include <cmath>
#include <functional>
#include <optional>
#include <stdexcept>
#include <vector>
// Tiny DNG files: an RGGB mosaic, a linear sRGB camera.
namespace DNGFixture {
struct Options {
int width = 64;
int height = 48;
// A photosite's value: 0 red, 1 green, 2 blue.
std::function<quint16(int channel, int x, int y)> value = [](int, int, int) { return quint16(20000); };
std::array<double, 3> asShotNeutral{1, 1, 1};
quint16 orientation = 1;
// A header's size past the pixels written, for budgets.
std::optional<QSize> claimed;
// Row-major: the camera's channels from sRGB's.
std::array<double, 9> mix{1, 0, 0, 0, 1, 0, 0, 0, 1};
// A fourth plane, the second green, as CMYG sensors have.
bool fourColours = false;
};
// XYZ (D65) to linear sRGB: the camera sees sRGB.
inline constexpr std::array<double, 9> colorMatrix{3.2404542, -1.5371385, -0.4985314, -0.9692660, 1.8760108, 0.0415560, 0.0556434, -0.2040259, 1.0572252};
struct Entry {
quint16 tag;
quint16 type;
quint32 count;
QByteArray data;
};
inline QByteArray le16(quint16 value)
{
QByteArray bytes(2, '\0');
qToLittleEndian(value, bytes.data());
return bytes;
}
inline QByteArray le32(quint32 value)
{
QByteArray bytes(4, '\0');
qToLittleEndian(value, bytes.data());
return bytes;
}
// Ten-thousandths; a signed numerator keeps its bits.
inline QByteArray rational(double value)
{
return le32(quint32(qint32(std::lround(value * 10000)))) + le32(10000);
}
inline QByteArray data(const Options &options)
{
QByteArray pixels;
for (int y = 0; y < options.height; ++y)
for (int x = 0; x < options.width; ++x) {
// RGGB: red on even rows and columns.
const int channel = y % 2 == 0 ? (x % 2 == 0 ? 0 : 1) : (x % 2 == 0 ? 1 : 2);
pixels += le16(options.value(channel, x, y));
}
QByteArray matrix, neutral;
const int planes = options.fourColours ? 4 : 3;
for (int row = 0; row < planes; ++row)
for (size_t column = 0; column < 3; ++column) {
const size_t from = size_t(row == 3 ? 1 : row) * 3;
matrix += rational(options.mix[from] * colorMatrix[column] + options.mix[from + 1] * colorMatrix[3 + column] + options.mix[from + 2] * colorMatrix[6 + column]);
}
for (int plane = 0; plane < planes; ++plane)
neutral += rational(options.asShotNeutral[size_t(plane == 3 ? 1 : plane)]);
const QByteArray model("OmaPhoto Fixture", 17);
std::vector<Entry> entries{
{254, 4, 1, le32(0)},
{256, 4, 1, le32(quint32(options.claimed.value_or(QSize(options.width, options.height)).width()))},
{257, 4, 1, le32(quint32(options.claimed.value_or(QSize(options.width, options.height)).height()))},
{258, 3, 1, le16(16) + le16(0)},
{259, 3, 1, le16(1) + le16(0)},
{262, 3, 1, le16(32803) + le16(0)},
{271, 2, 9, QByteArray("OmaPhoto", 9)},
{272, 2, 8, QByteArray("Fixture", 8)},
{273, 4, 1, QByteArray()},
{274, 3, 1, le16(options.orientation) + le16(0)},
{277, 3, 1, le16(1) + le16(0)},
{278, 4, 1, le32(quint32(options.height))},
{279, 4, 1, le32(quint32(pixels.size()))},
{284, 3, 1, le16(1) + le16(0)},
{33421, 3, 2, le16(2) + le16(2)},
{33422, 1, 4, options.fourColours ? QByteArray("\0\1\3\2", 4) : QByteArray("\0\1\1\2", 4)},
{50706, 1, 4, QByteArray("\1\4\0\0", 4)},
{50707, 1, 4, QByteArray("\1\1\0\0", 4)},
{50708, 2, quint32(model.size()), model},
{50710, 1, quint32(planes), QByteArray("\0\1\2\3", planes)},
{50714, 4, 1, le32(0)},
{50717, 4, 1, le32(65535)},
{50721, 10, quint32(planes * 3), matrix},
{50728, 5, quint32(planes), neutral},
{50778, 3, 1, le16(21) + le16(0)},
};
// Header, then the directory, its outside values, then the pixels.
const quint32 directory = 8, outside = directory + 2 + quint32(entries.size()) * 12 + 4;
QByteArray values;
for (Entry &entry : entries)
if (entry.data.size() > 4) {
if (values.size() % 2)
values += '\0';
const QByteArray stored = entry.data;
entry.data = le32(outside + quint32(values.size()));
values += stored;
}
if (values.size() % 2)
values += '\0';
const quint32 strip = outside + quint32(values.size());
QByteArray file = QByteArray("II*\0", 4) + le32(directory) + le16(quint16(entries.size()));
for (const Entry &entry : entries) {
const QByteArray inline_ = entry.tag == 273 ? le32(strip) : entry.data.leftJustified(4, '\0');
file += le16(entry.tag) + le16(entry.type) + le32(entry.count) + inline_;
}
file += le32(0) + values + pixels;
return file;
}
inline QString write(const QString &path, const Options &options = {})
{
QFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(data(options)) <= 0)
throw std::runtime_error("could not write a DNG");
return path;
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ void DesktopIntegrationTests::theEntryOpensSwiftsDocumentTypes()
QCOMPARE(keys.value("Terminal"), QString("false"));
QVERIFY(keys.value("Categories").split(QLatin1Char(';')).contains("Graphics"));
// Swift's two document types: its projects, then four pictures.
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;"));
QCOMPARE(keys.value("MimeType"), QString("application/x-compositor-project;image/png;image/jpeg;image/heic;image/tiff;image/vnd.adobe.photoshop;image/x-dcraw;"));
}
void DesktopIntegrationTests::aProjectFolderIsAType()
+17
View File
@@ -1,5 +1,6 @@
#include "ContentView.h"
#include "IO/ImageFileDrop.h"
#include "DNGFixture.h"
#include "PSDFixture.h"
#include "UI/ProjectTabs.h"
#include "UI/ProjectWorkspaceView.h"
@@ -148,6 +149,22 @@ void ImageFileDropTests::aPictureIsCopiedToAFileFirst()
QCOMPARE(names(opened), QStringList{"Sky"});
QCOMPARE(opened.history.undoName(), QString("Import Photoshop File"));
QCOMPARE(takeCopy(before), QString("Dropped.psd"));
// A camera RAW comes after Photoshop, before any other picture.
QMimeData raw;
raw.setData("image/gif", "any picture last");
raw.setData("image/x-adobe-dng", DNGFixture::data({}));
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/x-adobe-dng"});
raw.setData("image/vnd.adobe.photoshop", "photoshop first");
QCOMPARE(ImageFileDrop::providers(raw).front()->formats(), QStringList{"image/vnd.adobe.photoshop"});
raw.removeFormat("image/vnd.adobe.photoshop");
EditorSession developed;
done = false;
ImageFileDrop::importProviders(raw, developed, std::nullopt, nullptr, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(developed.rawDevelop());
developed.finishRawDevelop(developed.rawDevelop()->settings);
QTRY_VERIFY(done);
QCOMPARE(names(developed), QStringList{"Dropped"});
QCOMPARE(takeCopy(before), QString("Dropped.dng"));
}
void ImageFileDropTests::whatCannotBeReadIsSaid()
+283
View File
@@ -0,0 +1,283 @@
#include "ContentView.h"
#include "DNGFixture.h"
#include "IO/RawImporter.h"
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <QSlider>
#include <QTemporaryDir>
#include <QSignalSpy>
#include <QThreadPool>
#include <QTimer>
#include <QtTest>
// A camera RAW through the import and the develop sheet.
namespace {
QStringList names(const EditorSession &session)
{
QStringList listed;
for (const ImageLayer &each : session.document().value().layers)
listed << each.name;
return listed;
}
QUrl png(const QTemporaryDir &folder)
{
QImage image(4, 2, QImage::Format_RGBA8888);
image.fill(Qt::red);
const QString path = folder.filePath("Plain.png");
if (!image.save(path))
throw std::runtime_error("could not write a PNG");
return QUrl::fromLocalFile(path);
}
QUrl dng(const QTemporaryDir &folder, const QString &name, const DNGFixture::Options &options = {})
{
return QUrl::fromLocalFile(DNGFixture::write(folder.filePath(name), options));
}
QColor centre(const ImageLayer &layer)
{
const QImage image = layer.asset.value().image();
return image.pixelColor(image.width() / 2, image.height() / 2);
}
QColor centre(const QImage &image)
{
return image.pixelColor(image.width() / 2, image.height() / 2);
}
QString label(QWidget *sheet, const char *name)
{
return sheet->findChild<QLabel *>(name)->text();
}
}
class RawDevelopTests : public QObject {
Q_OBJECT
private slots:
void theSheetsSettingsDevelopTheLayer();
void cancelSkipsTheFileAndTheRestImport();
void whatCannotBeDevelopedIsReported();
void theLimitsLetTheirEdgeThrough();
void theSheetPreviewsAndAnswers();
};
void RawDevelopTests::theSheetsSettingsDevelopTheLayer()
{
const QTemporaryDir folder;
const QUrl raw = dng(folder, "Gray.dng");
EditorSession session;
bool done = false;
session.importImages({raw, png(folder)}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.rawDevelop());
QCOMPARE(session.rawDevelop()->path, raw.toLocalFile());
QVERIFY(session.rawDevelop()->settings == RawImporter::asShot(raw.toLocalFile()).value());
QVERIFY(session.isImporting() && !session.document());
RawDevelopSettings settings = session.rawDevelop()->settings;
settings.boost = 0;
settings.exposure = 1;
// The sheet hears at once that it has gone.
QSignalSpy changed(&session, &EditorSession::changed);
session.finishRawDevelop(settings);
QVERIFY(!session.rawDevelop() && changed.count() == 1);
QTRY_VERIFY(done);
QCOMPARE(names(session), (QStringList{"Gray", "Plain"}));
const ImageLayer &developed = session.document().value().layers.front();
QCOMPARE(developed.asset.value().size(), QSize(64, 48));
QCOMPARE(centre(developed), QColor(205, 205, 205));
QCOMPARE(developed.asset.value().thumbnail.size(), QSize(64, 48));
QCOMPARE(session.history.undoName(), QString("Import Images"));
QVERIFY(!session.importError());
}
void RawDevelopTests::cancelSkipsTheFileAndTheRestImport()
{
const QTemporaryDir folder;
EditorSession session;
bool done = false;
session.importImages({dng(folder, "Gray.dng"), png(folder)}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.rawDevelop());
session.finishRawDevelop(std::nullopt);
QTRY_VERIFY(done);
QCOMPARE(names(session), QStringList{"Plain"});
QVERIFY(!session.importError() && !session.rawDevelop());
}
void RawDevelopTests::whatCannotBeDevelopedIsReported()
{
const QTemporaryDir folder;
QFile fake(folder.filePath("Fake.dng"));
QVERIFY(fake.open(QIODevice::WriteOnly) && fake.write("not a raw") == 9);
fake.close();
DNGFixture::Options wide, tall, big;
wide.claimed = QSize(30'001, 48);
tall.claimed = QSize(64, 30'001);
big.claimed = QSize(20'000, 5'001);
DNGFixture::Options fits;
fits.claimed = QSize(20'000, 5'000);
const QUrl fitting = dng(folder, "Fits.dng", fits);
QCOMPARE(RawImporter::pixelSize(fitting.toLocalFile()), std::optional(QSize(20'000, 5'000)));
EditorSession session;
bool done = false;
const QList<QUrl> urls{QUrl::fromLocalFile(fake.fileName()), dng(folder, "Wide.dng", wide), dng(folder, "Tall.dng", tall), dng(folder, "Big.dng", big), png(folder)};
session.importImages(urls, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QVERIFY(!session.rawDevelop());
const QString unreadable = ImageImportError(ImageImportError::Kind::unreadable).what(), tooLarge = ImageImportError(ImageImportError::Kind::tooLarge).what();
QCOMPARE(session.importError().value(), QStringList({"Fake.dng: " + unreadable, "Wide.dng: " + tooLarge, "Tall.dng: " + tooLarge, "Big.dng: " + tooLarge}).join("\n\n"));
QCOMPARE(names(session), QStringList{"Plain"});
// The canvas's 4 by 2 pixels count against the budget.
session.setImportError(std::nullopt);
done = false;
session.importImages({fitting}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(done);
QCOMPARE(session.importError().value(), "Fits.dng: " + tooLarge);
// Accepted, then unreadable when the full develop runs.
session.setImportError(std::nullopt);
done = false;
const QUrl gray = dng(folder, "Gray.dng");
session.importImages({gray}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.rawDevelop());
QFile broken(gray.toLocalFile());
QVERIFY(broken.open(QIODevice::WriteOnly) && broken.write("gone") == 4);
broken.close();
session.finishRawDevelop(session.rawDevelop()->settings);
QTRY_VERIFY(done);
QCOMPARE(session.importError().value(), "Gray.dng: " + unreadable);
QCOMPARE(names(session), QStringList{"Plain"});
// A white the camera cannot record says so.
session.setImportError(std::nullopt);
done = false;
const QUrl warm = dng(folder, "Warm.dng");
session.importImages({warm}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.rawDevelop());
RawDevelopSettings candle = session.rawDevelop()->settings;
candle.temperature = 2000;
session.finishRawDevelop(candle);
QTRY_VERIFY(done);
QCOMPARE(session.importError().value(), "Warm.dng: " + QString(ImageImportError(ImageImportError::Kind::whiteBalance).what()));
QCOMPARE(names(session), QStringList{"Plain"});
}
void RawDevelopTests::theLimitsLetTheirEdgeThrough()
{
const QTemporaryDir folder;
for (const QSize claimed : {QSize(30'000, 48), QSize(64, 30'000), QSize(10'000, 10'000)}) {
DNGFixture::Options edge;
edge.claimed = claimed;
EditorSession session;
bool done = false;
session.importImages({dng(folder, "Edge.dng", edge)}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(session.rawDevelop());
session.finishRawDevelop(std::nullopt);
QTRY_VERIFY(done);
QVERIFY(!session.importError() && !session.document());
}
}
void RawDevelopTests::theSheetPreviewsAndAnswers()
{
const QTemporaryDir folder;
const QUrl raw = dng(folder, "Gray.dng");
const QString path = raw.toLocalFile();
EditorSession session;
ContentView view(session);
view.resize(900, 600);
view.show();
QVERIFY(QTest::qWaitForWindowExposed(&view));
bool done = false;
session.importImages({raw}, std::nullopt, [&done] { done = true; });
QDialog *sheet = view.findChild<QDialog *>("rawDevelopSheet");
QVERIFY(sheet);
// The first preview waits for nothing.
QTimer *wait = sheet->findChild<QTimer *>("rawWait");
QVERIFY(!wait->isActive());
// Another change keeps the one sheet.
session.notify();
QCOMPARE(view.findChildren<QDialog *>("rawDevelopSheet").size(), qsizetype(1));
QVERIFY(sheet->isVisible() && sheet->windowModality() == Qt::WindowModal);
QStringList words;
for (const QLabel *each : sheet->findChildren<QLabel *>())
words << each->text();
QVERIFY(words.contains("Develop “Gray.dng”"));
QCOMPARE(label(sheet, "rawExposureValue"), QString("0.00 EV"));
QCOMPARE(label(sheet, "rawTemperatureValue"), QString("6501 K"));
QCOMPARE(label(sheet, "rawTintValue"), QString("10"));
QCOMPARE(label(sheet, "rawBoostValue"), QString("1.00"));
QPushButton *reset = sheet->findChild<QPushButton *>("rawReset");
QVERIFY(!reset->isEnabled());
// The first preview lands: the boosted gray, fitted.
QWidget *preview = sheet->findChild<QWidget *>("rawPreview");
QTRY_VERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden());
QCOMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139));
// A develop under way spins; a newer change drops it.
QSlider *exposure = sheet->findChild<QSlider *>("rawExposure");
QProgressBar *spinner = sheet->findChild<QProgressBar *>("rawSpinner");
bool spinning = false;
const QMetaObject::Connection once = connect(wait, &QTimer::timeout, wait, [&] {
spinning = !spinner->isHidden();
exposure->setValue(-100);
wait->stop();
});
exposure->setValue(100);
QTRY_VERIFY(spinning);
disconnect(once);
QThreadPool::globalInstance()->waitForDone();
QTest::qWait(50);
QCOMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139));
QVERIFY(!spinner->isHidden());
RawDevelopSettings dimmer = session.rawDevelop()->settings;
dimmer.exposure = -1;
wait->start();
QTRY_COMPARE_WITH_TIMEOUT(preview->grab().toImage().pixelColor(200, 170), centre(RawImporter::develop(path, dimmer)), 1000);
QVERIFY(spinner->isHidden());
// A slider changes the settings; the preview follows.
exposure->setValue(100);
QCOMPARE(label(sheet, "rawExposureValue"), QString("1.00 EV"));
QVERIFY(reset->isEnabled());
RawDevelopSettings brighter = session.rawDevelop()->settings;
brighter.exposure = 1;
const QColor expected = centre(RawImporter::develop(path, brighter));
QTRY_COMPARE(preview->grab().toImage().pixelColor(200, 170), expected);
sheet->findChild<QSlider *>("rawTemperature")->setValue(3000);
QCOMPARE(label(sheet, "rawTemperatureValue"), QString("3000 K"));
reset->click();
QCOMPARE(label(sheet, "rawExposureValue"), QString("0.00 EV"));
QCOMPARE(label(sheet, "rawTemperatureValue"), QString("6501 K"));
QCOMPARE(sheet->findChild<QSlider *>("rawExposure")->value(), 0);
QVERIFY(!reset->isEnabled());
QTRY_COMPARE(preview->grab().toImage().pixelColor(200, 170), QColor(139, 139, 139));
// Import develops the whole frame with the sheet's settings.
sheet->findChild<QSlider *>("rawExposure")->setValue(100);
sheet->findChild<QPushButton *>("rawImport")->click();
QVERIFY(!session.rawDevelop());
QTRY_VERIFY(done);
QTRY_VERIFY(!view.findChild<QDialog *>("rawDevelopSheet"));
QCOMPARE(centre(session.document().value().layers.back()), expected);
// Escape cancels; the kept decode goes with the sheet.
done = false;
session.importImages({raw}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(view.findChild<QDialog *>("rawDevelopSheet"));
sheet = view.findChild<QDialog *>("rawDevelopSheet");
QTRY_VERIFY(sheet->findChild<QProgressBar *>("rawSpinner")->isHidden());
QTest::keyClick(sheet, Qt::Key_Escape);
QTRY_VERIFY(done);
QTRY_VERIFY(!view.findChild<QDialog *>("rawDevelopSheet"));
QCOMPARE(session.document().value().layers.size(), size_t(1));
QThreadPool::globalInstance()->waitForDone();
DNGFixture::Options darker;
darker.value = [](int, int, int) { return quint16(10000); };
DNGFixture::write(path, darker);
QVERIFY(centre(RawImporter::Queue::shared().develop(path, RawImporter::asShot(path).value(), 800)).red() < 139);
// Cancel answers no as well.
done = false;
session.importImages({raw}, std::nullopt, [&done] { done = true; });
QTRY_VERIFY(view.findChild<QDialog *>("rawDevelopSheet"));
view.findChild<QDialog *>("rawDevelopSheet")->findChild<QPushButton *>("rawCancel")->click();
QTRY_VERIFY(done);
QCOMPARE(session.document().value().layers.size(), size_t(1));
}
QTEST_MAIN(RawDevelopTests)
#include "RawDevelopTests.moc"
+338
View File
@@ -0,0 +1,338 @@
#include "DNGFixture.h"
#include "IO/ImageImporter.h"
#include "IO/RawImporter.h"
#include <QColorSpace>
#include <QTemporaryDir>
#include <QtTest>
// Camera RAW through LibRaw: matching, white balance, the develop.
namespace {
QColor middle(const QImage &image)
{
return image.pixelColor(image.width() / 2, image.height() / 2);
}
std::array<int, 3> rgb(const QImage &image)
{
const QColor colour = middle(image);
return {colour.red(), colour.green(), colour.blue()};
}
// The camera's neutral under a white, through the matrix.
std::array<double, 3> neutralFor(QPointF xy)
{
const double xyz[3] = {xy.x() / xy.y(), 1, (1 - xy.x() - xy.y()) / xy.y()};
std::array<double, 3> camera{};
for (size_t row = 0; row < 3; ++row)
for (size_t column = 0; column < 3; ++column)
camera[row] += DNGFixture::colorMatrix[row * 3 + column] * xyz[column];
return {camera[0] / camera[1], 1, camera[2] / camera[1]};
}
RawDevelopSettings flat(const QString &path)
{
RawDevelopSettings settings = RawImporter::asShot(path).value();
settings.boost = 0;
return settings;
}
std::optional<ImageImportError::Kind> refusal(const std::function<void()> &run)
{
try {
run();
} catch (const ImageImportError &error) {
return error.kind;
}
return std::nullopt;
}
}
class RawImporterTests : public QObject {
Q_OBJECT
private slots:
void cameraRawIsKnownByItsExtension();
void temperatureAndTintFollowThePlanckianLocus();
void theCamerasOwnBalanceIsWhereTheSlidersStart();
void boostMixesTheFlatAndTheLibRawCurves();
void exposureScalesTheLightBeforeTheCurve();
void temperatureAndTintColourTheDevelop();
void coloursComeThroughTheCameraMatrix();
void orientationAndThePreviewsLimit();
void theQueueKeepsThePreviewsDecodeUntilReleased();
void aMixingCameraDevelopsAlike();
void aKeptDecodeDevelopsAsAFreshOne();
void fourColourSensorsAreUnsupported();
void whatIsNoRawIsRefused();
void settingsResetToTheShot();
};
void RawImporterTests::cameraRawIsKnownByItsExtension()
{
for (const char *name : {"a.dng", "b.CR2", "c.nef", "d.ARW", "e.raf", "f.orf", "g.rw2", "h.cr3"})
QVERIFY2(RawImporter::matches(name), name);
for (const char *name : {"a.jpg", "b.psd", "c.tif", "d.png", "e", "f.dng.txt"})
QVERIFY2(!RawImporter::matches(name), name);
QVERIFY(RawImporter::isRawType("image/x-adobe-dng") && RawImporter::isRawType("image/x-canon-cr2") && RawImporter::isRawType("image/x-dcraw"));
QVERIFY(!RawImporter::isRawType("image/tiff") && !RawImporter::isRawType("image/png") && !RawImporter::isRawType("nothing"));
const QStringList globs = RawImporter::globs();
for (const char *glob : {"*.dng", "*.cr2", "*.nef", "*.arw", "*.raf"})
QVERIFY2(globs.contains(glob), glob);
QVERIFY(!globs.contains("*.jpg") && !globs.contains("*.tif") && globs.count("*.dng") == 1);
QVERIFY(std::is_sorted(globs.begin(), globs.end()));
}
void RawImporterTests::temperatureAndTintFollowThePlanckianLocus()
{
const QPointF d65 = RawImporter::planckian(6500);
QVERIFY(std::abs(d65.x() - 0.313494) < 1e-6 && std::abs(d65.y() - 0.323663) < 1e-6);
// Warmer is redder: x grows as the temperature falls.
QVERIFY(RawImporter::planckian(2000).x() > RawImporter::planckian(3000).x());
QVERIFY(RawImporter::planckian(3000).x() > RawImporter::planckian(10000).x());
// Positive tint is a white above the locus: greener.
const QPointF green = RawImporter::whitePoint(5000, 30), magenta = RawImporter::whitePoint(5000, -30), on = RawImporter::whitePoint(5000, 0);
QVERIFY(std::abs(on.x() - RawImporter::planckian(5000).x()) < 1e-12);
QVERIFY(magenta.y() < on.y() && green.y() > on.y());
for (const double kelvin : {2100.0, 3200.0, 5000.0, 6500.0, 9000.0, 11900.0})
for (const double tint : {-120.0, -10.0, 0.0, 45.0, 140.0}) {
const auto [backKelvin, backTint] = RawImporter::temperatureOf(RawImporter::whitePoint(kelvin, tint));
QVERIFY2(std::abs(backKelvin - kelvin) < 0.5 && std::abs(backTint - tint) < 0.05,
qPrintable(QStringLiteral("%1 %2 → %3 %4").arg(kelvin).arg(tint).arg(backKelvin).arg(backTint)));
}
}
void RawImporterTests::theCamerasOwnBalanceIsWhereTheSlidersStart()
{
const QTemporaryDir folder;
// D65 sits a little above the Planckian locus.
const RawDevelopSettings daylight = RawImporter::asShot(DNGFixture::write(folder.filePath("d65.dng"))).value();
QVERIFY2(std::abs(daylight.temperature - 6500.7) < 1 && std::abs(daylight.tint - 9.52) < 0.05, qPrintable(QString::number(daylight.tint)));
QCOMPARE(daylight.asShotTemperature, daylight.temperature);
QCOMPARE(daylight.asShotTint, daylight.tint);
QVERIFY(daylight.isAsShot() && daylight.exposure == 0 && daylight.boost == 1);
DNGFixture::Options tungsten;
tungsten.asShotNeutral = neutralFor(RawImporter::whitePoint(3200, 20));
const RawDevelopSettings warm = RawImporter::asShot(DNGFixture::write(folder.filePath("warm.dng"), tungsten)).value();
QVERIFY2(std::abs(warm.temperature - 3200) < 5 && std::abs(warm.tint - 20) < 0.5,
qPrintable(QStringLiteral("%1 %2").arg(warm.temperature).arg(warm.tint)));
}
void RawImporterTests::boostMixesTheFlatAndTheLibRawCurves()
{
const QTemporaryDir folder;
const QString path = DNGFixture::write(folder.filePath("gray.dng"));
RawDevelopSettings settings = flat(path);
// 20000 of 65535: sRGB's curve gives 150, BT.709's 139.
QCOMPARE(rgb(RawImporter::develop(path, settings)), (std::array<int, 3>{150, 150, 150}));
settings.boost = 1;
QCOMPARE(rgb(RawImporter::develop(path, settings)), (std::array<int, 3>{139, 139, 139}));
settings.boost = 0.5;
QCOMPARE(rgb(RawImporter::develop(path, settings)), (std::array<int, 3>{145, 145, 145}));
// In the shadows BT.709 turns straight: 4.5 times the light.
DNGFixture::Options dark;
dark.value = [](int, int, int) { return quint16(500); };
const QString shadow = DNGFixture::write(folder.filePath("dark.dng"), dark);
QCOMPARE(rgb(RawImporter::develop(shadow, RawImporter::asShot(shadow).value())), (std::array<int, 3>{9, 9, 9}));
const QImage developed = RawImporter::develop(path, settings);
QCOMPARE(developed.format(), QImage::Format_RGBA8888_Premultiplied);
QVERIFY(developed.colorSpace() == QColorSpace(QColorSpace::SRgb));
QCOMPARE(middle(developed).alpha(), 255);
}
void RawImporterTests::exposureScalesTheLightBeforeTheCurve()
{
const QTemporaryDir folder;
const QString path = DNGFixture::write(folder.filePath("gray.dng"));
RawDevelopSettings settings = flat(path);
settings.exposure = 1;
QCOMPARE(rgb(RawImporter::develop(path, settings))[1], 205);
settings.exposure = -3;
QCOMPARE(rgb(RawImporter::develop(path, settings))[1], 55);
settings.exposure = 3;
QCOMPARE(rgb(RawImporter::develop(path, settings))[1], 255);
// Near white LibRaw keeps the level: no stretch.
DNGFixture::Options bright;
bright.value = [](int, int, int) { return quint16(52000); };
const QString near = DNGFixture::write(folder.filePath("bright.dng"), bright);
QCOMPARE(rgb(RawImporter::develop(near, flat(near))), (std::array<int, 3>{230, 230, 230}));
}
void RawImporterTests::temperatureAndTintColourTheDevelop()
{
const QTemporaryDir folder;
const QString path = DNGFixture::write(folder.filePath("gray.dng"));
RawDevelopSettings settings = flat(path);
// Balanced for tungsten, a daylight gray turns blue; green holds.
settings.temperature = 3200;
const std::array<int, 3> cool = rgb(RawImporter::develop(path, settings));
QVERIFY2(cool[2] > 150 && cool[1] == 150 && cool[0] < 150, qPrintable(QStringLiteral("%1 %2 %3").arg(cool[0]).arg(cool[1]).arg(cool[2])));
settings.temperature = 12000;
const std::array<int, 3> warm = rgb(RawImporter::develop(path, settings));
QVERIFY2(warm[0] > 150 && warm[1] == 150 && warm[2] < 150, qPrintable(QStringLiteral("%1 %2 %3").arg(warm[0]).arg(warm[1]).arg(warm[2])));
// Balanced for a greener white, as Adobe's positive tint, magenta.
settings.temperature = settings.asShotTemperature;
settings.tint = settings.asShotTint + 60;
const std::array<int, 3> magenta = rgb(RawImporter::develop(path, settings));
QVERIFY2(magenta[0] > 150 && magenta[1] == 150 && magenta[2] > 150, qPrintable(QStringLiteral("%1 %2 %3").arg(magenta[0]).arg(magenta[1]).arg(magenta[2])));
// A white the camera cannot see is refused, not ignored.
settings.temperature = 2000;
settings.tint = settings.asShotTint;
QCOMPARE(refusal([&] { RawImporter::develop(path, settings); }), std::optional(ImageImportError::Kind::whiteBalance));
QCOMPARE(QString(ImageImportError(ImageImportError::Kind::whiteBalance).what()),
QString("This camera can’t record that white balance. Move Temperature or Tint back toward the shot."));
}
void RawImporterTests::coloursComeThroughTheCameraMatrix()
{
const QTemporaryDir folder;
DNGFixture::Options red;
red.value = [](int channel, int, int) { return quint16(channel == 0 ? 30000 : 0); };
const QString path = DNGFixture::write(folder.filePath("red.dng"), red);
const std::array<int, 3> developed = rgb(RawImporter::develop(path, flat(path)));
QVERIFY2(developed[0] > 170 && developed[1] < 5 && developed[2] < 5, qPrintable(QStringLiteral("%1 %2 %3").arg(developed[0]).arg(developed[1]).arg(developed[2])));
}
void RawImporterTests::orientationAndThePreviewsLimit()
{
const QTemporaryDir folder;
DNGFixture::Options turned;
turned.orientation = 6;
const QString path = DNGFixture::write(folder.filePath("turned.dng"), turned);
QCOMPARE(RawImporter::pixelSize(path).value(), QSize(48, 64));
QCOMPARE(RawImporter::develop(path, flat(path)).size(), QSize(48, 64));
const QString upright = DNGFixture::write(folder.filePath("upright.dng"));
QCOMPARE(RawImporter::pixelSize(upright).value(), QSize(64, 48));
// Past the limit, a half-size decode that then fits.
QCOMPARE(RawImporter::develop(upright, flat(upright), 32).size(), QSize(32, 24));
QCOMPARE(RawImporter::develop(upright, flat(upright), 20).size(), QSize(20, 15));
QCOMPARE(RawImporter::develop(upright, flat(upright), 64).size(), QSize(64, 48));
}
void RawImporterTests::theQueueKeepsThePreviewsDecodeUntilReleased()
{
const QTemporaryDir folder;
const QString path = DNGFixture::write(folder.filePath("gray.dng"));
RawImporter::Queue &queue = RawImporter::Queue::shared();
const RawDevelopSettings settings = flat(path);
QCOMPARE(rgb(queue.develop(path, settings, 40))[1], 150);
// The file changes; the kept decode does not, until released.
DNGFixture::Options darker;
darker.value = [](int, int, int) { return quint16(10000); };
DNGFixture::write(path, darker);
QCOMPARE(rgb(queue.develop(path, settings, 40))[1], 150);
QCOMPARE(rgb(queue.develop(path, settings, std::nullopt))[1], 109);
RawDevelopSettings brighter = settings;
brighter.exposure = 1;
QCOMPARE(rgb(queue.develop(path, brighter, 40))[1], 205);
queue.release();
QCOMPARE(rgb(queue.develop(path, brighter, 40))[1], 150);
// Another limit halves the kept frame; another file decodes afresh.
QCOMPARE(queue.develop(path, settings, 20).size(), QSize(20, 15));
QCOMPARE(queue.develop(path, settings, 40).size(), QSize(32, 24));
QCOMPARE(queue.develop(path, settings, 40).size(), QSize(32, 24));
const QString other = DNGFixture::write(folder.filePath("other.dng"));
QCOMPARE(rgb(queue.develop(other, settings, 20))[1], 150);
QCOMPARE(rgb(queue.develop(path, settings, 20))[1], 109);
queue.release();
}
void RawImporterTests::aMixingCameraDevelopsAlike()
{
// Channels leak into each other and differ in gain.
const std::array<double, 9> leaky{0.35, 0.1, 0.05, 0.1, 0.8, 0.1, 0.04, 0.2, 0.56};
const auto seen = [&](std::array<double, 3> scene) {
return [leaky, scene](int channel, int, int) {
const size_t row = size_t(channel) * 3;
return quint16(std::lround(65535 * (leaky[row] * scene[0] + leaky[row + 1] * scene[1] + leaky[row + 2] * scene[2])));
};
};
const QTemporaryDir folder;
DNGFixture::Options gray;
gray.mix = leaky;
gray.asShotNeutral = {(0.35 + 0.1 + 0.05) / 1.0, 1, (0.04 + 0.2 + 0.56) / 1.0};
gray.value = seen({20000 / 65535.0, 20000 / 65535.0, 20000 / 65535.0});
const QString path = DNGFixture::write(folder.filePath("gray.dng"), gray);
const RawDevelopSettings shot = RawImporter::asShot(path).value();
QVERIFY2(std::abs(shot.temperature - 6500.7) < 2 && std::abs(shot.tint - 9.52) < 0.1, qPrintable(QStringLiteral("%1 %2").arg(shot.temperature).arg(shot.tint)));
QCOMPARE(rgb(RawImporter::develop(path, flat(path))), (std::array<int, 3>{150, 150, 150}));
DNGFixture::Options red = gray;
red.value = seen({30000 / 65535.0, 0, 0});
const QString redPath = DNGFixture::write(folder.filePath("red.dng"), red);
const std::array<int, 3> developed = rgb(RawImporter::develop(redPath, flat(path)));
QVERIFY2(developed[0] > 170 && developed[1] < 5 && developed[2] < 5, qPrintable(QStringLiteral("%1 %2 %3").arg(developed[0]).arg(developed[1]).arg(developed[2])));
// Clipped photosites stay white, whichever channels clip.
for (const double level : {1.3, 3.0}) {
DNGFixture::Options clipped = gray;
clipped.value = [leaky, level](int channel, int, int) {
const size_t row = size_t(channel) * 3;
return quint16(std::min(65535.0, 65535 * level * (leaky[row] + leaky[row + 1] + leaky[row + 2])));
};
const QString clippedPath = DNGFixture::write(folder.filePath("clipped.dng"), clipped);
for (const float boost : {0.0f, 1.0f}) {
RawDevelopSettings settings = shot;
settings.boost = boost;
QCOMPARE(rgb(RawImporter::develop(clippedPath, settings)), (std::array<int, 3>{255, 255, 255}));
}
}
}
void RawImporterTests::aKeptDecodeDevelopsAsAFreshOne()
{
const QTemporaryDir folder;
DNGFixture::Options leaky;
leaky.mix = {0.35, 0.1, 0.05, 0.1, 0.8, 0.1, 0.04, 0.2, 0.56};
leaky.value = [](int channel, int, int) { return quint16(std::array{10000, 20000, 16000}[size_t(channel)]); };
const QString path = DNGFixture::write(folder.filePath("leaky.dng"), leaky);
RawImporter::Queue &queue = RawImporter::Queue::shared();
RawDevelopSettings settings = flat(path);
for (const auto &[kelvin, tint] : std::vector<std::pair<float, float>>{{3200, 0}, {3200, 0}, {9000, 40}, {settings.asShotTemperature, settings.asShotTint}}) {
settings.temperature = kelvin;
settings.tint = tint;
QCOMPARE(rgb(queue.develop(path, settings, 40)), rgb(RawImporter::develop(path, settings, 40)));
}
queue.release();
}
void RawImporterTests::fourColourSensorsAreUnsupported()
{
const QTemporaryDir folder;
DNGFixture::Options four;
four.fourColours = true;
const QString path = DNGFixture::write(folder.filePath("four.dng"), four);
QCOMPARE(RawImporter::pixelSize(path).value(), QSize(64, 48));
QVERIFY(!RawImporter::asShot(path));
QCOMPARE(refusal([&] { RawImporter::develop(path, RawDevelopSettings()); }), std::optional(ImageImportError::Kind::unsupported));
}
void RawImporterTests::whatIsNoRawIsRefused()
{
const QTemporaryDir folder;
QFile fake(folder.filePath("fake.dng"));
QVERIFY(fake.open(QIODevice::WriteOnly) && fake.write("not a raw") == 9);
fake.close();
QCOMPARE(refusal([&] { RawImporter::develop(fake.fileName(), RawDevelopSettings()); }), std::optional(ImageImportError::Kind::unreadable));
QVERIFY(!RawImporter::asShot(fake.fileName()) && !RawImporter::pixelSize(fake.fileName()));
const QString missing = folder.filePath("missing.dng");
QCOMPARE(refusal([&] { RawImporter::develop(missing, RawDevelopSettings()); }), std::optional(ImageImportError::Kind::unreadable));
QVERIFY(!RawImporter::asShot(missing) && !RawImporter::pixelSize(missing));
}
void RawImporterTests::settingsResetToTheShot()
{
RawDevelopSettings settings;
settings.asShotTemperature = 4100;
settings.asShotTint = 7;
QVERIFY(!settings.isAsShot());
settings.reset();
QVERIFY(settings.isAsShot());
QVERIFY(settings.temperature == 4100 && settings.tint == 7 && settings.exposure == 0 && settings.boost == 1);
for (const auto &change : std::vector<std::function<void(RawDevelopSettings &)>>{
[](RawDevelopSettings &s) { s.exposure = 0.5; }, [](RawDevelopSettings &s) { s.boost = 0.9f; },
[](RawDevelopSettings &s) { s.temperature = 4000; }, [](RawDevelopSettings &s) { s.tint = 0; }}) {
RawDevelopSettings changed = settings;
change(changed);
QVERIFY(!changed.isAsShot());
}
}
QTEST_GUILESS_MAIN(RawImporterTests)
#include "RawImporterTests.moc"
+245
View File
@@ -0,0 +1,245 @@
# Every test binary, one per Swift test file.
enable_testing()
function(compositor_test name)
add_executable(${name} tests/${name}.cpp)
target_link_libraries(${name} PRIVATE compositor_core Qt6::Test)
add_test(NAME ${name} COMMAND ${name})
set_tests_properties(${name} PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
endfunction()
compositor_test(AdjustmentSurfaceTests)
compositor_test(BlendModePickerTests)
compositor_test(BlendShortcutTests)
compositor_test(LayersPanelTests)
compositor_test(LayerListFocusTests)
compositor_test(LayerDragTests)
compositor_test(LayerDragGestureTests)
compositor_test(ProjectDropTests)
compositor_test(BlendModeTests)
compositor_test(BrushCanvasTests)
compositor_test(BrushGestureTests)
compositor_test(BrushKeyTests)
compositor_test(BrushCursorOverlayTests)
compositor_test(BrushControlsTests)
compositor_test(BrushPerformanceTests)
compositor_test(CanvasPerformanceTests)
compositor_test(PoolMapTests)
compositor_test(DesktopIntegrationTests)
# The install it checks needs the app built.
add_dependencies(DesktopIntegrationTests omaphoto)
# What this build installs, as CMake itself reads it.
target_compile_definitions(DesktopIntegrationTests PRIVATE OMAPHOTO_BUNDLED=$<BOOL:${OMAPHOTO_BUNDLE_ORT}>
OMAPHOTO_LIBDIR="${CMAKE_INSTALL_LIBDIR}" OMAPHOTO_MODEL_PATH="${OMAPHOTO_MODEL}")
compositor_test(BrushIntersectionTests)
compositor_test(BrushSessionTests)
compositor_test(BrushSmoothingTests)
compositor_test(BrushPreviewTests)
compositor_test(BrushRasterTests)
compositor_test(BrushTests)
compositor_test(BrushCoverageTests)
compositor_test(SpotHealingTests)
compositor_test(CloneStampTests)
compositor_test(CloneStampCanvasTests)
compositor_test(SmudgeLiquifyTests)
compositor_test(BlurToolTests)
compositor_test(SmearCanvasTests)
compositor_test(GradientTests)
compositor_test(GradientCanvasTests)
compositor_test(ShapeToolTests)
compositor_test(ShapeLayerTests)
compositor_test(ShapeCanvasTests)
compositor_test(TypeToolTests)
compositor_test(TextLayoutTests)
compositor_test(TypeSessionTests)
compositor_test(TypeStoreTests)
compositor_test(TypeControlsTests)
compositor_test(TypeFontPickerTests)
compositor_test(InlineTextEditorTests)
compositor_test(InlineTextInputTests)
compositor_test(InlineTextDrawTests)
compositor_test(InlineTextPreeditTests)
compositor_test(InlineTextPointerTests)
compositor_test(InlineTextHandleTests)
compositor_test(ColorPickerTests)
compositor_test(FloatingPanelTests)
compositor_test(ColorSwatchTests)
compositor_test(ColorPaletteControlsTests)
compositor_test(ColorPickerSheetTests)
compositor_test(EyedropperTests)
compositor_test(PickingTests)
compositor_test(LevelsTests)
compositor_test(LevelsSessionTests)
compositor_test(LevelsModelTests)
compositor_test(LevelsEdgeTests)
compositor_test(LevelsFailureTests)
compositor_test(LevelsSheetTests)
compositor_test(LevelsHandleTests)
compositor_test(LevelsCanvasTests)
compositor_test(ImageAdjustmentTests)
compositor_test(ImageAdjustmentModelTests)
compositor_test(CurvesControlsTests)
compositor_test(HueSaturationTests)
compositor_test(HueSaturationModelTests)
compositor_test(HueSaturationSessionTests)
compositor_test(HueSaturationFailureTests)
compositor_test(HueSaturationSheetTests)
compositor_test(HueSaturationSpectrumTests)
compositor_test(HueSaturationCanvasTests)
compositor_test(CanvasDisplayTests)
compositor_test(CanvasNavigationTests)
compositor_test(CanvasSizeTests)
compositor_test(CanvasThumbnailTests)
compositor_test(ClippingStackTests)
compositor_test(CompositorTests)
compositor_test(CompositorMenusTests)
compositor_test(ViewMenuTests)
compositor_test(KeyboardShortcutsTests)
compositor_test(KeyboardShortcutsSheetTests)
compositor_test(RemappedKeysTests)
compositor_test(CompositorUITestsLaunchTests)
compositor_test(ContentViewTests)
compositor_test(ContentViewStatusTests)
compositor_test(DistortTests)
compositor_test(DownsampleTests)
compositor_test(DrawCoverageTests)
compositor_test(EditorSessionSignalTests)
compositor_test(EditorSessionTests)
compositor_test(LayerDuplicateTests)
compositor_test(ExportTests)
compositor_test(FolderMaskClipTests)
compositor_test(EditorCanvasTests)
compositor_test(GroupSessionTests)
compositor_test(GroupTests)
compositor_test(GroupingSelectionTests)
compositor_test(HighDpiCanvasTests)
compositor_test(HistoryTests)
compositor_test(ImageImportSessionTests)
compositor_test(ImageImportTests)
compositor_test(ImageSizeTests)
compositor_test(JPEGExportTests)
compositor_test(LayerAppearanceControlsTests)
compositor_test(LayerAppearanceTests)
compositor_test(FolderOpacityTests)
compositor_test(LayerMaskEditTests)
compositor_test(LayerMaskSessionTests)
compositor_test(LayerMaskTests)
compositor_test(LayerRendererTests)
compositor_test(LayerTests)
compositor_test(LiveMaskRendererTests)
compositor_test(LiveMaskBakeTests)
compositor_test(LiveMaskDeleteTests)
compositor_test(LiveMaskEditTests)
compositor_test(LiveMaskSessionTests)
compositor_test(LiveMaskTests)
compositor_test(MaskPlacementTests)
compositor_test(MaskSignalTests)
compositor_test(MaskTracingTests)
compositor_test(MaskTransformTests)
compositor_test(ProjectManifestTests)
compositor_test(NativeLayerListTests)
compositor_test(NavigationToolHeaderTests)
compositor_test(NewCanvasSheetTests)
compositor_test(OmarchyThemeTests)
compositor_test(ProjectControllerTests)
compositor_test(ProjectCopyTests)
compositor_test(ProjectOpenTests)
compositor_test(ProjectReceiveTests)
compositor_test(ProjectReplaceTests)
compositor_test(ProjectWorkspaceFlowTests)
compositor_test(ProjectWorkspaceSignalTests)
compositor_test(ProjectWorkspaceTests)
compositor_test(ProjectWorkspaceViewTests)
# It starts the real app beside itself.
add_dependencies(ProjectWorkspaceViewTests omaphoto)
compositor_test(ProjectSessionTests)
compositor_test(ProjectTests)
compositor_test(RasterSnapshotSessionTests)
compositor_test(RasterSnapshotTests)
compositor_test(SessionGateTests)
compositor_test(TiledLayerTests)
compositor_test(TiledPieceTests)
compositor_test(TiledStrokeTests)
compositor_test(TransformSessionTests)
compositor_test(ToolIconTests)
compositor_test(TransformTests)
compositor_test(TransformOverlayTests)
compositor_test(TransformPressTests)
compositor_test(TransformInspectorTests)
compositor_test(TransformSnapTests)
compositor_test(CursorTests)
compositor_test(DistortCanvasTests)
compositor_test(DistortMaskTests)
compositor_test(FiltersTests)
compositor_test(FilterTests)
compositor_test(FilterModelTests)
# It checks what the filters hand Swift's C kernels.
target_include_directories(FilterModelTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(FilterSessionTests)
compositor_test(FilterFailureTests)
compositor_test(LayerAdjustmentTests)
compositor_test(AdjustmentLayerTests)
compositor_test(AdjustmentEditorTests)
compositor_test(AdjustmentPanelTests)
compositor_test(LayerEffectsTests)
compositor_test(OuterGlowTests)
compositor_test(LayerEffectsSessionTests)
compositor_test(LayerEffectsKernelTests)
compositor_test(EffectsPreviewCacheTests)
compositor_test(LayerEffectsSurfaceTests)
compositor_test(EffectsCanvasTests)
compositor_test(EffectsFailureTests)
compositor_test(EffectsPanelTests)
compositor_test(LayerEffectRowTests)
compositor_test(GuidedMatteTests)
compositor_test(SubjectRemovalTests)
compositor_test(SubjectRemovalModelTests)
compositor_test(SubjectRemovalFailureTests)
compositor_test(RemoveBackgroundTests)
compositor_test(SelectSubjectTests)
compositor_test(ObjectSelectionTests)
compositor_test(PSDRoundTripTests)
compositor_test(PSDVectorTests)
compositor_test(PSDReaderTests)
compositor_test(PSDReaderLayerTests)
compositor_test(PSDImportTests)
compositor_test(RawImporterTests)
compositor_test(RawDevelopTests)
compositor_test(LayerEffectsRendererTests)
compositor_test(LayerFlipTests)
compositor_test(LayerMergeTests)
compositor_test(PixelEditTests)
compositor_test(PixelInvertTests)
compositor_test(PixelMoveTests)
compositor_test(SelectionTests)
compositor_test(SelectionFeatherTests)
compositor_test(SelectionSourceTests)
compositor_test(SelectionEditTests)
compositor_test(SelectionSignalTests)
compositor_test(SelectMenuTests)
compositor_test(SelectionCanvasTests)
compositor_test(SelectionOverlayTests)
compositor_test(LassoControlsTests)
compositor_test(SelectionAmountSheetTests)
compositor_test(MagicWandTests)
compositor_test(SelectionClipboardTests)
compositor_test(SmartEditTests)
compositor_test(FilterSheetTests)
compositor_test(FilterControlsTests)
compositor_test(FloatingSelectionTests)
compositor_test(CropTests)
compositor_test(GuideTests)
compositor_test(GuideCanvasTests)
compositor_test(CropSessionTests)
compositor_test(CropCanvasTests)
compositor_test(CropControlsTests)
compositor_test(CropFailureTests)
compositor_test(CanvasSizeSheetTests)
compositor_test(ImageSizeSheetTests)
compositor_test(ProjectResizeTests)
compositor_test(JPEGExportSheetTests)
compositor_test(ProjectExportTests)
compositor_test(ProjectExportFailureTests)
compositor_test(ByteCountsTests)
compositor_test(ImageFileDropTests)
compositor_test(SliderSnapTests)
compositor_test(ProjectResizeFailureTests)