Render-path containers survive running out of memory: std::map for QHash (12.8e)

This commit is contained in:
雷电芽衣 committed 2026-09-28 01:25:58 -04:00
1 parent 56af936a7b
commit bb8129e8d5
8 files changed
+210 -48

No files matched your search

+1
View File
@@ -125,6 +125,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Black & White, Color Balance and Invert (12.7b, Swift 1.2.2): `kernels/AdjustPixels.c/.h` are v1.2.2's, with `adjust_black_white` and `adjust_color_balance`, run through `ImageAdjustmentPixels::run` on one thread as Swift's. `BlackWhiteSettings` and `ColorBalanceSettings` keep Swift's fields, defaults and bounds (the `isFinite` terms left out, as the bounds refuse NaN and infinities); `apply` throws `ProjectError(invalid)` past them and Color Balance hands an untouched image back. Swift's `FilterSettings.normalized` does not clamp either, so a number typed past a slider's range fails the preview with that error and a commit sets `brushError` with no step, as in Swift: the filter's two workers catch `ProjectError` beside their other errors, where it would otherwise escape as `QUnhandledException` and abort. `FilterKind` gains both after Grain (`isImageAdjustment`), `AdjustmentKind` Invert, Black & White and Color Balance in Swift's order, with `isEditable` (all but Invert) and `filterKind`; `LayerAdjustment` gains the two optionals with Swift's computed getter and setter pairs, validity and `apply` (Invert through `PixelInvert::run`, whole image, colour only); the manifest's `blackWhiteSettings` and `colorBalanceSettings` are Swift's synthesized `Codable`, every key required, left out while absent. `addAdjustment` opens the editor for editable kinds alone: an Invert layer applies at once. The editors carry both settings through `beginAdjustmentEditing` and `editedAdjustment`. A fix beyond Swift: Swift's Edit Adjustment… stays enabled on an Invert layer, whose editor switch breaks after the original is kept and the transaction opened, so nothing ever closes them and the session stays refused; the twin's entry rests for a kind that is not editable, and the two switches throw a `logic_error` for Invert, which nothing reaches. As Swift's row, an Invert layer says "Adjustment · Double-click to edit", yet a double click on its glyph renames it (`m_editableAdjustment`, Swift's `isEditable` test). The Image menu lists Black & White… and Color Balance… before Exposure…, the Layer menu's New Adjustment Layer entries take an ellipsis when editable, and a menu doubles the ampersand of "Black & White" (a lone one is a mnemonic in Qt), in the kinds' entries and in Undo and Redo's step names alike; object names drop it (`blackWhite`, `newBlackWhiteAdjustment`). `LayerIcons` draws Swift's three new symbols as paths: `circle.righthalf.filled`, `circle.filled.pattern.diagonalline.rectangle` (a rounded frame, diagonal lines, a filled circle) and `scale.3d` (three axes with arrowheads). The filter sheet's two kinds live in `UI/FilterSheet+Adjustments.cpp` (the sheet's file neared 500 lines): the six families and Tint's Hue and Saturation, shown while Tint is on (Swift's `if`, the rows `m_shownWhen` holds with Advanced's), and Color Balance's three headlines (13 pixels, semibold, Swift's `.headline`, as the effects sheet's) over three rows each and Preserve Luminosity; each `flag` is Swift's `Toggle` with its help as the tooltip. `NativeLayerList` stops listening in its destructor, as `ContentView` does: closing a list window hides it first, and a rename's focus-out then commits into a list half destroyed. Tests: Swift's `invertAppliesWithoutAnEditor` in `AdjustmentLayerTests`, its editor test over the editable kinds in `AdjustmentEditorTests` and `FloatingPanelTests`, `ColorAdjustmentTests` (the kernels against Photoshop's defaults and a tint worked in HSL, the tones of Color Balance and Preserve Luminosity, bounds, every manifest key, the sheet), the kinds, keys and menus in `FilterModelTests`, `LayerAdjustmentTests`, `AdjustmentPanelTests`, `CompositorMenusTests` and `SelectMenuTests`. `ProjectExportFailureTests`' two panel limits rose from 2,048 KB: the thousand-layer snapshot grew by the two optionals of every record (2,560 KB since 12.8d).
- The Camera Raw filter's model (12.8a, Swift 1.2.3): `kernels/AdjustPixels.c/.h` are v1.2.3's, with the Camera Raw kernels and Grain's particle size (`59b5040`); that kernel warns under `-Wunused-parameter` and `-Wmisleading-indentation` through 1.3.3 (both lines date from `9982fad`; `075a71b` fixes another), so CMake silences those two for that file alone. `Document/CameraRaw.h/.cpp`, `CameraRawColor`, `CameraRawDetailOptics` and `CameraRawGeometryCalibration` twin Swift's four files; Swift's `CameraRaw.swift` runs to 638 lines, so its session extension is `CameraRaw+Session.cpp` with `CameraRaw+Session.h`. The enums keep Swift's raw values and order; `CameraRawClipping` keeps Swift's bits (highlights 1, shadows 2); `Balance` and `Gains` are structs for Swift's tuples; `region` returns a reference where Swift writes through a key path. `CameraRawDrag` sits in `CameraRaw.h`, where Swift's sits in `CameraRawColor.swift`: it holds the whole settings, which C++ needs complete. Swift's panel state sits on `FilterEdit` (the shown groups, pages, swatch, point index, the tools, the guide draft, clipping, sharpen mask, the scope and its mode, the readout); the twin gathers it in `CameraRawPanel`, `FilterEdit::rawPanel`, written through `setCameraRawPanel`, which notifies while an edit is open. `renderSettings` is Swift's settings with the hidden groups left out (`applying`); the commit renders and keeps those, so a hidden group stays out next time, and an identity grade closes as Cancel does. `previewJob` carries the panel's views; the preview's worker runs `CameraRawScope::preview`, whose scope lands on the panel. A fix beyond Swift: Swift's `preview` drops the point colour's view (it runs the job itself only for clipping or the sharpen mask, through 1.3.3), so Visualize never shows; the twin runs the job for it too. Geometry's warp is `QTransform::quadToQuad` from the image's rect to Swift's corners flipped to y down, drawn smooth and antialiased; a warp Qt cannot solve is a `logic_error`. Constrain Crop fits the frame inside `brush_alpha_bounds`; a crop whose copy finds no memory is `ExportError(render)`, where Swift's `cropping` returning nil falls back to the uncropped warp. Left out as dead: `apply`'s `isValid` guard and with it `isValid` (settings arrive normalized, and nothing else asks), `nudged`'s guard for an empty channel (`normalized` repairs every curve to two points; `at` throws if that breaks), the vectorscope's wrap of a negative hue (its angle's cosine and sine turn round alike), the parametric curve's `max(0, weight)` (a tone inside its region weighs 0 to 1), each group's `applying(shows:)` (no caller), and the defringe eyedropper's own `normalized` (`updateFilter` normalizes). Equivalent mutants: `tone < shadow` against `<=` (the regions meet with equal weight), the defringe tie's `<` against `<=`, the mixer's `weights > 0` against `>= 0` (a zero weight adds nothing), the identity commit's cancel (the history drops an unchanged step), and Auto's `committing` term (a committing edit refuses `updateFilter`, and the landing asks again). The Filter menu and the sheet leave Camera Raw out until its panel (12.8b). Tests: `CameraRawTests`, `CameraRawSessionTests` (Swift's session tests; the preview's views and scope), `CameraRawToolsTests`, `CameraRawRulesTests`, `CameraRawColorTests`, `CameraRawLensTests` (the kernel's calibration called directly), `CameraRawWiringTests` (Effects, Optics and Detail against their kernels called with the header's order, a clip view on an untouched grade, Auto in linear light, every eye's fields, Offset, Aspect and Constrain Crop against placed pictures, the scope's channels and hues, the curve, mixer and grading call with its floats written out, the sharpen-mask view, each lens flag alone, Grain's scale, a dull colour's reach, Rotate, Scale and Vertical against placed pictures), `CameraRawFixtures.h`, Grain's size in `ImageAdjustmentTests`, the kind in `FilterModelTests`.
- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; the track is fixed at construction, and Swift's track update in `updateNSView` arrives with its first changing caller, the mixer's families (12.8b2). `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`.
- Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held.
- 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.
+2 -1
View File
@@ -351,7 +351,8 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.8b1 Version 1.2.3, part two: the Camera Raw panel docked beside the canvas (Swift's docked placement in `FloatingPanel.swift`), `CameraRawSlider` (coloured tracks, a double click on the knob resetting), the histogram and vectorscope with the clipping buttons and the readout, the ten sections with their eyes, Light, Color and Effects, and the canvas half of the tools in `EditorCanvas.swift` (the readout on hover and its clearing on leaving, the three eyedroppers and the guide's press, each refocusing the panel, the guide and targeted drags, their release, `picking`'s four terms).
- 12.8b1 round 1: FAIL, four findings: the rows' titles, help and two clipping views, the scope's drawing and the glyphs were unpinned; help said Option and Control-click; a stray `friend` and `setTrack` without a caller; AGENTS.md lacked the equivalents and the dock's height. All fixed, with the minor notes (buddy, `isHidden`, SwiftUI's dark colours, auto-repeat, the docked panel's deletion).
- 12.8b1 round 2: PASS. Its notes taken: every row's decimals and plain track pinned, the lit highlight triangle, the knob's two-point ring, a redundant label filter gone, two consequences named in AGENTS.md.
- [ ] 12.8e `ProjectExportFailureTests::aJPEGRenderThatFindsNoMemoryIsExplained` still aborts with `std::bad_alloc` under a full parallel run after 12.8d (once in 12.8b1's run; that test's main thread waits while the JPEG worker runs under the limit): catch a backtrace, fix the cause.
- [x] 12.8e `ProjectExportFailureTests::aJPEGRenderThatFindsNoMemoryIsExplained` still aborts with `std::bad_alloc` under a full parallel run after 12.8d (once in 12.8b1's run; that test's main thread waits while the JPEG worker runs under the limit): catch a backtrace, fix the cause.
- 12.8e: the cause, from a backtrace under load: Qt 6.4's `QHash` insert (`findOrInsert`, `noexcept`) terminates when it cannot allocate, and the JPEG worker's render filled a `QHash` in `LiveMaskGraph::validate`. The render path's hash containers are `std::map` and `std::set` now; `RenderStarvationTests` fails each allocation in turn. Round 1: PASS, its note taken (bake's workers catch `ExportError` alone).
- [x] 12.8p Review PR #8 (a self-contained AppImage workflow from a contributor) and say whether it can merge (the user's request, 2026-09-27).
- 12.8p: built the workflow's AppDir locally on Ubuntu 24.04. Not mergeable: `AppRun` is a bare symlink, so the bundled model under `$APPDIR/usr/share` is never on `XDG_DATA_DIRS` and Remove Background finds no model; libheif's HEVC plugin is not bundled; only xcb is deployed; its tag runs clash with `release.yml`; its tools are unpinned. Changes requested on the PR, with the user's approval.
- [ ] 12.8b2 Version 1.2.3, part three: Curve, Color Mixer, Color Grading (its wheels turning the way their hue runs, 1.2.9's `96edd5f`), Detail, Optics, Geometry and Calibration in the panel, and the Filter menu's Camera Raw entry.
+5 -5
View File
@@ -6,10 +6,10 @@
#include "IO/ImageExporter.h"
#include "Logging.h"
#include "Rendering/DownsampleCache.h"
#include <QHash>
#include "IO/ProjectStore.h"
#include <algorithm>
#include <cmath>
#include <map>
#include <stdexcept>
LayerMask::LayerMask(ImportedImage asset, bool isEnabled, std::optional<LayerTransform> placement, bool isLinked)
@@ -220,12 +220,12 @@ void FolderMaskClip::draw(const std::vector<QUuid> &ids, const std::function<std
const std::function<void(QUuid, const QImage &clip)> &drawLayer)
{
// Coverage through a folder and its ancestors; null for none.
QHash<QUuid, QImage> covered;
std::map<QUuid, QImage> covered;
std::function<QImage(std::optional<QUuid>, int)> through = [&](std::optional<QUuid> folder, int depth) -> QImage {
if (!folder || depth >= 64)
return QImage();
if (const auto found = covered.constFind(*folder); found != covered.constEnd())
return *found;
if (const auto found = covered.find(*folder); found != covered.end())
return found->second;
QImage coverage = through(parent(*folder), depth + 1);
if (const std::optional<Applier> applier = clip(*folder)) {
if (coverage.isNull()) {
@@ -236,7 +236,7 @@ void FolderMaskClip::draw(const std::vector<QUuid> &ids, const std::function<std
}
(*applier)(context, coverage);
}
covered.insert(*folder, coverage);
covered.insert_or_assign(*folder, coverage);
return coverage;
};
for (const QUuid &id : ids)
+20 -17
View File
@@ -8,12 +8,12 @@
#include "Rendering/AdjustmentSurface.h"
#include "Rendering/LayerRenderer.h"
#include "Rendering/LiveMaskRenderer.h"
#include <QHash>
#include <QMessageBox>
#include <QPushButton>
#include <QSet>
#include <QtConcurrent>
#include <map>
#include <set>
#include <stdexcept>
void EditorSession::drawLiveComposite(const CanvasDocument &document, QPainter &context, bool onSurface) const
@@ -73,27 +73,31 @@ void EditorSession::drawLiveComposite(const CanvasDocument &document, QPainter &
void LiveMaskGraph::validate(const std::vector<ProjectLayerRecord> &layers)
{
QHash<QUuid, const ProjectLayerRecord *> records;
// Standard containers: a worker's render may run out of memory.
std::map<QUuid, const ProjectLayerRecord *> records;
for (const ProjectLayerRecord &layer : layers) {
if (records.contains(layer.id))
if (!records.emplace(layer.id, &layer).second)
throw ProjectError(ProjectError::Kind::invalid);
records.insert(layer.id, &layer);
}
const auto record = [&records](QUuid id) -> const ProjectLayerRecord * {
const auto found = records.find(id);
return found != records.end() ? found->second : nullptr;
};
for (const ProjectLayerRecord &layer : layers) {
QSet<QUuid> path;
std::set<QUuid> path;
std::optional<QUuid> current = layer.id;
while (current) {
const ProjectLayerRecord *record = records.value(*current);
if (path.size() >= 256 || path.contains(*current) || !record)
const ProjectLayerRecord *found = record(*current);
if (path.size() >= 256 || path.contains(*current) || !found)
throw ProjectError(ProjectError::Kind::invalid);
path.insert(*current);
if (record->maskSourceID) {
const ProjectLayerRecord *source = records.value(*record->maskSourceID);
if (found->maskSourceID) {
const ProjectLayerRecord *source = record(*found->maskSourceID);
// Folders never clip; nothing clips to an adjustment.
if (record->isGroup == true || !source || source->isGroup == true || source->adjustment)
if (found->isGroup == true || !source || source->isGroup == true || source->adjustment)
throw ProjectError(ProjectError::Kind::invalid);
}
current = record->maskSourceID;
current = found->maskSourceID;
}
}
}
@@ -110,21 +114,20 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
QImage surface = BrushRaster::context(width, height, false);
const QTransform inverse = BrushRaster::pixelToDocument(layers[found].transform, width, height).inverted();
// Swift's dictionary traps on an id that repeats.
QHash<QUuid, const ProjectLayerRecord *> records;
std::map<QUuid, const ProjectLayerRecord *> records;
for (const ProjectLayerRecord &layer : layers) {
if (records.contains(layer.id))
if (!records.emplace(layer.id, &layer).second)
throw std::logic_error("a snapshot to bake repeats a layer id");
records.insert(layer.id, &layer);
}
// A source dims with its folders, as exported.
const auto folded = [&records](const ProjectLayerRecord &layer) {
return LayerOpacity::effective(layer.opacity.value_or(1), layer.parentID, [&records](QUuid id) -> std::optional<std::pair<double, std::optional<QUuid>>> {
if (!records.contains(id))
return std::nullopt;
return std::pair(records.value(id)->opacity.value_or(1), records.value(id)->parentID);
return std::pair(records.at(id)->opacity.value_or(1), records.at(id)->parentID);
});
};
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.value(id)->maskSourceID : std::nullopt; },
LiveMaskRenderer live([&](QUuid id) { return records.contains(id) ? records.at(id)->maskSourceID : std::nullopt; },
[&](QUuid id, QPainter &context, const QImage &clip) {
// `at` is checked: end() would read wild memory.
if (!records.contains(id) || !snapshot.images.contains(id))
@@ -136,7 +139,7 @@ std::optional<ImportedImage> LiveMaskBaker::bake(const ProjectSnapshot &snapshot
context, {.clip = clip});
return;
}
const ProjectLayerRecord &layer = *records.value(id);
const ProjectLayerRecord &layer = *records.at(id);
const std::optional<LayerMask> mask = snapshot.mask(layer);
const std::optional<QImage> shown =
mask ? mask->clipImage(mask->placement, layer.transform, pixels.width(), pixels.height()) : std::nullopt;
+18 -17
View File
@@ -16,7 +16,7 @@ void LiveMaskRenderer::prepareStacks(const std::vector<QUuid> &ids, const std::f
const std::function<LayerBlendMode(QUuid)> &blend)
{
for (const QUuid &id : ids)
m_modes.insert(id, blend(id));
m_modes.insert_or_assign(id, blend(id));
for (size_t index = 0; index < ids.size(); ++index) {
const QUuid base = ids[index];
if (m_source(base))
@@ -29,9 +29,9 @@ void LiveMaskRenderer::prepareStacks(const std::vector<QUuid> &ids, const std::f
}
if (children.empty())
continue;
m_stackModes.insert(base, blend(base));
m_stacked.unite(QSet<QUuid>(children.begin(), children.end()));
m_stacks.insert(base, std::move(children));
m_stackModes.insert_or_assign(base, blend(base));
m_stacked.insert(children.begin(), children.end());
m_stacks.insert_or_assign(base, std::move(children));
}
}
@@ -45,10 +45,10 @@ void LiveMaskRenderer::drawComposite(QUuid id, QPainter &context, const QImage &
adjust(id, context, clip);
return;
}
const auto children = m_stacks.constFind(id);
const auto children = m_stacks.find(id);
const QSize device(context.device()->width(), context.device()->height());
QImage group, alpha;
if (children != m_stacks.constEnd() && fits(context)) {
if (children != m_stacks.end() && fits(context)) {
group = QImage(device, QImage::Format_RGBA8888_Premultiplied);
alpha = QImage(device, QImage::Format_Grayscale8);
if (group.isNull() || alpha.isNull())
@@ -56,9 +56,9 @@ void LiveMaskRenderer::drawComposite(QUuid id, QPainter &context, const QImage &
}
if (group.isNull() || alpha.isNull()) {
// Without a group each child draws alone, through the base.
if (children != m_stacks.constEnd()) {
for (const QUuid &child : *children)
m_stacked.remove(child);
if (children != m_stacks.end()) {
for (const QUuid &child : children->second)
m_stacked.erase(child);
}
draw(id, context, clip);
return;
@@ -76,7 +76,7 @@ void LiveMaskRenderer::drawComposite(QUuid id, QPainter &context, const QImage &
{
QPainter painter(&group);
painter.setTransform(context.deviceTransform());
for (const QUuid &child : *children) {
for (const QUuid &child : children->second) {
if (adjustment(child))
adjust(child, painter, QImage());
else
@@ -87,7 +87,7 @@ void LiveMaskRenderer::drawComposite(QUuid id, QPainter &context, const QImage &
size_t(device.width()), size_t(device.height()));
// Device pixels already: one to one, at the painter's opacity.
LayerRenderer::composite(context, context.deviceTransform().inverted(), QRectF(group.rect()), LayerSampling::nearest,
InterpolationQuality::none, {.opacity = context.opacity(), .blendMode = m_stackModes.value(id), .clip = clip}, QRectF(),
InterpolationQuality::none, {.opacity = context.opacity(), .blendMode = m_stackModes.at(id), .clip = clip}, QRectF(),
[&](QPainter &aside) { aside.drawImage(QRectF(group.rect()), group); });
}
@@ -127,16 +127,16 @@ bool LiveMaskRenderer::fits(const QPainter &context) const
std::optional<QImage> LiveMaskRenderer::coverage(QUuid id, const QPainter &context)
{
if (const auto found = m_cache.constFind(id); found != m_cache.constEnd())
return *found;
if (const auto found = m_cache.find(id); found != m_cache.end())
return found->second;
if (!fits(context))
qCWarning(lcRendering) << "clip coverage passes its pixel budget:" << context.device()->width() << "x" << context.device()->height();
if (m_visiting.contains(id) || m_visiting.size() >= 256 || !fits(context))
return std::nullopt;
struct Visit {
QSet<QUuid> &visiting;
std::set<QUuid> &visiting;
QUuid id;
~Visit() { visiting.remove(id); }
~Visit() { visiting.erase(id); }
} visit{m_visiting, id};
m_visiting.insert(id);
const QSize device(context.device()->width(), context.device()->height());
@@ -153,7 +153,7 @@ std::optional<QImage> LiveMaskRenderer::coverage(QUuid id, const QPainter &conte
}
layer_extract_alpha(pixels.constBits(), size_t(pixels.bytesPerLine()), gray.bits(), size_t(gray.bytesPerLine()),
size_t(device.width()), size_t(device.height()));
m_cache.insert(id, gray);
m_cache.insert_or_assign(id, gray);
return gray;
}
@@ -217,7 +217,8 @@ void LiveMaskRenderer::adjust(QUuid id, QPainter &context, const QImage &clip)
throw ExportError(ExportError::Kind::render);
original.setDevicePixelRatio(1);
QImage adjusted = adjustment(id).value().apply(original, region);
const LayerBlendMode mode = m_modes.value(id, LayerBlendMode::normal);
const auto found = m_modes.find(id);
const LayerBlendMode mode = found != m_modes.end() ? found->second : LayerBlendMode::normal;
if (mode != LayerBlendMode::normal)
adjusted = blended(original, adjusted, mode);
QImage coverage(device->size(), QImage::Format_Alpha8);
+9 -8
View File
@@ -1,13 +1,13 @@
#pragma once
#include "Document/LayerAdjustment.h"
#include "Document/LayerAppearance.h"
#include <QHash>
#include <QImage>
#include <QPainter>
#include <QSet>
#include <QUuid>
#include <functional>
#include <map>
#include <optional>
#include <set>
#include <vector>
// Per-render dependency cache: a layer shows through its source's alpha.
@@ -39,11 +39,12 @@ private:
const Source m_source;
const DrawOwn m_drawOwn;
const qint64 m_pixelBudget;
QHash<QUuid, QImage> m_cache;
QSet<QUuid> m_visiting;
QHash<QUuid, std::vector<QUuid>> m_stacks;
QSet<QUuid> m_stacked;
QHash<QUuid, LayerBlendMode> m_stackModes;
// Standard containers: Qt 6.4's hash inserts terminate out of memory.
std::map<QUuid, QImage> m_cache;
std::set<QUuid> m_visiting;
std::map<QUuid, std::vector<QUuid>> m_stacks;
std::set<QUuid> m_stacked;
std::map<QUuid, LayerBlendMode> m_stackModes;
// Every prepared layer's mode, as Swift's `blendMode`.
QHash<QUuid, LayerBlendMode> m_modes;
std::map<QUuid, LayerBlendMode> m_modes;
};
+154
View File
@@ -0,0 +1,154 @@
#include "Document/BrushStroke.h"
#include "Document/LayerMask.h"
#include "Document/LiveLayerMask.h"
#include "IO/ProjectStore.h"
#include "Rendering/LiveMaskRenderer.h"
#include <QtTest>
#include <cstdlib>
#include <new>
// A render's own containers may fail to allocate; none terminates.
namespace {
// This thread's allocations left before one fails; below zero, none.
thread_local long allowed = -1;
}
void *operator new(std::size_t size)
{
if (allowed == 0)
throw std::bad_alloc();
if (allowed > 0)
--allowed;
if (void *memory = std::malloc(size))
return memory;
throw std::bad_alloc();
}
void *operator new[](std::size_t size)
{
return operator new(size);
}
// New is malloc, so free pairs; GCC 16 cannot tell.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wmismatched-new-delete"
void operator delete(void *memory) noexcept
{
std::free(memory);
}
void operator delete(void *memory, std::size_t) noexcept
{
std::free(memory);
}
void operator delete[](void *memory) noexcept
{
std::free(memory);
}
void operator delete[](void *memory, std::size_t) noexcept
{
std::free(memory);
}
#pragma GCC diagnostic pop
namespace {
// Fails each allocation in turn; returns the call's count.
long starved(const std::function<void()> &call)
{
for (long count = 0; count < 100000; ++count) {
allowed = count;
try {
call();
allowed = -1;
return count;
} catch (const std::bad_alloc &) {
allowed = -1;
}
}
throw std::logic_error("the call never finished");
}
// Layers in two folders, every third clipped below.
std::vector<ProjectLayerRecord> layers(int count)
{
std::vector<ProjectLayerRecord> result;
const QUuid folders[2]{QUuid::createUuid(), QUuid::createUuid()};
for (const QUuid &folder : folders)
result.push_back({.id = folder, .name = QStringLiteral("Folder"), .isVisible = true, .transform = {.origin = {0, 0}, .size = {4, 4}},
.imageFile = std::nullopt, .isGroup = true});
for (int index = 0; index < count; ++index) {
ProjectLayerRecord layer{.id = QUuid::createUuid(), .name = QStringLiteral("Layer"), .isVisible = true,
.transform = {.origin = {0, 0}, .size = {4, 4}}, .imageFile = QStringLiteral("L.png"),
.parentID = folders[index % 2]};
if (index % 3 == 2)
layer.maskSourceID = result.back().id;
result.push_back(layer);
}
return result;
}
}
class RenderStarvationTests : public QObject {
Q_OBJECT
private slots:
void validatingTheGraphThrows();
void preparingStacksThrows();
void folderClipsThrow();
};
void RenderStarvationTests::validatingTheGraphThrows()
{
const std::vector<ProjectLayerRecord> records = layers(64);
QVERIFY(starved([&] { LiveMaskGraph::validate(records); }) > 64);
}
void RenderStarvationTests::preparingStacksThrows()
{
const std::vector<ProjectLayerRecord> records = layers(64);
std::vector<QUuid> ids;
for (const ProjectLayerRecord &layer : records)
ids.push_back(layer.id);
const long made = starved([&] {
LiveMaskRenderer live(
[&records](QUuid id) {
for (const ProjectLayerRecord &layer : records)
if (layer.id == id)
return layer.maskSourceID;
return std::optional<QUuid>();
},
[](QUuid, QPainter &, const QImage &) {});
live.prepareStacks(ids, [](QUuid) { return std::optional<QUuid>(); }, [](QUuid) { return LayerBlendMode::multiply; });
});
QVERIFY(made > 64);
}
void RenderStarvationTests::folderClipsThrow()
{
const std::vector<ProjectLayerRecord> records = layers(6);
std::vector<QUuid> ids;
for (const ProjectLayerRecord &layer : records)
ids.push_back(layer.id);
QImage device = BrushRaster::context(4, 4, false);
QPainter painter(&device);
int drawn = 0;
const long made = starved([&] {
drawn = 0;
FolderMaskClip::draw(
ids,
[&records](QUuid id) {
for (const ProjectLayerRecord &layer : records)
if (layer.id == id)
return layer.parentID;
return std::optional<QUuid>();
},
[](QUuid) { return std::optional<FolderMaskClip::Applier>([](const QPainter &, QImage &coverage) { coverage.fill(128); }); },
painter, [&drawn](QUuid, const QImage &) { ++drawn; });
});
QVERIFY(made > 2);
QCOMPARE(drawn, int(ids.size()));
}
QTEST_GUILESS_MAIN(RenderStarvationTests)
#include "RenderStarvationTests.moc"
+1
View File
@@ -101,6 +101,7 @@ compositor_test(ContentViewTests)
compositor_test(ContentViewStatusTests)
compositor_test(DistortTests)
compositor_test(DownsampleTests)
compositor_test(RenderStarvationTests)
compositor_test(DrawCoverageTests)
compositor_test(EditorSessionSignalTests)
compositor_test(EditorSessionTests)