diff --git a/AGENTS.md b/AGENTS.md
index 30e81b5..bbc1af9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,6 +2,10 @@
OmaPhoto is the Linux port of Compositor, the macOS app at https://github.com/robbietilton/Compositor. That repository's Swift tree (`Compositor/`, `CompositorTests/`) is the specification; this repository mirrors it. Its checkout at `../Compositor-spec`, pinned to the tag being ported (v1.3.3 since item 12), is where the Swift sources are read; never edit it for OmaPhoto work.
+## Designing or editing a project
+
+To make or change an image in a `.comp` project, the source is not needed: read [docs/writing-comp-files.md](docs/writing-comp-files.md). It covers the format, the rules that make a project load, and writing it safely while it is open. [docs/project-format.md](docs/project-format.md) describes the whole format.
+
## Trees
- The repository root: the app. C++20, Qt 6 Widgets (floor Qt 6.4), CMake, Qt Test. CPU raster only: QPainter plus the C kernels in `kernels/`, copied unchanged from Compositor's `Compositor/Rendering/*.c` and compiled in place.
@@ -143,6 +147,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Scrubbable titles in the sheets and panels (12.12c2, the rest of `2d36d6e` and `680a522`): each title (or channel letter) is a `NumericScrub` with Swift's sensitivity, range and step, and every scrub replaces its field's typing (`setModified(false)`, as the row's slider does) before the sheet shows the value. Effects' rows scrub to the typed limit (Swift's `inputRange`, the stroke to 500); Hue/Saturation's titles take their range from Colorize, the Canvas and Image Size titles theirs and their sensitivity from the unit, Relative, Lock, Resample and the resolution, so the owner hands new limits to `NumericScrub::reshape` on every synchronize, as SwiftUI's next render hands the modifier new ones. Levels' captions move a tone a level a point and gamma a hundredth, within 0 to 255 and 0.1 to 9.99; a filter's title moves the field's last decimal a point (`1 / 10^decimals`) over the linear range, logarithmic sliders included, as Swift's; the colour picker's R, G and B move whole levels. Canvas Size snaps to whole units in pixels, percent, inches or centimetres, its range Swift's `scrubRange` (a locked ratio keeps both sides within 1 and 30,000; Relative counts from the current size); Image Size's titles rest while a physical unit has no positive resolution (Swift's `canScrubDimensions`), resampling spans Swift's pixel and hundred-megapixel limits, a print size moves a hundredth of an inch (0.0254 cm) a point down to the pixels at 9,600 per inch, and Resolution scrubs whole pixels per inch from 1 to 9,600, through the same `setResolution` a typed one takes (a physical size keeps, its pixels scale). Camera Raw's rows opt in through `CameraRawRow::Spec::scrub` (the sensitivity), scrubbing through the row's typed setter, unrounded and unclipped, as Swift's bindings do: the main rows a point per last decimal, the curve's amounts and Refine Saturation, the HSL family titles (the Color and Point Color rows stay passive, as Swift's `colorSlider` and `pointSlider`), Detail, Optics, Geometry and Calibration a unit a point; Color Grading's Blending and Balance do not scrub, as in Swift. A title's double-click reset runs first and lets the press through, so the second press still scrubs, as Swift's simultaneous gesture does. Known: a sheet destroyed while one of its fields holds the focus has that field commit into the sheet's destroyed members as Qt moves the focus; the app always hides a panel's content before deleting it, so the focus leaves first, and `ScrubFixtures`' typing helper leaves the field before the sheet goes. Equivalent mutant: the colour channels' step of one (the picker's colour is quantized to 8 bits, so a channel lands on whole levels anyway). Tests: `ScrubbableSheetsTests`, `ScrubbableCameraRawTests` (each row group's value, setter, sensitivity and limits; the passive titles).
- Projects changed on disk (12.13a, Swift 1.3: `ed5ddea`, `cd4061a`, `cba6efc`): `IO/ProjectWatcher` twins Swift's class with a `QFileSystemWatcher` (inotify) on the package folder, its manifest and its images folder, where Swift opens kernel vnode sources: an event restarts a 300 ms single-shot delivery (Swift's coalescing) and re-arms every path from scratch each 100 ms, up to 20 times, until all three are watched (both timers are named children, `delivery` and `rearm`, so tests read them, and precise, since a coarse timer may fire 5% early where Swift's sleeps never end early; so is the controller's recheck); Qt's inotify engine subscribes to no `IN_MOVE_SELF`, where Swift's sources watch `.rename`, so a package exchanged for another one kept aside would pass unseen: the watcher also watches the package's parent folder and counts an event there only when the package path's device and inode differ from the ones it armed on, because a package swapped in (our store's `renameat2` exchange, another app's rename) retires the watched inodes. `IO/ProjectDigest` is a namespace: SHA-256 (`QCryptographicHash`) over the manifest's bytes, then each regular file in `images/` (links and folders skipped, names sorted by UTF-16 units) as its UTF-8 name and its size as a little-endian 64-bit number; a manifest that cannot be opened, or reads short of its size (a failed read, as Swift's `Data(contentsOf:)` throws), or an image gone mid-listing, throws, which the callers read as no digest (Swift's `try?`). `ProjectController+ExternalChanges.cpp` holds Swift's extension, its state `ExternalChangeState` (`ProjectController+ExternalChanges.h`) a public member, as Swift's: the watcher, the known digest, `checking`, `pending`, `saving`, the recheck `QTimer` (single-shot, a child of the controller), `recheckAttempt` and `reloadCount`. Swift's async loop runs as callbacks: each pass takes the pending flag, stops without a path, a document or while our own save writes, computes the digest off the UI thread (a `QFutureWatcher` child of the controller, so an answer dies with it), passes over a digest that matches, backs off at 250 ms × 2ⁿ (n to 7) while the project cannot start an operation, a transform is open or the workspace manages, reloads an unmodified document, and for unsaved work asks Swift's alert (Revert, Keep Mine) over the window, only for the front tab; without a window, or behind another tab, the change waits for `resumeExternalChangeCheck`, which the workspace's `select` calls. Keep Mine remembers the new digest. A reload (`reloadFromDisk`) marks the project busy, loads through the store off the UI thread, lands only on the same path with a document still open, calls `EditorSession::reloadProject`, remembers the digest as loaded and then counts the reload; a package that fails to load leaves the document alone and logs on `omaphoto.io`. `reloadProject` installs the snapshot and puts back the viewport, the collapsed folders that still exist, and the active layer with the selection's surviving members when the active layer still exists, as Swift's; history starts over. An open (the controller's own, which the workspace's tabs use) and a save remember the digest, then watch the package; a save marks `saving` from before its worker starts until the digest of what it wrote is remembered, or, when it fails, until its alert is answered (Swift's `defer`), so its own events pass; a new canvas and a close stop the watch. Swift's decoding from bytes in memory (`cba6efc`) has no twin: `QImageReader` decodes a PNG into memory the image owns, never mapped from the file, so a later save that replaces the file cannot reach an image kept for undo. Equivalent mutants: the try count's reset on a new event (a missing folder's creation is itself an event on the package, which re-arms), and the reload's two log lines. Tests: `ExternalChangeTests` (Swift's six; the revert question both ways, Keep Mine remembering, a background tab asking when brought forward, a busy project backing off then reloading, new canvas and close stopping the watch and its recheck, one check at a time, a check ending without a project or during our save), `ExternalChangeGateTests` (a failed save's guard, a managing workspace, the back-off's intervals, a load landing elsewhere, the reload's whole view and last signal, an unreadable manifest, Save As and open moving the watch, a second change while the question waits), `ExternalChangeFixtures.h`, `ProjectDigestRaceTests` (a binary of its own: `readdir` and `readdir64` take libc's place and remove an image as its folder's listing ends, so the digest's throw is ordered, not raced), `ProjectWatcherTests` (one report a burst, the quiet time from the last event, twenty re-arm tries, each watch speaking alone, an exchange keeping the old package, precise timing, re-arming and giving up, the digest's exact input and order, an unreadable manifest).
- Editing while a project saves (12.13b, Swift 1.3: `940e188`): a save holds the tools only while it captures the document (`prepareSave`, Swift's: the revision, the snapshot, and the Save panel when there is no path yet or for Save As), then reserves the writer and only then frees the project, so no save asked in that signal slips in beside it, and writes in the background; a capture finds no document when Undo took it while the save waited (Swift's `guard let snapshot`), and the save answers false; `DocumentHistory::currentRevision` and `markSaved(QUuid)` twin Swift's, so only the captured version counts as saved: edits made while it writes stay modified, and undoing back to it is unmodified. Swift's `writing` task is `m_writing` with a list of waiters: `finishWriting` runs its callback at once when nothing writes, else when the write lands, each waiter posted to the event loop and passed through `finishWriting` again, so one writer runs at a time where Swift's store actor serialises the disk; `save` waits there before `begin`, so a second save writes what changed since, and `confirmReplacement` waits there first, so a close, a quit, a new canvas or an open never cuts a file short and asks only once the write is whole; an open also waits before its first read, and so does an external reload (12.13a's `reloadFromDisk`), since the twin's store is no actor and could read a package mid-save: a Revert answered while our save writes loads what we wrote. A digest that comes back while our save writes is looked at again once the write lands (pending, through `finishWriting`): after a save it matches what we wrote, after a failed one the change is taken up; Keep Mine remembers its digest only when no save landed while the question was up. A save queued behind a quit that already holds the tools is refused, as Swift's `begin` refuses it. A save that fails frees its waiters after its alert is answered. The refusal of a completed Save As name that exists sits in `prepareSave`, beside the panel. Swift has no tests for this commit: `ProjectSaveWhileEditingTests` pins it (edits while writing, a second save, a new canvas waiting, the panel alone holding the tools, a failed write, the last signal), and, over `tests/SaveHooks.h` (`fsync` slowed and counting overlapping writes, `renameat2` held at a package's swap, `readdir` and `readdir64` held at one folder, the store's loads counted from its log, every hold let go on cleanup), `ProjectSaveOrderTests` (a reopen reading nothing while the write is held, a Revert mid-save, the guard through the save's digest, Canvas Size and Image Size each committed before and after the held write lands, a digest out as our save begins, a failed save keeping an external change in line, a second save waiting through the first save's digest, Keep Mine after a save) and `ProjectSaveQueueTests` ( three saves one at a time, a save asked as the tools free, a quit waiting, a document undone away, a failed write holding its waiters, a reopen, a tab closing, a controller gone mid-write, an export beside a write).
+- The guide for agents (12.13c, Swift 1.3: `c317306`): `docs/writing-comp-files.md` twins Swift's guide and `docs/project-format.md` Swift's format page, both adapted to Linux (a project is a folder, the example lives under `~/Pictures`, the save swaps folders with `renameat2`, a replaced project folder is noticed too, sources named by the twin's files). Swift's top-level `AGENTS.md` for agents becomes the section at the head of this file. README gains Swift's lines (scrubbing, saving while editing, projects changed on disk, working with AI agents). The format page's claims follow the twin, where Swift's page is out of date: the pixel budgets scale with memory, the save's exchange has a two-step fallback, only `maskPlacement` needs a mask, the common adjustment fields are required and later kinds default to their own values, folders pass adjustments through, text becomes pixels after Canvas Size, Image Size and Crop, and a missing face may fall to another family. `WritingProjectsGuideTests` reads the guide itself: the minimal manifest loads with the fields it shows (visibility, opacity, blend, placement, the image named after its layer), the Curves layer's points are pinned and its rendered pixel is the background through those curves, removing any common adjustment field refuses the project, a folder passes an Invert through, `hsvSettings` wins over the legacy Hue/Saturation fields (the guide says where to edit a saved one), and the guide's lists of blend modes, adjustment kinds and samplings equal the twin's `rawValue`s. The guide asks for 8-bit RGBA layers and accepts what the store accepts: RGB, grayscale and lower-depth grayscale layers, never 16 bits. `ExternalChangeTests::theGuidesSameSizeRecipeReloads` runs the same-size recipe against a watched project.
- Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to
"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip`, a spacer and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
diff --git a/README.md b/README.md
index 85d9f1c..dcc6e44 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,13 @@ It is a Linux port of [Compositor](https://github.com/robbietilton/Compositor) b
- 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, HEIC, Photoshop files and camera RAW
+- Drag a number's label to scrub its value
+- Keep working while a project saves
+- A project another app changes on disk reloads in place; unsaved work is never replaced without asking
+
+### Works with AI agents
+
+AI agents and scripts can build and edit projects directly: a `.comp` is a folder of PNG layers and a manifest, and an open project updates live as it's written. See [Writing OmaPhoto projects](docs/writing-comp-files.md) and the [project format](docs/project-format.md).
## Install
diff --git a/TASKS.md b/TASKS.md
index 6db1207..85ccbed 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -388,7 +388,7 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- [x] 12.12c2 Numeric label scrubbing in the sheets and panels: Canvas Size, Image Size, the colour picker, effects, filters, Hue/Saturation, Levels, Camera Raw. Codex review: 3 rounds. Round 1: every Mixer row scrubbed where Swift scrubs the HSL families alone, and test gaps (Image Size limits, Camera Raw bindings, filter decimals, centimetres). Round 2: test gaps (a held drag across `reshape`, titles enabled again, Alt-held Camera Raw scrubs). Round 3 passed.
- [x] 12.13a Version 1.3: reloading a project changed on disk (`ed5ddea`, merged in 1.3), its manifest-and-sizes fingerprint (`cd4061a`), images decoded from memory (`cba6efc`). Codex review: 3 rounds. Round 1: a package exchanged and kept aside unseen (Qt watches no IN_MOVE_SELF), a failed manifest read still digested, coarse timers, a failed save's guard released early, and test gaps. Round 2: test gaps (the watch moving, a second change while asking, quiet-time and re-arm counts, an ordered test for a vanished image). Round 3 passed.
- [x] 12.13b Keep editing while a project saves (`940e188`). Codex review: 3 rounds. Round 1: overlapping writes and a quit before a write finished, a queued save crashing after Undo took the document, a reopen reading a package mid-save, and test gaps. Round 2: an external reload reading mid-save, and test gaps. Round 3: a failed save stranding an external change already seen, and test gaps. All fixed; closed after the third.
-- [ ] 12.13c The guide for AI agents writing projects (`c317306`), README's lines.
+- [x] 12.13c The guide for AI agents writing projects (`c317306`), README's lines. Codex review: 3 rounds. Round 1: thirteen claims Swift's pages make that the code does not keep (the same-size recipe, replacing a folder, required adjustment fields, folder pass-through, budgets, the save's fallback, text after resizes, fonts, masks, Alt-click) and weak example tests. Round 2: Hue/Saturation's `hsvSettings` precedence, the sampling list, PNG depths, a checked read. Round 3 passed.
- [ ] 12.14 Versions 1.3.1–1.3.2: quitting and closing while text is edited, the I-beam over the canvas, File > Open Recent, Image Size keeping print sizes, Camera Raw's Color Grading layout, coloured slider tracks with double-click reset, colouring only the selected letters, sampling keeping a panel's focus, filters waiting for text editing to finish.
- [ ] 12.15 Version 1.3.3: the Dither filter.
- [ ] 12.16 Release OmaPhoto 1.3.3, the version Compositor carries (the user's decision, 2026-09-26: OmaPhoto's version follows Compositor's): README's features, AGENTS.md, the distro checks, packages, the install scripts rerun.
diff --git a/docs/project-format.md b/docs/project-format.md
new file mode 100644
index 0000000..f53ed44
--- /dev/null
+++ b/docs/project-format.md
@@ -0,0 +1,50 @@
+# OmaPhoto project format, versions 1–9
+
+OmaPhoto reads and writes [Compositor](https://github.com/robbietilton/Compositor)'s project format; this page follows Compositor's own description.
+
+A `.comp` project is a folder (a document package on macOS) containing `manifest.json` and an `images/` directory of `.png` assets.
+
+The manifest identifies `com.compositor.project`, version `9` for new saves (versions `1`–`8` remain readable), and the sRGB working space. It stores document UUID, pixel dimensions, active layer UUID, and layers in bottom-to-top order. Each layer stores its UUID, name, visibility, transform (origin, size, clockwise rotation, flips, sampling), and optional image filename. Blank layers have no image asset.
+
+Embedded PNGs preserve source pixels and transparency; transforms remain separate. Projects survive moving or deleting imported source photos. Saving writes a hidden sibling folder and syncs it. A first save renames it into place; a later save swaps it with the old project atomically (`renameat2` with `RENAME_EXCHANGE`). Where the file system knows no exchange, the old project is moved aside and the new one moved in, two steps with a moment between them and a warning in the log, and the old copy is kept until the new one is synced. Unsupported versions, invalid metadata, missing assets, unsafe paths, and oversized data are rejected before replacing the live document.
+
+Limits: 30,000 pixels per canvas/image side, a pixel budget for layer images and another as large for masks (a sixteenth of the computer's memory in pixels, between 200 and 800 million: `src/Document/DocumentLimits.cpp`), 200 million pixels for any one surface, 10,000 layers, 4 MiB manifest, 512 MiB per encoded asset. See `src/IO/ProjectStore.cpp` for validation.
+
+Undo history and viewport are session-only. Opening fits the canvas, restores selection, and starts with clean history. Future editable features must extend the schema and round-trip tests. PNG export is a flattened derivative and does not mark project edits saved.
+
+Image Size adds optional `resolution` (pixels/inch, 1–9600). Older manifests without it default to 72. This additive field retains version 1 compatibility. Both PNG and JPEG exports include document resolution metadata. Resampling stores the new layer pixels and bounds; undo retains the prior sources only during the current session.
+
+Version 2 adds optional `parentID` and `isGroup` on layer records. A group has no image file. Root nodes have no parent; children refer to an existing group. Array order defines bottom-to-top sibling order; renderers traverse each group as a contiguous subtree. Visibility is inherited without changing child flags. Cycles, missing/non-group parents, image-bearing groups, and nesting beyond 64 ancestor levels are rejected. Group ancestors permit room for leaf nodes at the deepest level. Group metadata survives image/canvas resizing and cropping. Older app builds reject version 2 rather than misrender grouped documents. Collapse state is not serialized.
+
+Version 3 adds optional per-layer `opacity` (finite 0–1) and `blendMode` (Normal, Multiply, Screen, Overlay, Darken, Lighten, Difference, Color Dodge, Color Burn). Missing fields default to full opacity and Normal. Group records required those defaults until version 8, which lets a folder carry its own opacity; a folder's opacity multiplies into every layer inside it, while its blend mode stays Normal because folders are pass-through. Effects are applied during compositing and retained as metadata when resizing sources. Files declaring older versions cannot contain non-default appearance values.
+
+Version 4 adds optional `maskFile` and `maskEnabled` fields to individual layers. Mask filenames must be `.mask.png` under `images/`; enabled defaults to true when a mask exists. Records without masks omit both fields. Groups cannot carry masks in this version. Files declaring versions 1–3 cannot contain mask metadata.
+
+Masks store 8-bit grayscale coverage without alpha (white reveals, black hides). Their normalized extent matches the image’s local rectangle, so the same layer transform applies to both. A uniform 1×1 mask is valid and avoids allocating full-resolution pixels before painting. Nonuniform mask pixels and a thumbnail are immutable assets shared by history. Image Size resamples them with the image transform; Canvas Size and Crop preserve their pixels. Mask pixels count toward their own budget, as large as the image budget; per-side and per-file limits also apply to masks. Disabled masks remain embedded and editable but do not affect compositing. Image-versus-mask target selection is session-only and reopens on image pixels.
+
+Version 5 adds optional `maskSourceID`: the UUID of a non-group layer supplying live alpha in document coordinates. It multiplies the target’s alpha alongside its enabled raster mask. Source pixels, transform, opacity, raster mask and upstream live masks contribute coverage; visibility and RGB color do not. Sources remain independent layers. Missing references, self-links, cycles, group endpoints and chains over 256 nodes are rejected. Deletion can bake the live coverage into dependent image pixels (retaining their raster masks) or remove the links, as one undoable operation. Links survive image/canvas resize and crop. Older versions default to no live mask; older app builds reject v5.
+
+UI terminology: these alpha links are clipping masks. Alt-click in a row's clipping strip (Option-click on macOS) assigns the lower sibling’s base or releases the connection. Multiple clipped layers share one base, show indented above it, and release when moved outside the contiguous stack. The underlying `maskSourceID` representation is unchanged.
+
+Version 6 allows `maskFile` and `maskEnabled` on group records. A folder has no image, so its mask covers the folder's own transform rectangle (the canvas size when the folder was created); Image Size resamples it through that transform, and Canvas Size and Crop preserve its pixels, exactly as for layer masks. Groups are pass-through, so an enabled folder mask multiplies the coverage of every descendant layer, together with that layer's own mask and any enclosing folders' masks; clipping-mask coverage is unaffected. Files declaring versions 1–5 cannot give a group a mask, and older app builds reject v6.
+
+Version 7 adds adjustment layers: a layer record with an optional `adjustment` object and no `imageFile`. Adjustment layers cannot be groups and cannot carry `text`; like any layer they take a transform, opacity, blend mode, raster mask and clipping link, and they affect everything composited below them. Folders are pass-through, so an adjustment inside a folder also changes what lies below the folder; only a clipping stack gives its members a backdrop of their own. `kind` is one of `Hue/Saturation`, `Levels`, `Curves`, `Exposure`, `Gradient Map`, `Grain`, `Invert`, `Black & White` and `Color Balance` (version 9 adds three more). Every adjustment, whatever its kind, must carry `hue`, `saturation`, `lightness`, `colorize`, `levels` and `curves`; the other kinds' settings are optional, and a missing one takes that kind's own defaults (not always an identity: Gaussian Blur's radius defaults to 10). The settings: `hue` (±360), `saturation` and `lightness` (±100), `colorize` and `hsvSettings` for Hue/Saturation (`hsvSettings`, when present, wins over the other four); `levels` (four channel ranges, RGB then red, green, blue); `curves` (four channel point lists); `exposureSettings`; `gradientMapSettings` (`shadows`, `highlights`, `reversed`); `grainSettings`; `blackWhiteSettings`; `colorBalanceSettings`. Out-of-range or non-finite values are rejected. Files declaring versions 1–6 cannot contain adjustment records, and older app builds reject v7. See `src/Document/LayerAdjustment.cpp` for the exact ranges.
+
+Version 8 lets a folder carry its own `opacity`, which multiplies into every layer inside it; a folder's blend mode stays Normal because folders are pass-through (files declaring 1–7 require folders at full opacity). It also adds an optional top-level `guides` array of alignment guides, each with `id`, `axis` (`horizontal` or `vertical`) and `position` in document pixels (finite, at most 1,000,000 in magnitude). At most 1,000 guides are stored; files declaring 1–7 cannot contain guides. Guides survive Canvas Size and Crop by offsetting with the canvas.
+
+Version 9 adds three adjustment kinds that sample neighboring pixels: `Gaussian Blur` (`blurRadius`, 0.1–250 document pixels), `Motion Blur` (`motionAngle`, −90 to 90 degrees, and `motionDistance`, 1–2000) and `Add Noise` (`noiseAmount`, 0.1–400, `noiseGaussian`, `noiseMonochromatic` and `noiseSeed`, so the pattern is stable between sessions). Files declaring 1–8 cannot contain these kinds; the earlier adjustment kinds remain valid at version 7 and up.
+
+### Additive layer fields
+
+Later fields are optional and not gated on the version, so older readers ignore them and keep the pixels or the linked mask as they were:
+
+- `maskPlacement` and `maskLinked`: an unlinked mask (`maskLinked` false; missing means linked) keeps its own transform in `maskPlacement`, a document-space rectangle like the layer transform, and no longer follows the layer when it moves. `maskPlacement` requires a `maskFile`; `maskLinked` without a mask is accepted and changes nothing.
+- `shape`: a layer made with the Shape tool keeps its style (`kind`, `red`/`green`/`blue`, `cornerRadius` in document pixels, and for lines `lineWidth` plus `start` and `end` as fractions of the layer box) so it redraws cleanly when scaled. Its PNG is still an ordinary raster; once anything else changes those pixels the metadata is dropped.
+
+### Editable text
+
+Pixel layer records may include optional `text` metadata: content, PostScript font name, font size in pixels, RGB color, alignment, tracking, line spacing and optional `boxSize` paragraph bounds. Text wraps inside these bounds; changing them reflows the text without scaling the font. The PNG remains the display and export fallback. Older readers ignore this metadata. Transforms, duplication and masks preserve it. Canvas Size, Image Size and Crop rebuild the open document without it, so there the text becomes pixels; destructive pixel operations also rasterize text and omit the metadata on the next save. A missing face is replaced through Qt and fontconfig when edited, which can pick another family, while the saved PNG preserves the original appearance until then.
+
+### Layer effects
+
+An optional `effects` record contains independent `stroke`, `shadow`, `colorOverlay`, `innerShadow`, `outerGlow` and `innerGlow` records. Stroke carries a size (0–500 layer pixels), a color, an opacity and an `inside` flag choosing which side of the edge it sits on; drop shadow and inner shadow each carry an angle, a distance, a blur, a color and an opacity; color overlay carries a color and an opacity; outer glow and inner glow each carry a size (0–500 layer pixels), a color and an opacity. Each supports optional `enabled` visibility (missing means visible); hidden effects keep all parameters and remain listed under their layer. Effects, including their visibility, are saved and participate in document undo. Canvas previews run on a serial background worker with a shared pixel budget; exports render the full-resolution effects. A record omitting an effect means that layer does not have it, so older readers see the effects they understand and ignore the rest.
diff --git a/docs/writing-comp-files.md b/docs/writing-comp-files.md
new file mode 100644
index 0000000..16dc82d
--- /dev/null
+++ b/docs/writing-comp-files.md
@@ -0,0 +1,137 @@
+# Writing OmaPhoto projects (for AI agents and scripts)
+
+An OmaPhoto project (`.comp`) is a folder of PNG layer images plus a `manifest.json`: the same format as [Compositor](https://github.com/robbietilton/Compositor)'s, so a project moves between the two apps. Anything that can write files can build or edit one, and OmaPhoto updates the open canvas as the files change. No plugin or API is involved.
+
+## Try it
+
+1. Open a project in OmaPhoto 1.3 or later (save a new canvas somewhere, e.g. `~/Pictures/demo.comp`), and keep it open.
+2. Ask an AI agent that can edit files on your computer (Claude Code, Codex and the like):
+
+ > Read docs/writing-comp-files.md in github.com/ZacharyZhang-NY/OmaPhoto, then design a moody night scene in ~/Pictures/demo.comp. Work in steps, one or two layers at a time.
+
+3. Watch the canvas. Each time the agent writes the project, OmaPhoto reloads it, usually within half a second.
+
+What you get are ordinary layers: select them, change their opacity or blend mode, paint on their masks, save.
+
+## The project folder
+
+```
+Example.comp/
+├── manifest.json
+└── images/
+ ├── 6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.png a layer's pixels
+ └── 6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.mask.png its mask (optional)
+```
+
+A minimal manifest with one full-canvas image layer:
+
+```json
+{
+ "format": "com.compositor.project",
+ "version": 9,
+ "colorSpace": "sRGB",
+ "documentID": "0C5E7A91-3B2D-4F6A-8E1C-9D0B7A6F5E4D",
+ "width": 1920,
+ "height": 1080,
+ "resolution": 72,
+ "activeLayerID": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F",
+ "layers": [
+ {
+ "id": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F",
+ "name": "Background",
+ "imageFile": "6F1D3C2A-0B7E-4E8A-9C4D-2A1B3C4D5E6F.png",
+ "isVisible": true,
+ "isGroup": false,
+ "opacity": 1,
+ "blendMode": "Normal",
+ "transform": {
+ "origin": [0, 0],
+ "size": [1920, 1080],
+ "rotation": 0,
+ "flipX": false,
+ "flipY": false,
+ "sampling": "High quality"
+ }
+ }
+ ]
+}
+```
+
+- `layers` runs **bottom to top**: the last layer draws on top.
+- Keep `documentID` as it is when editing an existing project.
+- `transform` places the layer in document pixels: `origin` is its top-left corner, `size` its width and height, `rotation` is in degrees, clockwise. The image is stretched to `size`, so a layer can be smaller than the canvas (a cut-out placed with `origin`) or scaled.
+- `sampling` is `"High quality"`, `"Smooth"` or `"Nearest"`.
+- `opacity` runs from 0 to 1.
+
+## Rules that matter
+
+Break one of these and OmaPhoto refuses the whole file **without any message**: the open canvas just stays as it was. If nothing updates, check these first.
+
+- **Image files are named after their layer.** A layer with `"id": "6F1D…"` must use `"imageFile": "6F1D….png"`, and a mask `"maskFile": "6F1D….mask.png"`, with the ID in uppercase as written in the manifest. One ID per layer, unique in the project.
+- **Images are PNGs** in `images/`, at most 8 bits a channel: 16-bit PNGs are refused. Write layer images as 8-bit RGBA (RGB, grayscale and lower-depth grayscale load too, converted); masks are 8-bit grayscale (white shows the layer, black hides it).
+- **Blend modes are spelled exactly** as OmaPhoto names them: `Normal`, `Darken`, `Multiply`, `Color Burn`, `Linear Burn`, `Lighten`, `Screen`, `Color Dodge`, `Linear Dodge (Add)`, `Overlay`, `Soft Light`, `Hard Light`, `Vivid Light`, `Linear Light`, `Pin Light`, `Hard Mix`, `Difference`, `Exclusion`, `Subtract`, `Divide`, `Hue`, `Saturation`, `Color`, `Luminosity`.
+- **Every `imageFile` and `maskFile` the manifest names is in place**, and the manifest is valid JSON. Blank layers, folders and adjustment layers have no `imageFile`.
+
+## Writing safely while the project is open
+
+OmaPhoto reads the project as soon as it changes, so never leave it half written:
+
+1. Write any new or changed PNGs into `images/` first.
+2. Then write the manifest to a temporary file inside the project folder (for example `.manifest.json.tmp`) and rename it over `manifest.json`. A rename is atomic: OmaPhoto sees either the old manifest or the new one, never part of one.
+
+To change an existing layer, keep its `id` and overwrite its PNG, then rewrite the manifest. The layer updates in place, in the same spot in the stack.
+
+Remove images you no longer reference once the manifest no longer lists them.
+
+## What the open app does
+
+- It reloads about a third of a second after writes stop. Several writes in quick succession arrive as one update, so pause briefly between steps if a viewer should see each one.
+- A reload keeps the zoom, scroll and selection, but clears undo, as reopening a file does.
+- If the person has unsaved changes of their own, OmaPhoto asks them to revert to your version or keep theirs, and never replaces their work silently.
+- A write that fails to load is ignored until the next change, so a mistake you then fix will still show up.
+- Changes are noticed from the manifest's contents and from each image's name and size. Rewriting a PNG with different pixels changes its size in practice, but if you replace an image with one of exactly the same byte size, also change the manifest's bytes (any valid change will do, even added whitespace): rewriting identical bytes is not noticed.
+- Replacing the whole project folder at once is noticed too. A populated folder cannot be renamed over another on Linux; exchange the two instead with `renameat2(RENAME_EXCHANGE)` (Python's `os.rename` cannot), then remove the old one.
+
+## Masks
+
+Add a mask to any layer with `"maskFile": ".mask.png"` and `"maskEnabled": true`. The mask is stretched over the layer's box, so it usually has the same pixel size as the layer's image; a 1×1 mask covers the whole layer with one value. Soft grays give soft edges.
+
+## Adjustment layers
+
+An adjustment layer has an `adjustment` object and no `imageFile`, and it affects everything below it. Every kind carries identity `levels` and `curves` blocks, plus its own settings. A warming Curves layer:
+
+```json
+{
+ "id": "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D",
+ "name": "Warm Grade",
+ "isVisible": true,
+ "isGroup": false,
+ "opacity": 1,
+ "blendMode": "Normal",
+ "transform": { "origin": [0, 0], "size": [1920, 1080], "rotation": 0, "flipX": false, "flipY": false, "sampling": "High quality" },
+ "adjustment": {
+ "kind": "Curves",
+ "hue": 0, "saturation": 0, "lightness": 0, "colorize": false,
+ "levels": { "channel": "RGB", "ranges": [
+ { "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
+ { "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
+ { "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 },
+ { "black": 0, "gamma": 1, "white": 255, "outputBlack": 0, "outputWhite": 255 } ] },
+ "curves": { "channel": "RGB", "channels": [
+ [ { "x": 0, "y": 0 }, { "x": 255, "y": 255 } ],
+ [ { "x": 0, "y": 0 }, { "x": 120, "y": 147 }, { "x": 255, "y": 255 } ],
+ [ { "x": 0, "y": 0 }, { "x": 100, "y": 114 }, { "x": 255, "y": 255 } ],
+ [ { "x": 0, "y": 0 }, { "x": 115, "y": 97 }, { "x": 255, "y": 238 } ] ] }
+ }
+}
+```
+
+- `ranges` and `channels` run RGB, then red, green, blue. Curve points run from x 0 to x 255, in increasing x.
+- `kind` is one of `Hue/Saturation`, `Levels`, `Curves`, `Exposure`, `Gradient Map`, `Grain`, `Invert`, `Black & White`, `Color Balance`, `Gaussian Blur`, `Motion Blur`, `Add Noise`.
+- For Hue/Saturation, set `hue`, `saturation` and `lightness` on the adjustment itself when it has no `hsvSettings`. A Hue/Saturation layer saved by OmaPhoto carries `hsvSettings`, which wins over those three: there, edit its Master entry in `hsvSettings.adjustments`, a list of range names each followed by its values (for example `["Master", { "hue": 120, "saturation": 0, "lightness": 0 }]`), and leave the other ranges as they are. Color Balance takes a `colorBalanceSettings` object (`shadowCyanRed`, `shadowMagentaGreen`, `shadowYellowBlue`, and the same for `mid` and `highlight`, each −100 to 100, plus `preserveLuminosity`).
+- For the other kinds, the easiest way to get the exact shape is to add one in OmaPhoto, save, and copy it from that project's manifest.
+
+## More
+
+- Folders, text layers, layer effects and everything else the format holds: [project-format.md](project-format.md).
+- Limits: canvases up to 30,000 pixels on a side; layers and masks count toward a memory budget that scales with the computer's memory.
diff --git a/tests/ExternalChangeTests.cpp b/tests/ExternalChangeTests.cpp
index eea1a10..fc15946 100644
--- a/tests/ExternalChangeTests.cpp
+++ b/tests/ExternalChangeTests.cpp
@@ -1,4 +1,5 @@
#include "ExternalChangeFixtures.h"
+#include
#include
// A project something else writes while it is open.
@@ -15,6 +16,7 @@ private slots:
void aBackgroundTabAsksWhenItComesToTheFront();
void aBusyProjectWaitsThenReloads();
void closingOrANewCanvasStopsTheWatch();
+ void theGuidesSameSizeRecipeReloads();
void aNewCanvasDropsAWaitingRecheck();
void oneCheckRunsAtATime();
void aCheckWithoutAProjectOrDuringOurSaveEnds();
@@ -334,5 +336,33 @@ void ExternalChangeTests::aCheckWithoutAProjectOrDuringOurSaveEnds()
QVERIFY(!idle.externalChanges.pending);
}
+// docs/writing-comp-files.md: a same-size image needs new manifest bytes.
+void ExternalChangeTests::theGuidesSameSizeRecipeReloads()
+{
+ QTemporaryDir root;
+ const QString path = savedProject(root);
+ EditorSession session;
+ const auto controller = opened(session, path);
+ const ImageLayer base = session.document().value().layers[0];
+ const QString file = path + QStringLiteral("/images/") + base.id.toString(QUuid::WithoutBraces).toUpper() + QStringLiteral(".png");
+ const qint64 size = QFileInfo(file).size();
+ QImage blue(8, 6, QImage::Format_RGBA8888);
+ blue.fill(QColor(60, 120, 200));
+ QByteArray bytes;
+ QBuffer buffer(&bytes);
+ QVERIFY(buffer.open(QIODevice::WriteOnly) && blue.save(&buffer, "PNG"));
+ QCOMPARE(qint64(bytes.size()), size);
+ rewrite(file, bytes);
+ // The same manifest bytes again: nothing is noticed.
+ const QString manifest = path + QStringLiteral("/manifest.json");
+ rewrite(manifest, contents(manifest));
+ settle();
+ QCOMPARE(controller->externalChanges.reloadCount, 0);
+ // Any change to its bytes, even whitespace: reloaded.
+ rewrite(manifest, contents(manifest) + "\n");
+ QTRY_COMPARE_WITH_TIMEOUT(controller->externalChanges.reloadCount, 1, 4000);
+ QCOMPARE(session.document().value().layers[0].asset.value().image().pixelColor(0, 0), QColor(60, 120, 200));
+}
+
QTEST_MAIN(ExternalChangeTests)
#include "ExternalChangeTests.moc"
diff --git a/tests/Tests.cmake b/tests/Tests.cmake
index 1953367..e9bfd92 100644
--- a/tests/Tests.cmake
+++ b/tests/Tests.cmake
@@ -157,6 +157,7 @@ compositor_test(ExternalChangeGateTests)
compositor_test(ProjectSaveWhileEditingTests)
compositor_test(ProjectSaveQueueTests)
compositor_test(ProjectSaveOrderTests)
+compositor_test(WritingProjectsGuideTests)
compositor_test(ProjectWatcherTests)
compositor_test(ProjectDigestRaceTests)
compositor_test(ProjectCopyTests)
diff --git a/tests/WritingProjectsGuideTests.cpp b/tests/WritingProjectsGuideTests.cpp
new file mode 100644
index 0000000..ac1886a
--- /dev/null
+++ b/tests/WritingProjectsGuideTests.cpp
@@ -0,0 +1,242 @@
+#include "Document/LayerAdjustment.h"
+#include "Document/LayerAppearance.h"
+#include "Document/LayerTransform.h"
+#include "IO/ImageExporter.h"
+#include "IO/ProjectStore.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// docs/writing-comp-files.md: its examples load, its names are ours.
+namespace {
+QString guide()
+{
+ QFile file(QStringLiteral(QT_TESTCASE_SOURCEDIR "/docs/writing-comp-files.md"));
+ if (!file.open(QIODevice::ReadOnly))
+ throw std::runtime_error("the guide is missing");
+ return QString::fromUtf8(file.readAll());
+}
+
+// The guide's ```json blocks, in order.
+QList examples()
+{
+ QList found;
+ static const QRegularExpression block(QStringLiteral("```json\\n(.*?)```"), QRegularExpression::DotMatchesEverythingOption);
+ for (auto match = block.globalMatch(guide()); match.hasNext();) {
+ QJsonParseError error;
+ const QJsonDocument parsed = QJsonDocument::fromJson(match.next().captured(1).toUtf8(), &error);
+ if (error.error != QJsonParseError::NoError)
+ throw std::runtime_error("a guide example is no JSON: " + error.errorString().toStdString());
+ found << parsed.object();
+ }
+ return found;
+}
+
+// The backticked names on the line that begins with `lead`.
+QSet named(const QString &lead)
+{
+ static const QRegularExpression ticked(QStringLiteral("`([^`]+)`"));
+ for (const QString &line : guide().split(QLatin1Char('\n'))) {
+ if (!line.startsWith(lead))
+ continue;
+ QSet names;
+ for (auto match = ticked.globalMatch(line.mid(lead.size())); match.hasNext();)
+ names.insert(match.next().captured(1).remove(QLatin1Char('"')));
+ return names;
+ }
+ throw std::runtime_error("no guide line begins " + lead.toStdString());
+}
+
+// A project folder: `manifest`, and a PNG per image.
+QString project(const QTemporaryDir &root, const QJsonObject &manifest)
+{
+ const QString path = root.filePath(QStringLiteral("Guide.comp"));
+ QDir().mkpath(path + QStringLiteral("/images"));
+ QFile out(path + QStringLiteral("/manifest.json"));
+ if (!out.open(QIODevice::WriteOnly) || out.write(QJsonDocument(manifest).toJson()) < 0)
+ throw std::runtime_error("the manifest was not written");
+ out.close();
+ QImage image(1920, 1080, QImage::Format_RGBA8888);
+ image.fill(QColor(20, 30, 60));
+ for (const QJsonValue &layer : manifest.value(QStringLiteral("layers")).toArray()) {
+ const QString file = layer.toObject().value(QStringLiteral("imageFile")).toString();
+ if (!file.isEmpty() && !image.save(path + QStringLiteral("/images/") + file, "PNG"))
+ throw std::runtime_error("an image was not written");
+ }
+ return path;
+}
+// The guide's two examples as one project, Curves on top.
+QJsonObject withCurves()
+{
+ const QList blocks = examples();
+ if (blocks.size() != 2)
+ throw std::runtime_error("the guide should hold two examples");
+ QJsonObject manifest = blocks[0];
+ QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray();
+ layers.append(blocks[1]);
+ manifest.insert(QStringLiteral("layers"), layers);
+ return manifest;
+}
+
+// The Curves layer turned `kind`, with `drop` taken out.
+QJsonObject adjusted(const QString &kind, const QString &drop = QString())
+{
+ QJsonObject manifest = withCurves();
+ QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray();
+ QJsonObject layer = layers[1].toObject();
+ QJsonObject adjustment = layer.value(QStringLiteral("adjustment")).toObject();
+ adjustment.insert(QStringLiteral("kind"), kind);
+ adjustment.remove(drop);
+ layer.insert(QStringLiteral("adjustment"), adjustment);
+ layers[1] = layer;
+ manifest.insert(QStringLiteral("layers"), layers);
+ return manifest;
+}
+}
+
+class WritingProjectsGuideTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theMinimalManifestLoadsAsItSays();
+ void theCurvesLayerWarmsWhatLiesBelow();
+ void theCommonAdjustmentFieldsAreRequired();
+ void aFolderPassesAnAdjustmentThrough();
+ void theBlendModesAndKindsAreOurs();
+ void hsvSettingsWinOverTheLegacyFields();
+};
+
+void WritingProjectsGuideTests::theMinimalManifestLoadsAsItSays()
+{
+ QTemporaryDir root;
+ const ProjectSnapshot snapshot = ProjectStore::load(project(root, examples().value(0)));
+ QCOMPARE(snapshot.manifest.width, qint64(1920));
+ QCOMPARE(snapshot.manifest.height, qint64(1080));
+ QCOMPARE(snapshot.manifest.layers.size(), size_t(1));
+ const ProjectLayerRecord &layer = snapshot.manifest.layers[0];
+ QCOMPARE(layer.name, QString("Background"));
+ QVERIFY(layer.isVisible);
+ QCOMPARE(layer.opacity.value(), 1.0);
+ QVERIFY(layer.blendMode.value() == LayerBlendMode::normal);
+ QCOMPARE(layer.transform.origin, QPointF(0, 0));
+ QCOMPARE(layer.transform.size, QSizeF(1920, 1080));
+ QCOMPARE(layer.transform.rotation, 0.0);
+ QVERIFY(layer.transform.sampling == LayerSampling::high);
+ // Named after its layer, as the rules say.
+ QCOMPARE(layer.imageFile.value(), layer.id.toString(QUuid::WithoutBraces).toUpper() + QStringLiteral(".png"));
+ QVERIFY(snapshot.images.count(layer.id) == 1);
+ QCOMPARE(snapshot.manifest.activeLayerID.value(), layer.id);
+}
+
+void WritingProjectsGuideTests::theCurvesLayerWarmsWhatLiesBelow()
+{
+ QTemporaryDir root;
+ const ProjectSnapshot snapshot = ProjectStore::load(project(root, withCurves()));
+ QCOMPARE(snapshot.manifest.layers.size(), size_t(2));
+ QCOMPARE(snapshot.manifest.layers[1].name, QString("Warm Grade"));
+ const LayerAdjustment &adjustment = snapshot.manifest.layers[1].adjustment.value();
+ QVERIFY(adjustment.kind == AdjustmentKind::curves);
+ const std::vector> points{{{0, 0}, {255, 255}},
+ {{0, 0}, {120, 147}, {255, 255}},
+ {{0, 0}, {100, 114}, {255, 255}},
+ {{0, 0}, {115, 97}, {255, 238}}};
+ QCOMPARE(adjustment.curves.channels.size(), points.size());
+ for (size_t channel = 0; channel < points.size(); ++channel)
+ QCOMPARE(adjustment.curves.channels[channel], points[channel]);
+ // The composite is the background through those curves.
+ QImage below(1, 1, QImage::Format_RGBA8888_Premultiplied);
+ below.fill(QColor(20, 30, 60));
+ const QColor expected = adjustment.curves.apply(below).pixelColor(0, 0);
+ const QColor rendered = ImageExporter::render(snapshot).image.pixelColor(960, 540);
+ QCOMPARE(rendered, expected);
+ QVERIFY(rendered.red() > 20 && rendered.blue() < 60);
+}
+
+void WritingProjectsGuideTests::theCommonAdjustmentFieldsAreRequired()
+{
+ for (const QString &field : {QStringLiteral("hue"), QStringLiteral("saturation"), QStringLiteral("lightness"), QStringLiteral("colorize"),
+ QStringLiteral("levels"), QStringLiteral("curves")}) {
+ QTemporaryDir root;
+ QVERIFY_THROWS_EXCEPTION(std::runtime_error, ProjectStore::load(project(root, adjusted(QStringLiteral("Curves"), field))));
+ }
+ // A kind's own settings may be missing: its defaults.
+ QTemporaryDir root;
+ const ProjectSnapshot blur = ProjectStore::load(project(root, adjusted(QStringLiteral("Gaussian Blur"))));
+ const LayerAdjustment &adjustment = blur.manifest.layers[1].adjustment.value();
+ QVERIFY(adjustment.kind == AdjustmentKind::gaussianBlur);
+ QCOMPARE(adjustment.gaussianRadius(), 10.0);
+}
+
+void WritingProjectsGuideTests::aFolderPassesAnAdjustmentThrough()
+{
+ // Red below, and a folder holding only an Invert layer.
+ QJsonObject manifest = adjusted(QStringLiteral("Invert"));
+ QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray();
+ const QString folder = QStringLiteral("B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E");
+ QJsonObject group = layers[1].toObject();
+ group.remove(QStringLiteral("adjustment"));
+ group.insert(QStringLiteral("id"), folder);
+ group.insert(QStringLiteral("name"), QStringLiteral("Folder"));
+ group.insert(QStringLiteral("isGroup"), true);
+ QJsonObject invert = layers[1].toObject();
+ invert.insert(QStringLiteral("parentID"), folder);
+ manifest.insert(QStringLiteral("layers"), QJsonArray{layers[0], group, invert});
+ QTemporaryDir root;
+ const QString path = project(root, manifest);
+ QImage red(1920, 1080, QImage::Format_RGBA8888);
+ red.fill(Qt::red);
+ QVERIFY(red.save(path + QStringLiteral("/images/") + layers[0].toObject().value(QStringLiteral("imageFile")).toString(), "PNG"));
+ const QColor shown = ImageExporter::render(ProjectStore::load(path)).image.pixelColor(960, 540);
+ QCOMPARE(shown, QColor(0, 255, 255));
+}
+
+void WritingProjectsGuideTests::theBlendModesAndKindsAreOurs()
+{
+ QSet modes, kinds;
+ for (const LayerBlendMode mode : allLayerBlendModes)
+ modes.insert(rawValue(mode));
+ for (const AdjustmentKind kind : allAdjustmentKinds)
+ kinds.insert(rawValue(kind));
+ QCOMPARE(named(QStringLiteral("- **Blend modes are spelled exactly** as OmaPhoto names them:")), modes);
+ QCOMPARE(named(QStringLiteral("- `kind` is one of")), kinds);
+ QSet samplings;
+ for (const LayerSampling sampling : {LayerSampling::high, LayerSampling::smooth, LayerSampling::nearest})
+ samplings.insert(rawValue(sampling));
+ QCOMPARE(named(QStringLiteral("- `sampling` is")), samplings);
+}
+
+void WritingProjectsGuideTests::hsvSettingsWinOverTheLegacyFields()
+{
+ // The background's colour, then through each Hue/Saturation.
+ const auto shown = [](int legacyHue, std::optional masterHue) {
+ QJsonObject manifest = adjusted(QStringLiteral("Hue/Saturation"));
+ QJsonArray layers = manifest.value(QStringLiteral("layers")).toArray();
+ QJsonObject layer = layers[1].toObject();
+ QJsonObject adjustment = layer.value(QStringLiteral("adjustment")).toObject();
+ adjustment.insert(QStringLiteral("hue"), legacyHue);
+ if (masterHue) {
+ const QJsonObject values{{"hue", *masterHue}, {"saturation", 0}, {"lightness", 0}};
+ adjustment.insert(QStringLiteral("hsvSettings"), QJsonObject{{"range", "Master"}, {"colorize", false}, {"invertRange", false},
+ {"adjustments", QJsonArray{"Master", values}}, {"bands", QJsonArray{}}});
+ }
+ layer.insert(QStringLiteral("adjustment"), adjustment);
+ layers[1] = layer;
+ manifest.insert(QStringLiteral("layers"), layers);
+ QTemporaryDir root;
+ return ImageExporter::render(ProjectStore::load(project(root, manifest))).image.pixelColor(960, 540);
+ };
+ const QColor background(20, 30, 60);
+ // No hsvSettings: the legacy hue turns the colour.
+ QVERIFY(shown(120, std::nullopt) != background);
+ // hsvSettings present: the legacy hue is ignored, its own applies.
+ QCOMPARE(shown(120, 0), background);
+ QCOMPARE(shown(0, 120), shown(120, std::nullopt));
+}
+
+QTEST_GUILESS_MAIN(WritingProjectsGuideTests)
+#include "WritingProjectsGuideTests.moc"