From aa2c0107db35559eb0a05caef46ab7dc9d10e429 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 13 Oct 2026 14:10:38 -0700 Subject: [PATCH 1/2] Cut the docs to the rules that matter AGENTS.md now holds every repository rule: structure, performance, UI, code, tests, and review. ARCHITECTURE, DESIGN, PERFORMANCE, REVIEW, and CONTRIBUTING are folded into it or left to the code comments that already state them. API.md, README, CONTEXT, and the solver notes keep only their contracts, and API.md's processing order now matches the renderer. Co-Authored-By: Claude Opus 5.5 --- .github/pull_request_template.md | 2 +- AGENTS.md | 77 +++++---- API.md | 224 ++++++------------------- ARCHITECTURE.md | 97 ----------- CONTEXT.md | 30 ++-- CONTRIBUTING.md | 3 - DESIGN.md | 22 --- PERFORMANCE.md | 32 ---- README.md | 53 +----- REVIEW.md | 42 ----- src/components/editor/layout.tsx | 3 +- src/features/heal/inpaint/algorithm.md | 30 +--- tests/crop.e2e.ts | 2 +- tests/fixtures/raw/README.md | 6 +- 14 files changed, 127 insertions(+), 496 deletions(-) delete mode 100644 ARCHITECTURE.md delete mode 100644 CONTRIBUTING.md delete mode 100644 DESIGN.md delete mode 100644 PERFORMANCE.md delete mode 100644 REVIEW.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6000e3aa..8459d84f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,4 +4,4 @@ ## Verification - + diff --git a/AGENTS.md b/AGENTS.md index 6a9cce5b..88cf59be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,53 +1,60 @@ # OpenLight -A professional photo editor with little, readable code: Vite, React, [vgpu](https://vgpu.sh) on WebGPU, Bun, and Biome. +A professional photo editor with little, readable code: Vite, React, [vgpu](https://vgpu.sh) on WebGPU, Bun, and Biome. Less is more: the smallest correct change wins, and technical debt is not accepted. [CONTEXT.md](CONTEXT.md) names domain terms; [API.md](API.md) documents `window.openlight`. -Start with [ARCHITECTURE.md](ARCHITECTURE.md) to find the affected code and its layer rules. [CONTEXT.md](CONTEXT.md) names domain terms. For UI changes, read [DESIGN.md](DESIGN.md) for the library and editor components. [REVIEW.md](REVIEW.md) covers verification, and [API.md](API.md) documents `window.openlight`. +## Commands -## Setup +`bun install`, then `bun dev`. On every code change run `bun run check` (writes fixes), `bun run build`, and `bun run test` (Bun with `vgpu/mock`); CI runs them too. `bun run test:browser [file]` runs `*.e2e.ts` in Chromium with real pixels; install its browser once per Playwright version with `bunx --no-install playwright install chromium`. It starts its own Vite: stop any other Vite server first, since concurrent servers reload each other's pages mid-test. Any other scripted browser reuses the launch flags in [playwright.config.ts](playwright.config.ts). The assistant at `/?assistant` needs `vercel dev` with `AI_GATEWAY_API_KEY` in `.env.local`. -```sh -bun install -bun dev -bunx --no-install playwright install chromium -``` +## Structure -The last command installs the Chromium build that the project's Playwright version expects; it is needed once per Playwright version for browser tests and scripted screenshots. `bun run test:browser` starts Vite itself and runs WebGPU on SwiftShader; reuse the launch flags in [playwright.config.ts](playwright.config.ts) for any other scripted browser. +- `lib/`: code that could ship as a standalone library; being shared or React-free does not qualify. +- `core/`: document, image, and renderer, without React. `components/` and `hooks/`: UI primitives and React bindings. +- `features/`: one removable capability, usually `model.ts`, `edits.ts`, `pass.ts` with its `.wgsl`, and `controls.tsx`. Most product behavior lives here. +- `app/`: shell, entry points, and the explicit composition of features. -The experimental assistant shows only at `/?assistant` and answers through the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). `bun dev` serves the app alone; to try the assistant, run `vercel dev` with `AI_GATEWAY_API_KEY` in `.env.local`. +Dependencies point down. Features never import each other; `app/` connects them. Keep module internals private; no application-wide barrel. -| Command | Checks | -| --- | --- | -| `bun run check` | Format and lint; writes fixes. | -| `bun run build` | Types and production build. | -| `bun run test` | Bun tests with `vgpu/mock`; seconds. | -| `bun run test:browser [file]` | Chromium tests with real pixels; minutes for the whole suite. | +Edits and rendering run without React or an implicit active document: pass the workspace, document, and GPU explicitly. UI controls, `window.openlight`, and commands call the same edits; a change to them updates API.md in the same change. The workspace owns document replacement; each document owns its scene, history, and resources. Scenes hold immutable, serializable content; files and GPU resources stay outside history. A gesture is one edit; preview and navigation stay outside history. Async work never outlives its document. -Run the first three on code changes; CI runs them too. Run browser tests for what a change can affect, as [REVIEW.md](REVIEW.md#verify) describes. +`/api` runs unbundled in Node: the modules it reaches import other app modules only as types and name `.js` files instead of `@/`. The model and its key stay on the server. -## Conventions +Confirm changes to this structure with the user and update this section in the same change. -- Everything committed is in English: code, comments, docs, UI text, commit messages, and PRs, even when the conversation is in another language. -- File names use kebab-case. Prefer named exports; use `@/` across folders and relative imports within one. -- Files read from small to large: define a function above the functions that use it. -- Comment only what the code cannot say. -- Keep Tailwind classes inline; use `cva` for local variants when needed. +## Performance -## Quality +Frame rate and low memory are product requirements, on phones as much as desktops. -- Code quality comes first and technical debt is not accepted: no workarounds, dead code, or TODOs in place of a fix. When the right fix is larger than the request, say so instead of patching around it. -- Change behavior at its owner and keep the diff within the request. Preserve unrelated UI and behavior. Add only the tests, helpers, abstractions, or docs the change needs. -- Give each module, component, and function one responsibility. Start with direct functions and library calls; prefer a few repeated lines over coupling unrelated behavior. -- Drive behavior from the state that causes it, such as props or document state, never from incidental DOM structure, selectors, or timing. -- Keep control flow linear with guard clauses and `const`. Avoid nested ternaries and dense logic in JSX. -- Use precise types and one source of truth; avoid casts. Validate external input at its boundary and keep useful errors. +- Pixel work runs on the GPU. Read back only what the result needs; await `queue.onSubmittedWorkDone()` before reading GPU output through a 2D canvas, or `convertToBlob` stalls a second. +- Work follows what changed, never every render or pointer event; per-frame data such as histogram bins stays out of React state. Create pipelines once per engine; keep `.wgsl` beside its owner. +- Render at the reduction the display needs; avoid full-size intermediates. Reuse textures instead of allocating and freeing them. Every owner disposes what it creates. Hidden UI does not mount. +- The working space is linear Rec.2020 `rgba16float`; 8-bit sources stay 8-bit until a pass converts them. Preserve HDR headroom and alpha until display or export. Fitted shader constants are calibration data: never retune them by hand. +- GPU changes carry before/after measurements in the PR: same fixture, device, and flags; first render apart from warm repeats; median and p95; a new effect also reports its neutral or bypassed cost; GPU time from vgpu's `timer(gpu)`, or reported as unavailable, never replaced by CPU time. Memory counts retained rasters and Remove fields alongside textures. SwiftShader numbers describe SwiftShader only. Run `bun run test:browser --config playwright.bench.config.ts --grep ` alone. `?stats` shows fps and every texture and buffer allocation. + +## UI + +- Use [`@roprgm/ui`](https://ui.roprgm.com) with its defaults and variants: `Button` or `IconButton` for actions, `Menu` for commands, `Popover` for settings, `Select` for choices. Override styling only for a demonstrated editor need; an existing override is not a precedent. +- Compose the editor from `components/editor`; `components/ui` holds only what the library lacks. Features describe numbers as `Parameter`s, drawn as sliders on desktop and dials on mobile. New UI keeps the padding and gaps of the components around it. +- `useDesktopLayout` is the one layout switch. State that outlives a layout lives above `EditorLayout`, and the canvas never remounts across it. +- Keep Tailwind classes inline; use `cva` for local variants. + +## Code + +- Everything committed is in English, even when the conversation is not. +- kebab-case files, named exports, `@/` across folders and relative imports within one. Define a function above its users. Comment only what the code cannot say. +- No workarounds, dead code, or TODOs in place of a fix. When the right fix is larger than the request, say so. +- Change behavior at its owner and keep the diff within the request; add only the tests, helpers, abstractions, or docs it needs. +- One responsibility per module, component, and function. Direct calls first; a few repeated lines over coupling unrelated behavior. +- Drive behavior from the state that causes it, never DOM structure, selectors, or timing. +- Linear control flow with guard clauses and `const`; no nested ternaries or dense logic in JSX. +- Precise types, one source of truth, no casts. Validate external input at its boundary with useful errors. ## Tests -Tests follow what a person would verify: open an image, use a tool, undo, export, and check the output. Add or extend one when a change adds or alters such a workflow or its processing, or fixes a regression. Core algorithms such as render order and resource reuse may have focused unit tests. Layout, styling, and copy changes are checked by looking at the app, not by tests. Tests run offline: the browser fixture fails any request beyond the dev server. +Tests follow what a person verifies: open an image, use a tool, undo, export, check the output. Add one when a workflow or its processing changes or a regression is fixed; core algorithms may have unit tests. Layout, styling, and copy are checked by looking at the app. Tests run offline: the browser fixture fails any request beyond the dev server. -## Finishing +## Review and PRs -- When a change alters the structure [ARCHITECTURE.md](ARCHITECTURE.md) describes, confirm it with the user and update that file in the same change. -- Before a substantial change's PR is ready, review it as [REVIEW.md](REVIEW.md) describes; after a small one, offer a review instead. -- Keep PR descriptions short with the [template](.github/pull_request_template.md). Include [screenshots](REVIEW.md#screenshots) for new features and substantial UI changes. Generated output goes to the PR, never into the repository. +- Verify what the change can reach with the cheapest check that catches a regression: Bun tests for document, history, and loaders; the feature's `*.e2e.ts` for processing and rendering; the whole suite only when a change cuts across the editor; the affected screens for UI. A failure that also happens on the base is a gap to report. +- Review substantial changes before the PR is ready; offer a review after small ones. Name each finding's code, problem, and smallest fix, separating required from optional. A simpler version that removes code without losing behavior is required. +- Short PR descriptions with the [template](.github/pull_request_template.md). New features and substantial UI changes show before/after screenshots with the same fixture and viewport, uploaded as PR attachments. Generated output never goes in the repository. diff --git a/API.md b/API.md index 7542027e..8b712666 100644 --- a/API.md +++ b/API.md @@ -1,15 +1,11 @@ # Control API -`window.openlight` is the browser entry point for the imperative [controls](src/app/controls.ts). It is available after the app initializes and acts on the current document. Load an image before editing or exporting. UI controls and this API use the same document edits. - -## Example - -With an image `File` named `imageFile`: +`window.openlight` is the browser entry point for the [controls](src/app/controls.ts). It acts on the current document with the same edits as the UI. ```js const editor = window.openlight; await editor.openFile(imageFile); -if (!editor.getState().documentId) throw new Error("Image could not be loaded."); +if (editor.getState().failure) throw new Error("Image could not be loaded."); editor.setAdjustments({ exposure: 1, shadows: 25 }); editor.undo(); @@ -17,120 +13,63 @@ editor.redo(); const image = await editor.exportImage({ format: "jpeg", quality: 90 }); ``` -Export returns a `File`. It does not start a download. - ## Loading -These methods return promises; await them before editing. All but `loadUrl`, which takes a URL, accept browser `File` objects. +These return promises; await them before editing. File arguments are browser `File`s. | Method | Behavior | | --- | --- | -| `openFile(file)` | Opens one image or [scene file](#scene-files), imports one Camera Raw XMP file, or adds a [LUT](#luts) layer from one `.cube` file. | -| `openFiles(files)` | Opens the first recognized image or scene file, then imports recognized XMP and `.cube` files in order. Unsupported files are ignored. | -| `loadImage(file)` | Loads a file as an image, replacing the current document and its history. | -| `loadScene(file)` | Opens a scene file as a new document, replacing the current one and its history. | -| `recoverDraft()` | Opens the stored [draft](#drafts) as a new document, like `loadScene`. Rejects when no draft is stored or storage fails. | -| `discardDraft()` | Deletes the stored draft and its source files. | -| `loadUrl(url)` | Fetches an image and loads it. A URL on another origin must allow CORS; the response's content type identifies an image whose URL has no extension. | -| `importXmp(file)` | Applies supported Camera Raw adjustments as one undoable edit. | +| `openFile(file)` | Opens an image or scene file, imports a Camera Raw XMP file, or adds a LUT layer from a `.cube` file. | +| `openFiles(files)` | Opens the first image or scene file, then imports XMP and `.cube` files in order. | +| `loadImage(file)`, `loadScene(file)` | Replaces the document and its history. | +| `loadUrl(url)` | Fetches an image; another origin must allow CORS. | +| `importXmp(file)` | Applies supported Camera Raw adjustments as one edit. | +| `recoverDraft()`, `discardDraft()` | Opens or deletes the draft kept in the browser. | -File loads are queued; `loadUrl` starts at once, and when two opens overlap the later one wins. XMP and `.cube` imports are skipped if no document is ready; an invalid XMP import can reject without blocking later loads. Image, scene, and `.cube` failures do not reject the loading promise. Without a document open, a failure leaves the workspace in its error state; with one, that document stays open and keeps its history. Either way `getState().failure` names the file and the error, and settings loaded in the same batch are skipped. Check `getState().documentId` or `failure` to confirm success. Loading completion does not guarantee the preview has rendered. +File loads are queued and `loadUrl` starts at once; the last load started wins. Image, scene, and `.cube` failures do not reject: the open document, if any, stays with its history, and `getState().failure` names the file and error. Check `failure` to confirm success; a `documentId` may be the previous document's. Loading completion does not mean the preview has rendered. ## Editing -Commands take explicit layer IDs rather than using UI selection. Without an ID, adjustments, the tone curve, and white balance address the base image; color-mixer, Details, vignette, and grain commands use the first matching root effect or create one at the top of the stack; effects nested in other layers stay untouched. Creation and parameter changes are one edit, respecting an open history group. - -`setAdjustments(change, id?)` updates only the supplied adjustments on an image or mask. Values must be finite numbers within these inclusive ranges. Unknown names and invalid values throw. - -| Adjustment | Range | Default | -| --- | --- | --- | -| `exposure` | -5 to 5 | 0 | -| `contrast`, `highlights`, `shadows`, `whites`, `blacks` | -100 to 100 | 0 | -| `incrementalTemperature`, `incrementalTint`, `vibrance`, `saturation` | -100 to 100 | 0 | - -`setToneCurve(points, id?)` replaces the tone curve of an image or mask layer, applied after its basic adjustments. Each point is `{ x, y }` with coordinates between 0 and 1. Supply at least two points, ordered by `x` with a minimum gap of `1/1024`. The first point must have `x = 0` or `y = 0`; the last must have `x = 1` or `y = 1`. Call `setToneCurve()` to reset it. - -`setDetails(change, id?)` edits a Details effect: `clarity` (−100 to 100, default 0), `sharpening` (0 to 150, default 0), and `sharpenRadius` (0.5 to 3, default 1). Values must be finite. Like other effect commands, it creates a layer when no matching layer exists. - -`setColorMixer(color, change, id?)` updates one of `red`, `orange`, `yellow`, `green`, `aqua`, `blue`, `purple`, or `magenta`. Supply any of `hue`, `saturation`, and `luminance`, each a finite number from -100 to 100. Other colors and unspecified channels keep their values. `resetColorMixer(id?)` resets every color in the matching layer as one undoable edit; without a matching layer it does nothing. A full shift of 100 rotates hue by 30°, scales saturation from zero to double, or moves luminance by one stop, weighted by each pixel's distance to the color's Oklab hue. Hue and saturation edits preserve luminance, and neutrals are unaffected. - -`setVignette({ intensity, softness }, id?)` updates a vignette layer. Values are finite numbers from 0 to 100; defaults are intensity 0 and softness 50. Without an ID, it updates the first vignette or creates one, which starts at intensity 50 unless the change sets it. Intensity 0 bypasses the effect; increasing softness spreads the transition toward the center. The falloff stays centered on the source image before crop and rotation, independently of viewport zoom and pan. Its position among the effect layers determines processing order. It multiplies linear RGB equally and preserves alpha and HDR headroom. - -`setGrain({ amount, size, roughness }, id?)` updates a Grain layer, film grain in the style of Lightroom's. Values are finite numbers from 0 to 100; defaults are amount 0, size 25, and roughness 50. Without an ID, it updates the first Grain layer or creates one, which starts at amount 25 unless the change sets it. Amount 0 bypasses the effect. The particles lie at four fixed spacings, one to eight source pixels apart: size fades the finer ones out as the coarser ones come in, rather than stretching them, and roughness mixes in neighboring spacings and varies the particles' size, so 0 is even and 100 is clumped. The grain is fixed to source pixels before crop and rotation: zoom, pan, and crop do not move it, and a full-size export matches the preview; a smaller `longEdge` averages it like any fine detail. It moves luminance only, most in the midtones and fading toward black and white, scaling the channels equally so color holds, and leaves alpha and HDR headroom unchanged. - -`setFill({ color, blend }, id?)` updates a Color layer: `color` is `#rrggbb` sRGB and `blend` is `normal`, `multiply`, `screen`, `overlay`, `soft-light`, `color`, or `luminosity`. The layer paints that color over the image below it, blended as Photoshop does on encoded values, so inside a brush or gradient mask it paints the mask's coverage; the layer's opacity sets the strength. Without an ID it updates the first Color layer or creates one, which starts at `#f0763c` in Normal. - -Create an empty Heal effect with `addLayer("heal")`. - -`addHealPatch(id, stroke, offset, mode?)` appends a Healing patch and returns its ID. `stroke` is a painted `BrushStroke`, and `offset` is `[sourceX - targetX, sourceY - targetY]` in source pixels. `setHealSource(id, patchId, offset)` changes a donor. Patches replay in order from the accumulated composite below; the tool supplies automatic donors. `mode` is `"heal"` (the default) or `"clone"`; Clone copies donor color without boundary correction and retains feather and opacity. - -`addHealStroke(id, patchId, stroke)` appends a painted or erased `BrushStroke` to an existing patch as one undoable edit. It preserves the mode, donor, feather, and opacity. Feather is a patch-wide blend based on the first stroke's diameter, shared by later additions. All patches expose an ordered `strokes` array; each path stays separate, coverage is combined on the GPU, and moving the patch moves paint and erase together. In the editor, Shift adds and Alt/Option subtracts on the selected patch, committing on release. Alt-click chooses a donor for Heal/Clone when no patch is selected. Older saved scenes with a single `stroke` load as one-element arrays. +Commands take explicit layer IDs, never the UI selection. Without an ID, adjustments, the tone curve, and white balance address the image; effect setters use the first matching root effect or create one on top. Values must be finite and in range; invalid input throws. Edits update the scene and history synchronously; rendering finishes later. -`addRemovePatch(id, stroke)` appends a Remove patch and returns its ID. It synthesizes the covered region from unmasked surrounding context using multiscale GPU PatchMatch; no offset or manual source is needed. `setHealSource` rejects Remove patches. The same feather, opacity, destination, duplicate, and delete edits apply. The patch's `field` names, by ID, where each pixel copies from; a new ID is reserved whenever its strokes change. In the editor, the first full-resolution render synthesizes that field and keeps it under its ID: changes to the layers below, undo, export, and scene files keep it, while the copied colors follow those layers. `exportImage` and `exportScene` first read back any field the editor shows that the document still waits for, and reject when that readback fails. A stroke added or erased with `addHealStroke`, or a move, synthesizes the patch's whole new shape. The solver and compositor run through `createEditorRenderer` without React; [the solver documentation](src/features/heal/inpaint/algorithm.md) describes limits and reproducible benchmarks. - -Create an empty Paint layer with `addLayer("paint")`. `addPaintStroke(id, stroke)` appends a `BrushStroke` with a `color`, `#rrggbb` sRGB: a painting stroke lays that color over the layer's earlier paint, and an erasing one removes paint whatever its color. `setPaintBlend(id, blend)` blends the layer's paint over the image below with the modes `setFill` takes, on encoded values as Photoshop does. A paint stroke builds up as a mask's does. In the editor, a layer's strokes settle into pixels once it has 100 and no gesture is open: `getState()` then shows a `raster` ID and fewer strokes, undo still reaches every stroke, and scene files keep the pixels. A photo holds up to 4 Paint layers and 10 brush masks; adding more throws. - -For RAW sources, `setWhiteBalance({ temperature, tint })` sets absolute Kelvin and DNG tint, preserving unspecified values. Temperature accepts 2000–25000 K and tint accepts -150–150, extending either range to include the file's As Shot value. `setWhiteBalance()` restores that value (the decoder's daylight fallback if camera multipliers are unavailable). Non-RAW sources and invalid values throw. Incremental temperature/tint remain separate RGB adjustments. - -`autoWhiteBalance()` neutralizes the photo's color cast as one edit and resolves once it is made. The GPU measures the cast of the source before any edit, as its edges' average color, so calling it again gives the same balance. A RAW photo's white balance moves from As Shot; any other photo's incremental temperature and tint are replaced. When another photo opens first, it resolves without an edit. - -Edits update the scene and history synchronously. Rendering may finish later, particularly RAW development. Tests should wait for visible results; `exportImage()` renders and waits for its captured scene independently of the preview. - -`setFrame(frame)` replaces the complete [ImageFrame](src/core/image/frame.ts) as an undoable edit. Geometry uses finite two-element coordinate pairs, positive dimensions, and nonzero scale. The frame is validated and copied; later changes to the supplied object do not affect the document. - -The frame's `perspective`, `[x, y]` from −100 to 100 and `[0, 0]` when omitted, corrects keystone along the photo's own axes before the crop, rotation, and flips. Positive `x` enlarges the right edge against the left, positive `y` the bottom against the top: at 100 they take 1/0.55 and 1/1.45 of their scale. At value `v`, lines that meet 1/(0.0045·|v|) half-sizes from the photo's center, beyond the edge `v` enlarges, become parallel; a building shot looking up straightens at a negative `y`. The center keeps its scale, and lines stay straight. Without perspective, `center` is in source pixels; with it, in the corrected photo's pixels, which share the source's center. The Crop tool shows the correction as Vertical and Horizontal along the displayed axes, so they follow quarter turns and flips, and magnifies around the crop's center only as much as covering the crop requires, keeping the output size. Layers keep their source-pixel geometry, so correcting perspective leaves paint, masks, and patches on the photo. +| Method | Arguments | +| --- | --- | +| `setAdjustments(change, id?)` | `exposure` −5…5; `contrast`, `highlights`, `shadows`, `whites`, `blacks`, `incrementalTemperature`, `incrementalTint`, `vibrance`, `saturation` −100…100. On the image or a mask. | +| `setToneCurve(points?, id?)` | `{ x, y }` points in 0…1, at least two, ordered by `x` at least `1/1024` apart; first at `x = 0` or `y = 0`, last at `x = 1` or `y = 1`. No points resets. | +| `setWhiteBalance(change?)` | RAW only: `temperature` 2000…25000 K, `tint` −150…150, each widened to include As Shot. No change restores As Shot. | +| `autoWhiteBalance()` | Neutralizes the color cast as one edit; resolves once made. | +| `setDetails(change, id?)` | `clarity` −100…100, `sharpening` 0…150, `sharpenRadius` 0.5…3. | +| `setColorMixer(color, change, id?)` | `color`: `red`, `orange`, `yellow`, `green`, `aqua`, `blue`, `purple`, `magenta`; `hue`, `saturation`, `luminance` −100…100. `resetColorMixer(id?)` resets all. | +| `setVignette(change, id?)` | `intensity`, `softness` 0…100. | +| `setGrain(change, id?)` | `amount`, `size`, `roughness` 0…100. | +| `setFill(change, id?)` | Color layer: `color` `#rrggbb`; `blend` `normal`, `multiply`, `screen`, `overlay`, `soft-light`, `color`, `luminosity`. | +| `addPaintStroke(id, stroke)`, `setPaintBlend(id, blend)` | Paint layer: a `BrushStroke` with a `#rrggbb` `color`; blends as `setFill`. | +| `addHealPatch(id, stroke, offset, mode?)` | Heal layer: returns the patch ID. `offset` is `[sourceX − targetX, sourceY − targetY]`; `mode` `"heal"` or `"clone"`. | +| `addRemovePatch(id, stroke)` | Heal layer: synthesizes the area from its surroundings; returns the patch ID. | +| `addHealStroke(id, patchId, stroke)`, `setHealSource(id, patchId, offset)` | Adds to a patch, or moves a Heal or Clone donor. | +| `setFrame(frame)` | Replaces the [ImageFrame](src/core/image/frame.ts): crop, rotation, flips, and `perspective` `[x, y]` −100…100. | + +A `BrushStroke` has `mode` (`"paint"` or `"erase"`), `size` (diameter in source pixels), `feather` and `flow` (0…1), and `points` of `[x, y, pressure]` in source pixels, pressure 0…1. A photo holds up to 4 Paint layers and 10 brush masks. ## Layers -`scene.layers` is ordered bottom to top, beginning with the locked image at index 0. Processing layers have `children`, also ordered bottom to top; the image cannot contain layers. Editing supports two levels. Adding, duplicating, moving, and deleting commit an open gesture and record one history entry each. Selecting a layer changes the inspector without adding history; changing selection commits an active gesture. Removing the selected layer or an ancestor returns selection to the image. - -Images own basic adjustments and a tone curve. Processing layers edit the source image in stack order; the image layer's adjustments and curve then tone the composite, so a local exposure can recover light that the global exposure pushes past white. An effect processes the image below, then its children. A mask processes its basic adjustments, its tone curve, and child effects, then blends that result with its input using coverage × opacity. A neutral mask with no effects does nothing. Hidden layers and zero opacity bypass the complete branch. - -Direct mask children of another mask modify coverage instead of processing image pixels: Add sums coverage, Subtract removes it, clamping to 0–1 after each child in stored order, and Intersect keeps it only where the child covers. Each child's opacity scales its contribution; an Intersect child at opacity `o` keeps `1 − o × (1 − child)` of the coverage. A brush child that has not painted yet takes no part. Its stored basic adjustments are inactive in this position. Other children process the image within the combined mask. Layer opacity always controls effect strength, preserving the input image's alpha. +`scene.layers` is ordered bottom to top from the locked image at index 0; processing layers have `children`, two levels deep. The image's adjustments and curve develop the photo first; the layers above process that result in stack order. A mask applies its adjustments, curve, and child effects within its coverage × opacity. Mask children of a mask shape its coverage: Add, Subtract, or Intersect, in stored order. Hidden layers and zero opacity bypass their branch. | Method | Behavior | | --- | --- | -| `addLayer(kind, placement?)` | Adds `"exposure"`, `"color-mixer"`, `"details"`, `"vignette"`, `"grain"`, `"fill"`, `"heal"`, `"paint"`, or `"mask"`; selects and returns its ID. A `"lut"` layer comes from a `.cube` file instead. `{ inside: id }` appends a child to a processing layer; `{ above: id }` inserts directly above that layer among its siblings; without a placement, the layer goes on top of the root stack. Exposure starts at +1 EV, Vignette at intensity 50, Grain at amount 25; Color Mixer, Details, Healing, and masks start neutral. | -| `selectLayer(id)` | Selects any layer. | -| `setLayer(id, change)` | Updates processing-layer `name`, `visible`, or `opacity` (0–1). | -| `setExposure(id, value)` | Sets an Exposure layer to -5…5 EV. | -| `setLayerMask(id, mask)` | Replaces a mask layer's mask: `{ kind: "linear", start, end }`, `{ kind: "radial", center, radius, angle, feather }`, `{ kind: "brush", strokes }`, or a [range](#ranges), `{ kind: "luminance-range", low, high, smoothness }` or `{ kind: "color-range", color, tolerance }`. Geometry uses source pixels; radial angle is degrees and feather is 0–1. | -| `setMaskOperation(id, operation)` | Sets `"add"`, `"subtract"`, or `"intersect"`; used when this mask is inside another mask. | -| `duplicateLayer(id)` | Copies a processing layer and its children above itself with independent IDs; selects and returns the new ID. | -| `moveLayer(id, index, parentId?)` | Moves to a final sibling index, bottom to top. Omit the parent for the root stack, where index 0 is reserved for the image. Image parents, cycles, and third-level nesting are rejected. | -| `deleteLayer(id)` | Removes a processing layer and its children; undo restores them. | - -A linear gradient has full coverage at `start`, zero at `end`; its points must be finite and distinct. A radial gradient covers the ellipse inside `radius`, with positive radii and a feathered falloff toward its edge. Crop, rotation, perspective, and viewport navigation do not move it within the document. - -A brush mask is a list of strokes, over the pixels earlier strokes settled into, as a Paint layer's are: in the editor they settle every 100 strokes, and `getState()` shows the mask's `raster` ID. `setLayerMask` keeps a `raster` only if it names the document's own settled pixels. Each stroke has `mode` (`"paint"` or `"erase"`), `size` (diameter in source pixels), `feather` and `flow` (0–1), and `points`, each `[x, y, pressure]` in source pixels with pressure 0–1. A paint stroke adds `flow × pressure` of the remaining coverage every quarter diameter along the points, and an erase stroke removes that share of the existing coverage; dabs land sixteen times per diameter, each laying a fraction of that, so soft edges show no ripples. The renderer rasterizes strokes into a cached coverage texture at half the photo's resolution each way and only stamps new points, so appending to the last stroke is cheap and undo replays at most 99. A brush inside another mask keeps its own coverage, paint and erase strokes alike, which the mask adds or subtracts scaled by the brush layer's opacity. A mask with no painted coverage and nothing added to it is bypassed. - -### Ranges - -A range mask selects pixels of the image below its mask group, before the group's own adjustments, rather than by position. `{ kind: "luminance-range", low, high, smoothness }` selects the pixels whose CIE lightness lies from `low` to `high`, 0 black to 100 white, fading out over `smoothness` past either end; headroom above white counts as white. `{ kind: "color-range", color, tolerance }` selects the pixels whose hue and saturation are near `color`, `#rrggbb` sRGB, however light or dark, more of them as `tolerance` grows. Set `color` to `null` for an unpicked range with no coverage. Every value runs from 0 to 100, and `low` cannot exceed `high`. As a mask's own shape, a range covers the whole photo within it; as a child, it adds, subtracts, or intersects like any mask, so a gradient intersected with a color range darkens only that color across the gradient. Range coverage follows the photo's edges at its full resolution. - -The Add menu's **Luminance Range** starts at 50 to 100 with smoothness 25. **Color Range**, including a mask's Add, Subtract, or Intersect entry for one, creates and selects an unpicked mask immediately with tolerance 30. A click on the photo picks its color, averaged over 5 × 5 rendered texels, and a drag keeps picking as one edit. Picks read the image below the mask group as the screen would show it, so the mask's own edit never changes them. Once a color is chosen, the swatch beside the eyedropper opens the browser's color picker. Selecting another kind of layer or deleting the range leaves the picker. - -## LUTs - -A LUT layer grades the image below it with a 3D lookup table, stored in the layer as `lut: { size, domain: [min, max], table }`: `table` holds `size`³ RGB triplets, red varying fastest, then green, then blue, for inputs spread evenly across the domain. The layer's opacity sets its strength. - -`openFile` with an Adobe or Resolve `.cube` file adds one where the Add menu places effects, named after the file's `TITLE` or, without one, the file; the Add menu's LUT entry asks for such a file. The file needs `LUT_3D_SIZE`, a whole number from 2 to 65, before its rows. `TITLE`, `DOMAIN_MIN` and `DOMAIN_MAX` (default 0 and 1), or Resolve's `LUT_3D_INPUT_RANGE`, are read; comments, blank lines, and other keywords are skipped. 1D LUTs are rejected, and so is a file with too few or too many rows, naming the line where one is wrong. - -Creative LUTs expect and return display-referred, sRGB-encoded color. The layer converts the working color as display does, into sRGB clipped to its gamut with hue and luminance kept, so headroom above white clips at the LUT; the color, clamped to the LUT's domain, is interpolated tetrahedrally between its entries, and the result, clamped to 0–1, converts back to the working space. The image layer's adjustments still apply after it, on the composite. +| `addLayer(kind, placement?)` | `"exposure"`, `"color-mixer"`, `"details"`, `"vignette"`, `"grain"`, `"fill"`, `"heal"`, `"paint"`, or `"mask"`; `{ inside: id }` or `{ above: id }`, else on top. Selects and returns the ID. | +| `selectLayer(id)` | Selects a layer, without history. | +| `setLayer(id, change)` | `name`, `visible`, or `opacity` 0…1. | +| `setExposure(id, value)` | Exposure layer, −5…5 EV. | +| `setLayerMask(id, mask)` | `{ kind: "linear", start, end }`, `{ kind: "radial", center, radius, angle, feather }`, `{ kind: "brush", strokes }`, `{ kind: "luminance-range", low, high, smoothness }`, or `{ kind: "color-range", color, tolerance }`. Points and radii in source pixels, `angle` in degrees, `feather` 0…1; range values 0…100 with `low` ≤ `high`; `color` `null` until picked. | +| `setMaskOperation(id, operation)` | `"add"`, `"subtract"`, or `"intersect"` inside another mask. | +| `duplicateLayer(id)` | Copies a layer and its children above it; returns the new ID. | +| `moveLayer(id, index, parentId?)` | Moves to a final sibling index, bottom to top. | +| `deleteLayer(id)` | Removes a layer and its children. | ## History -Each content change creates an undo step unless a group is open. No-op edits add no history. Preview changes stay outside history. Groups do not nest. The preview renders a reduced proxy of the image, by the largest whole factor that still gives every device pixel a source pixel, in a group or not, so the full image renders once the display shows more than half a device pixel per source pixel; outside a group, a Remove patch still waiting for its field is synthesized in full apart. Exports always render in full. - -| Method | Behavior | -| --- | --- | -| `beginEdit()` | Starts a group. Subsequent edits appear immediately. | -| `commitEdit()` | Records the group as one undo step. | -| `cancelEdit()` | Restores the scene from before the group. | -| `undo()` | Commits any open group, then undoes one step. | -| `redo()` | Commits any open group, then redoes one step if available. | - -Group related synchronous edits and cancel on failure. Finish asynchronous preparation before opening a group: +Each content change is one undo step unless a group is open; groups do not nest. `beginEdit()` opens a group, `commitEdit()` records it, `cancelEdit()` restores the scene from before it, and `undo()`, `redo()`, selection, adding, duplicating, moving, and deleting layers, and the `reset` command commit an open group first. Finish async work before opening a group: ```js editor.beginEdit(); @@ -144,66 +83,19 @@ try { } ``` -## Preview - -`setPreview(change)` merges the supplied preview settings without runtime validation. Use the values below. - -| Field | Values | Default | -| --- | --- | --- | -| `comparison` | `"edited"`, `"original"`, `"split"` | `"edited"` | -| `split` | Divider position from 0 to 1 | 0.5 | -| `shadows` | Show shadow clipping | `false` | -| `highlights` | Show highlight clipping | `false` | - -The editor also manages `maskOverlay` for the selected mask, `rangeSource` for the mask group being sampled, and `density`, the device pixels the canvas shows per source pixel, which renders reduce the source to about. These transient fields are visible in `getState().preview`; leave them to the active tool and the canvas. - -## Export - -`exportImage(options)` returns `Promise` containing the edited image, named after the source file. Preview settings do not affect export. - -| Option | Values | Default | -| --- | --- | --- | -| `format` | `"png"`, `"jpeg"`, `"webp"` | `"png"` | -| `quality` | 1 to 100 for JPEG and WebP; PNG ignores it | 80 | -| `longEdge` | Longest output side in pixels, from 1 to the document's longest side | The document size | +## Preview and export -The image renders at the document dimensions and downsamples to `longEdge` with high-quality smoothing. Invalid values throw, as does a format the browser cannot encode. +`setPreview(change)` merges `comparison` (`"edited"`, `"original"`, `"split"`), `split` (0…1), `shadows`, and `highlights` (clipping, booleans), outside history and without validation. -## Scene files - -`exportScene()` returns `Promise`: the document as an `.openlight` file named after its source image, which **Save scene** in the Export panel downloads. It is a ZIP archive holding the source file's original bytes at `sources/`, the deflated texels of each Remove field at `fields/`, and a deflated `scene.json`: - -```json -{ - "format": "openlight", - "version": 2, - "sources": { "": { "name": "photo.jpg", "type": "image/jpeg" } }, - "fields": { "": { "origin": [320, 180], "scale": 1, "size": [96, 64] } }, - "scene": { "frame": {}, "layers": [] } -} -``` - -`scene` is the `getState()` scene; each image layer's `source` names an entry in `sources`, and each Remove patch's `field` an entry in `fields`: where its texels sit, or `{}` for a field not yet synthesized. Version 1 files, which kept each Remove field inside its patch, open as version 2. Opening the file with `loadScene`, `openFile`, a drop, or the file picker decodes the stored source again and restores the frame and every layer as a new document with empty history. Preview settings and history are not saved. - -Opening validates every value as the matching command does, and a file that fails leaves the workspace in its error state with a message naming the first invalid field. Fields OpenLight does not know are dropped. A parameter missing from `adjustments`, `details`, `vignette`, `grain`, `fill`, or `colorMixer` takes its default, so older files still open when a group gains a parameter; a RAW image without a white balance uses its As Shot value, and a frame without `perspective` has none. `version` increases only when older files can no longer open as written, or an older OpenLight would drop what a file holds: a scene that corrects perspective is version 3, which OpenLight before perspective correction rejects, and every other scene stays version 2. A newer version is rejected. - -## Drafts - -Once a document has an edit, OpenLight keeps it as the draft in the browser's IndexedDB, so closing the tab loses nothing. A save follows 1.5 s after the last scene change and flushes when the tab is hidden or the page unloads; saves run one at a time and never render. Opening an image or scene without editing it keeps the previous draft. Only the latest document is kept. - -A draft stores the same `scene.json` object a [scene file](#scene-files) holds, in a record with its own `version` and the document's name, while each source file sits in a separate store under its source ID. A save keeps source files already stored under their ID and deletes unreferenced ones in the same transaction, so edits never rewrite the photo. - -On a fresh load the start screen shows a notice in the viewport's corner with **Recover** and **Forget**. `recoverDraft()` opens the draft through the same validation as a scene file, restoring each source under its original ID, with empty history; a newer draft `version` is rejected rather than dropped, and a parameter added to a group since takes its default. `discardDraft()` removes it. Both return `Promise`. When IndexedDB is unavailable or fails, a dismissible notice suggests saving a scene file, and editing continues. +`exportImage({ format, quality, longEdge })` returns a `File` without downloading it: `format` `"png"` (default), `"jpeg"`, or `"webp"`; `quality` 1…100, default 80; `longEdge` up to the document's size. It renders its own full-resolution snapshot, unaffected by the preview. `exportScene()` returns the document as an `.openlight` scene file. ## State -`getState()` returns a detached snapshot containing `file`, `failure`, `documentId`, `scene`, `selectedLayerId`, `size`, `frame`, `adjustments`, `whiteBalance`, `details`, `toneCurve`, `colorMixer`, `vignette`, `grain`, `preview`, and `history`. `scene` contains `frame` and the layer tree. The top-level adjustment, tone-curve, and white-balance values come from `scene.layers[0]`; details, color-mixer, vignette, and grain values describe the first matching root layer from the bottom, or its defaults. `colorMixer` contains eight-value `hue`, `saturation`, and `luminance` arrays in the color order above, defaulting to zero. `whiteBalance` contains absolute temperature/tint for RAW sources and is undefined for other sources. `file` is the open document's filename, or without one the file loading or failed, `failure` is the last file that failed to open and its error until another opens or the notice is dismissed, and `history` contains `undoCount`, `redoCount`, and `editing`, which is true while a group is open. - -Without a document, `documentId`, `size`, `frame`, and `preview` are undefined. Adjustments, details, and the tone curve use their defaults, and history counts are zero. Mutating the snapshot does not edit the document. +`getState()` returns a detached snapshot: `file`, `failure`, `documentId`, `scene`, `selectedLayerId`, `size`, `frame`, `adjustments`, `whiteBalance`, `details`, `toneCurve`, `colorMixer`, `vignette`, `grain`, `preview`, and `history` (`undoCount`, `redoCount`, `editing`). Top-level values describe the image or the first matching root layer. Without a document, its fields are undefined and the settings are defaults. ## Commands -`run(command)` validates and runs one command: a plain object with a `type` and that command's fields, as JSON can carry it. It returns `{ layerId }` for the layer the command edited or created, so a caller can select it, and throws on an unknown type or an invalid field. Commands make the same edits as the methods above. +`run(command)` validates and runs one [command](src/app/commands.ts): a JSON object with a `type` and its fields. It returns `{ layerId }` for the layer it edited or created, or `{}`, and throws on an unknown type or invalid field. ```js editor.run({ @@ -213,28 +105,12 @@ editor.run({ }); ``` -| Command | Fields | Behavior | -| --- | --- | --- | -| `set-adjustments` | adjustments, `layerId?` | Like `setAdjustments`. | -| `set-tone-curve` | `points?`, `layerId?` | Like `setToneCurve`. | -| `set-white-balance` | `temperature?`, `tint?` | Like `setWhiteBalance` with a change. | -| `set-color-mixer` | `color`, `hue?`, `saturation?`, `luminance?`, `layerId?` | Like `setColorMixer`. | -| `set-details` | `clarity?`, `sharpening?`, `sharpenRadius?`, `layerId?` | Like `setDetails`. | -| `set-vignette` | `intensity?`, `softness?`, `layerId?` | Like `setVignette`. | -| `set-grain` | `amount?`, `size?`, `roughness?`, `layerId?` | Like `setGrain`. | -| `add-mask` | `mask`, `adjustments?` | Adds a mask layer with those adjustments on top of the stack as one edit. | -| `delete-layer` | `layerId` | Like `deleteLayer`. | -| `set-crop` | `aspectRatio?`, `straighten?` | Replaces the frame with the largest centered crop of the source at `aspectRatio`, width over height, straightened by −45 to 45 degrees, keeping the frame's perspective. Without either, it removes the crop. | -| `reset` | none | Removes every layer and returns adjustments, the tone curve, white balance, and the frame, perspective included, to how the photo opened, as one edit. | -| `undo`, `redo` | none | Like `undo` and `redo`. | -| `set-preview` | `comparison` | Shows `"edited"`, `"original"`, or `"split"`. | +Types: `set-adjustments`, `set-tone-curve`, `set-white-balance`, `set-color-mixer`, `set-details`, `set-vignette`, `set-grain`, `add-mask`, `delete-layer`, `set-crop` (`aspectRatio?`, `straighten?` −45…45), `reset`, `undo`, `redo`, and `set-preview` (`comparison` only). Fields mirror the matching method's arguments, with `layerId` for the ID. ## WebMCP -In browsers with [WebMCP](https://webmachinelearning.github.io/webmcp/), OpenLight registers every command as a tool on `document.modelContext`, so a browser agent can edit the open photo. Chrome offers WebMCP from version 149 through an origin trial or `chrome://flags/#enable-webmcp-testing`. A tool takes its command's fields, with a JSON Schema generated from the same models, and returns `{ layerId }` or `"Done."`; errors come back as text, so the agent can correct its input. - -A read-only `get-state` tool returns the file, source size, frame, layers, comparison, and history, counting brush strokes, healing patches, and LUT table values rather than listing them. - -WebMCP passes only JSON, so no tool takes or returns a file. `open-image` takes an http(s) `url` instead and opens it like `loadUrl`, in place of the open photo; it returns the new state like `get-state`, or why the image could not open. An agent opens a local file, such as an image attached to its chat, by serving it over HTTP with CORS and passing its URL. From a public origin such as openlight.app, Chrome's [Local Network Access](https://developer.chrome.com/blog/local-network-access) asks the user before the page reaches a local address; an automated browser can grant the `local-network-access` permission instead. When the request itself fails, the error names these causes, since the browser does not say which one it was. +In browsers with [WebMCP](https://webmachinelearning.github.io/webmcp/), such as Chrome 149 through an origin trial or `chrome://flags/#enable-webmcp-testing`, every command is a tool on `document.modelContext` with a JSON Schema from the same models. Tools return `{ layerId }` or `"Done."`; errors come back as text. -`run-commands` takes `commands`, a list of objects shaped like `run`'s, and runs them in order in one call, so an agent can apply an edit of several steps without a round trip for each. Each command is its own undo step. It returns each command's result; at the first error it stops, names the failing command, and keeps the ones before it. +- `get-state` summarizes the file, size, frame, layers, comparison, and history. +- `open-image` opens an http(s) `url` like `loadUrl`, since tools carry no files; serve a local file over HTTP with CORS. From a public origin, Chrome's [Local Network Access](https://developer.chrome.com/blog/local-network-access) asks before the page reaches a local address. +- `run-commands` runs a list of commands in order, as `run` would one by one, and stops at the first error, keeping the ones before it. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 03fe88e3..00000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,97 +0,0 @@ -# Architecture - -Where the code lives and the rules that keep it in place. Keep this file true: when a change moves a responsibility, update it in the same change. - -## Map - -| Path | Owns | -| --- | --- | -| `src/main.tsx` | Mounts the GPU provider and the app. | -| `index.html`, `about.html` | The two pages. `index.html` holds the welcome's heading, visually hidden, for crawlers that don't run the script; `about.html` is static, without script. | -| `app/index.tsx` | The app: workspace, file drop, draft notice. | -| `app/workspace` | The open document, its replacement, and loading state. A replacement loads beside the open document and takes its place only once it opens; a failure leaves the open document in place. | -| `app/loaders` | Image, scene file, Camera Raw XMP, and `.cube` LUT loaders. A loader reads one file format and opens a document or edits the open one through the same edits the UI uses. | -| `app/draft` | The draft autosaved in the browser. | -| `app/controls.ts` | `window.openlight`, documented in [API.md](API.md). | -| `app/commands.ts` | The commands: serializable, validated edits that `run`, WebMCP, and other callers share; see [API.md](API.md#commands). | -| `app/webmcp.ts` | WebMCP tools for browser agents, one per command; see [API.md](API.md#webmcp). | -| `app/assistant` | Experimental chat that edits the photo, mounted only at `/?assistant`. `index.tsx` sends a message with a summary of the photo and runs the commands that come back; `jev.ts` asks the Jev evaluation model and turns its answers into commands. | -| `/api`, at the repository root | Vercel functions: `assistant.ts` answers the chat through `jev.ts`; `vercel dev` serves them locally. | -| `app/editor/index.tsx` | The editor with a document: header, then a tool's canvas and controls in the layout. A tool's `View` replaces both. Active brush subscriptions update their input context without rendering the layout. | -| `app/editor/empty.tsx` | The editor before a document opens: welcome or loading status, and the placeholder sidebar. | -| `app/editor/sidebar.tsx` | Sidebar sections in order: `EditorSidebar` with a document, `PlaceholderSidebar` without one. | -| `app/editor/tools.tsx`, `tool-rail.tsx` | The tools and the desktop rail. A tool brings a `Canvas` overlay, `Options` for the bar over the image or the dock, or its own `View`. | -| `app/editor/tool-shortcuts.tsx` | Group shortcuts: return to a tool's remembered mode, then cycle its available modes while it is active. | -| `components/editor/brush-input.tsx` | Brush settings and the active input context. Brush and Healing own separate settings above the views; the canvas, cursor, menu, and shortcuts consume the active family. Size is a viewport diameter independent of image resolution and zoom. `BrushCanvas` converts it to source pixels once at stroke start, covering the same area there under perspective; recorded strokes retain their image-space geometry, round in source pixels, and the cursor shows the dab as the viewport does. | -| `components/editor/mapping.ts` | Viewport ↔ document positions through the frame, perspective included, for every canvas tool: exact both ways, with source circles and polygons drawn as their exact images. Pointer input stops at a straight line short of the correction's horizon, joining a stroke that crosses it along that line; a shape the viewport cannot show whole is clipped where it leaves the viewport. | -| `components/editor/brush-canvas.tsx`, `brush-cursor.tsx`, `brush-wheel.ts` | Shared brush gestures, cursor, and wheel input for color, masks, and retouch. Input batches each frame and flushes on release. Remove and modifier edits keep a local contour outside document history and record one edit on release; Remove also previews destination moves until drop. Modifiers and the release policy are latched at gesture start; the retouch feature owns their meaning. Portaled controls retain their own input. Vertical wheel resizes the active brush and horizontal wheel is captured without panning; trackpad pinch and Space override return navigation to the viewport. The cursor adds a center cross above a 100 px visible diameter. | -| `features/heal/modes.ts` | Retouch names, descriptions, and order: Remove, Heal, Clone. The initial mode, desktop buttons, mobile tabs, and shortcut cycle share this order; choosing a mode is remembered across tool visits. | -| `app/editor/brush.tsx` | The Brush tool: one canvas that sends color or mask strokes to the feature edits its mode picks, and its options. | -| `app/editor/dock.tsx` | The mobile dock's tabs and what it shows: the layer stack, a tool's options, or the selected layer's dials. | -| `app/editor/renderer.ts` | Composes feature passes into the preview and export pipelines. | -| `app/editor/mask-pass.ts` | A mask's adjustments, curve, and mix in one pass, fusing two features' shaders, which only the app may; `backdrop.wgsl` is the other shader the app owns. | -| `app/editor/mask-overlay.tsx` | The only writer of the mask overlay, derived from the selection. | -| `app/editor/layers.ts` | The effect kinds the app offers and their layer factories. | -| `app/editor/export` | The export view. | -| `components/editor` | Editor primitives: layout, panel, dock, parameters, viewport, document and renderer contexts, brush input. | -| `components/ui` | What [`@roprgm/ui`](https://ui.roprgm.com) lacks; see [DESIGN.md](DESIGN.md). | -| `core/document` | Scene contract, layer tree, history, resources. | -| `core/image` | Image sources, decoding, geometry, color. | -| `core/renderer` | Render nodes, graph execution, masks, Remove fields, proxy, transform, display. | -| `features/` | One capability: usually `model.ts` (parameters, defaults), `edits.ts`, `pass.ts` with its `.wgsl`, and `controls.tsx`. | -| `lib` | Utilities independent of OpenLight. | -| `tests` | `*.test.ts` run in Bun with `vgpu/mock`; `*.e2e.ts` run in Chromium; `fixtures/` holds test images. | - -## Layouts - -`useDesktopLayout` in `components/editor/layout.tsx` is the one switch between two layouts, read from the same media query as Tailwind's `md` (768 px), so a class and the tree change in the same frame. Every view renders an `EditorLayout` with its canvas and controls, inside an `EditorFrame` that outlives the views: rail, canvas, and sidebar in a row on desktop; canvas, dock, and tab bar in a column on mobile. The app supplies the rail and the tab bar to the frame. - -- The canvas keeps its place in both layouts, so crossing the breakpoint moves it without mounting it again. The sidebar and the dock swap; neither mounts hidden, since each subscribes to the document and the histogram reads the GPU. -- Features describe numbers as `Parameter`s drawn as sliders in the sidebar and dials in the dock, and other controls once for both. Composition that differs sits beside its desktop form: `AdjustPanel` and `AdjustDock`, or a tool's `Options` under the `dock` density. -- State that outlives a layout, such as the tool, selection, camera, history, and each brush family's size and feather, lives above `EditorLayout`. Brush settings last for the document and stay outside history. - -## Layers - -| Layer | Owns | -| --- | --- | -| 0 — `lib/` | Code independent of OpenLight that could be a standalone library. Being shared or React-free does not qualify code for `lib/`. | -| 1 — `core/`, `components/`, `hooks/` | Document, image, and renderer infrastructure without React; UI primitives and React bindings above it. | -| 2 — `features/` | Removable product capabilities: processing, shaders, parameters, edits, and controls. Most product behavior belongs here. | -| 3 — `app/` | The shell, entry points, and explicit composition of features. | - -Dependencies point downward. Features do not import each other; `app/` connects them. Shared primitives do not import features. Keep module internals private and add no application-wide barrel. - -## Engine and React - -- Document edits and rendering run without React, a mounted UI, or an implicit active document: pass workspace, document, and GPU explicitly. Feature processing imports without its panel. -- Each document owns a vanilla Zustand scene store, history, and image resources. Scenes hold immutable, serializable content and image-source IDs; files and GPU resources stay outside history. -- UI controls, `window.openlight`, and commands call the same edits. A slider or curve gesture is one edit; cancelling restores the previous scene. Preview settings and navigation stay outside history. -- The engine owns GPU resources, rendering, and derived data such as histogram bins; frame data stays out of React state. Every resource owner disposes what it creates. - -## Assistant - -- The model, how it is asked, and its key stay on the server. The browser sends a message and a summary of the photo, and receives commands and a message, so another model can answer without changing the client. -- `protocol.ts` validates both directions. Vercel runs `api/assistant.ts` unbundled in Node, so the modules it reaches import app modules only as types, and its own imports name their `.js` output instead of using `@/`. - -## Rendering - -- `core/renderer` defines passes as nodes (`node`, `pipeline`, `split`, `merge`); the graph owns intermediate textures and timing. Features declare passes; `app/editor/renderer.ts` connects them, and `app/editor/mask-pass.ts` fuses two features' into one. -- Hidden layers retain their composition instances, including descendant layers and Healing patches. -- The renderer keeps the proxy and the intermediate textures of the four reductions it rendered at last, each a quarter of the photo at most, so a zoom in and out allocates and frees nothing, which Safari penalizes; the photo-size set, which a zoom to every source pixel needs, goes once a reduction renders, so a phone's GPU memory holds the display's sizes, not the photo's. -- Use `vgpu` for GPU work and `vgpu-react` for bindings. Create pipelines once per engine and reuse them. Keep `.wgsl` beside its owner. -- The working space is linear Rec.2020 in `rgba16float`. RAW and TIFF decode into it. An 8-bit source with the sRGB transfer stays as it came, encoded in a texture format that decodes to linear when read, in its own primaries, half the memory of the working format: the image layer's adjustments pass converts it, or its proxy, into the working space, and the display converts the source itself for the comparison. A source with another transfer, such as a 16-bit PNG or an HDR photo, converts at import into the working format. Passes preserve format and primaries unless they explicitly convert. -- A LUT layer converts explicitly: its table expects display-referred sRGB, so it receives the working color as the display shows it, headroom above white clipped, and its output returns to linear Rec.2020. -- A node's storage arrays upload when the array changes; passing the same array, such as a LUT's table, keeps its buffer. -- The image layer's adjustments and tone curve develop the photo first; its children and the layers above process that result in stack order. Paint colors therefore follow the photo's adjustments. Working textures stay floating-point through composition; display and export encode the final result. -- Preserve HDR headroom through exposure, curves, and vibrance. Exposure multiplies linear RGB by `2^EV`, without clipping, so +3 EV followed by -3 EV recovers the original light even when intermediate values exceed 1. -- The adjustment shader's parameters use UI units; its fitted constants are calibration data. -- Strokes are scene content; the renderer caches their rasterized coverage, or a Paint layer's premultiplied 8-bit color, at half the source's resolution each way, a quarter of its pixels, which the passes that read them sample back to full size, and stamps only appended dabs. A Paint layer's or brush mask's raster comes with its first paint and stays, cleared if need be, until the layer goes, so painting and undoing allocate nothing. A photo holds up to 4 Paint layers and 10 brush masks. -- A mask's or Paint layer's latest stroke builds up in a half-float stroke buffer at the same resolution, one stroke at a time; its raster takes it in one pass when the next stroke starts, so it rounds to 8 bits once, however many moves drew it. Until then a Paint layer composes the buffer over its raster, and a mask's readers get its raster with the stroke laid over, in a view redrawn where the stroke grows. A Healing or Clone patch records its mode, reuses the same coverage, and keeps editable feather and opacity. Clone copies donor color directly, skipping the correction passes. A Healing patch records ordered hard paint/erase strokes, stamped through the shared GPU brush engine into one raster over only the 256 px tiles its paint reaches. Appending a gesture stamps only new dabs; undo or moved geometry replays the sequence. Shift adds and Alt/Option subtracts on the selected patch, preserving its donor and blend; separate strokes stay separate. The local SVG preview composes the same ordered operations and shows union boundaries and erased holes. Scene loading migrates earlier single-stroke patches once at the boundary. -- Remove patches store hard paint/erase strokes without a donor offset. `features/heal/inpaint` synthesizes a field of each hole texel's offset to its donor with a local, multiscale PatchMatch graph, entirely on the GPU: exclusion mask, preserved texture descriptors, nearest valid donor initialization, parallel propagation/random search, and overlap voting. Matching is bounded to 512 texels along the long side; the output samples donors at full source resolution. The Heal compositor applies the patch's coverage, feather, and opacity. -- A Remove field is synthesized once per stroke version and kept. Each edit that changes a Remove patch's strokes, adding, erasing, or moving them, reserves a new field in the document's resources, and the patch names it, so every scene with those strokes, in history, drafts, scene files, and exports, shows one fill however late it arrives; filling it never changes the scene or history. The renderer keeps fields by ID: it synthesizes a waiting one once, at full resolution, for the patch's whole shape, from the image below, and reads it back for the document at once, rendering nothing meanwhile. Patches that share a field, such as duplicates, share its synthesis. The layers below, a proxy, hiding, undo, export, and a reopened scene all use that field and never solve again; a version never shown synthesizes when it first shows. Export, scene files, and drafts complete their snapshot with the fields the editor shows that the document still waits for, and fail when that readback fails. Composition samples donor color from the current image below, so the patch follows changes under it, such as exposure. A stroke added over part of an object the first stroke missed therefore leaves none of it, since the whole shape synthesizes from context without it. Synthesis needs no React. See [the solver](src/features/heal/inpaint/algorithm.md) for its constraints and standalone benchmarks. -- A brush mask paints coverage as a Paint layer paints color: both are a painting, the latest strokes over settled pixels, in one raster module (r8 for coverage, rgba8 for color), which also owns the open stroke coverage view. At 100 strokes, once no gesture is open, the renderer reads a painting's raster band by band through one mapped buffer, and the pixels, deflated, become a document resource the layer names in place of its strokes, without a history step. Rendering waits until both the scene and cached raster accept the settled pixels. History scenes name the pixels they drew over, so undo reaches past a settle: the renderer loads those pixels, inflating them band by band through a staging texture, and draws the strokes again. Resources go once no retained scene names them. -- A selected layer's curve input, with the mask's coverage as alpha, renders at the few texels its histogram samples, never at full size. -- A mask without child effects renders its adjustments, curve, and mix in one pass over the image below, so no full-size texture holds its edited image; while it is inspected, its curve input reads the adjusted image, so the three render separately. A mask that only gradients shape needs no texture: the mix pass computes it, children included. A mask with a painted brush or a range combines in the render graph: each child's coverage, a brush's cached raster, a gradient drawn by a node, or a range read from the image below the mask, folds into the group's in stored order through one combine pass, and the mix pass shares its add, subtract, and intersect. The group takes the image's resolution when a range takes part, since a range follows the photo's edges, and the brushes' otherwise. The renderer gives it out, with each range's own coverage, for the overlay and thumbnails. While a color is picked, renders also keep the image below the mask group, which the picker reads through one GPU pass and 16 bytes of readback per pick. -- Renders reduce the source by the largest whole factor that still gives every device pixel a texel, a proxy, in gestures or not, so the photo's own size renders once the display shows more than half a device pixel per source pixel; the main canvas writes that density into the preview state, and the provider renders again when it changes. A proxy that lacks a Remove patch's field synthesizes it apart, outside a gesture: the layers below the patch render in full for the field alone, and the proxy composes again with it, so the display never shows a full render for it. The export view renders its own full snapshot, which its preview and the saved file both encode. Every render image carries `scale`, its source pixels per texel; shaders that take document coordinates or radii apply it. -- The scene's frame maps output to source in one homogeneous transform, `frameTransform` in `core/image/frame.ts`: crop, rotation, flips, and scale, then the perspective correction about the source's center. Layers, paint, masks, and patches stay in source pixels, so correcting perspective leaves them in place; `transformImage` resamples the composite once, the display draws the comparison and the mask overlay from the source through the same transform, and a frame that changes nothing adds no pass. The proxy follows the frame's strongest magnification, perspective's included. `lib/projective.ts` holds the plane projective geometry the frame, the Crop tool's coverage, and the canvas mapping share. -- TIFF and camera RAW decode through `raw-webgpu`; OpenLight adapts its resources to document ownership. A RAW source keeps its as-shot development, which a renderer shows as long as that balance is selected; another balance develops into the renderer's own texture. diff --git a/CONTEXT.md b/CONTEXT.md index 9822c021..36c89d94 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,22 +1,20 @@ # Domain terms -OpenLight edits documents containing image content and adjustments. - | Term | Meaning | | --- | --- | -| **Document** | An independent editing session with its own canvas, history, and resources. Opening an image creates a document containing that image. | -| **Scene** | The document's serializable content: canvas geometry, image-source references, and adjustments. Files and GPU resources live outside it. | -| **Layer** | A source, effect, or mask in the composition. Image layers own basic adjustments; effects process the image below; masks scope their basic adjustments and child effects. Linear, radial, brush, and range child masks add, subtract, or intersect coverage. A Paint layer is an effect that lays its strokes' colors over the image with a blend mode. Layers compose render nodes without owning textures. | -| **Stroke** | One brush drag: paint or erase, size, feather, flow, and points with pressure in source pixels; a Paint layer's strokes also carry a color. Strokes are content; the renderer rasterizes them into a cached texture at half the source's resolution, coverage for a mask and premultiplied color for a Paint layer, and the latest stroke into a shared stroke buffer until the next one starts. A Paint layer's or brush mask's strokes settle into pixels every 100 strokes, which it then paints over; undo still reaches every stroke. A Healing patch stores ordered hard paint/erase strokes, rasterized over only the tiles its paint reaches, and feathers its accumulated shape separately. | -| **Range** | A mask that selects pixels of the image below its mask group by lightness, a luminance range, or by color, a color range, rather than by position. A new color range has no coverage until it takes a color from a click on the photo. | -| **Healing patch** | One painted, non-destructive repair in a Healing layer. Its Heal mode copies a nearby donor, found automatically or set by hand, with a bounded boundary color correction. Clone mode copies the donor without color correction. Remove mode synthesizes many local donor patches from surrounding texture and has no donor offset; it keeps where each pixel copies from as its field, so the layers below change only the copied colors, and a stroke added or erased synthesizes its whole new shape. Patches replay in order and each sees the result of the preceding patches. Each gesture keeps its recorded size and separate path; Shift adds and Alt/Option subtracts on the selected patch. Feather and opacity remain editable on its accumulated shape. | -| **Perspective correction** | Part of the frame: Vertical and Horizontal keystone that makes lines converging in the photo, such as a building's edges, parallel, before the crop, rotation, and flips. Layers keep their source pixels and follow it; tools draw and place them as it maps them. | -| **Image source** | An image used as input to the document's composition. It is content within a document. | -| **LUT** | A 3D color lookup table, such as a creative grade, read from a `.cube` file. A LUT layer holds one and applies it to the image below, at the layer's opacity. | -| **Edit** | One reversible content change. A continuous slider or curve drag is one edit, as is importing a set of adjustments. | -| **Scene file** | A document saved as an `.openlight` archive: its scene, the original source files, the pixels its paint settled into, and its Remove fields. Opening it creates a document that continues editing; history and preview settings are not saved. | -| **Draft** | The latest edited document, kept in the browser as a scene with its source files, settled paint, and Remove fields so it survives closing the tab. The start screen offers to recover or forget it; recovering opens a new document with empty history. | -| **Workspace** | The open-document session. It currently holds at most one document, replaced when another image or scene file is opened. | -| **Tool** | What a drag on the canvas does: Brush paints colors on a Paint layer or coverage on a brush mask, as its Color or Mask mode says, Healing repairs, and Linear and Radial gradient draw masks and edit the selected gradient's guides. Adjust leaves the canvas to pan and zoom. Crop and Export bring their own view. Switching tools never changes the sidebar; Brush and Healing create their layer on entry and remove it again if it leaves untouched. A selected mask is *active*; it is *edited* only while a shape tool is on. | +| **Workspace** | The open-document session; it holds one document, replaced when another image or scene file opens. | +| **Document** | An independent editing session with its own scene, history, and resources. | +| **Scene** | The document's serializable content: frame, image-source references, and layers. Files and GPU resources live outside it. | +| **Image source** | An image the document composes, referenced by ID. | +| **Layer** | A source, effect, or mask in the composition. Effects process the image below; masks scope their adjustments and child effects, and child masks add, subtract, or intersect coverage. | +| **Stroke** | One brush drag: paint or erase, size, feather, flow, and points with pressure in source pixels. | +| **Range** | A mask that selects pixels by lightness or color rather than position. | +| **Healing patch** | One repair in a Healing layer: Heal copies a donor with color correction, Clone copies it as is, Remove synthesizes the area from its surroundings. | +| **Perspective correction** | Vertical and Horizontal keystone in the frame, applied before crop, rotation, and flips. | +| **LUT** | A 3D color lookup table from a `.cube` file, applied by a LUT layer. | +| **Edit** | One reversible content change; a whole gesture is one edit. | +| **Scene file** | A document saved as an `.openlight` archive to continue editing. | +| **Draft** | The latest edited document, kept in the browser to survive closing the tab. | +| **Tool** | What a drag on the canvas does: Brush, Healing, Linear and Radial gradient; Adjust pans and zooms. Crop and Export bring their own view. | Use these names consistently in code, UI text, and documentation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b9c1ad1d..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributing - -[AGENTS.md](AGENTS.md) covers setup, checks, and code conventions for people and agents alike. [ARCHITECTURE.md](ARCHITECTURE.md) maps the code, and [REVIEW.md](REVIEW.md) describes how a change is reviewed and verified. Open pull requests with the [template](.github/pull_request_template.md). diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index e082e3a5..00000000 --- a/DESIGN.md +++ /dev/null @@ -1,22 +0,0 @@ -# Interface design - -## UI library - -Use [`@roprgm/ui`](https://ui.roprgm.com) for standard controls, imported as `@roprgm/ui/`. Its theme is loaded in `src/index.css`. The library owns their appearance and interaction; use its defaults and supported variants. Consult the installed component API when choosing props. - -Choose `Button` or `IconButton` for actions, `Menu` for commands, `Popover` for settings, and `Select` for choices. Use the library's other primitives wherever they cover the interaction. - -Keep styling overrides specific to an editor requirement demonstrated by the task. An existing override is not a rule to copy onto other controls. - -## Editor composition - -The photo editor has layouts and interactions the library does not provide. Reuse the existing editor components and compose library primitives inside them: - -- `EditorPanel`, `PanelBody`, and `PanelHeader` organize the desktop sidebar. -- `EditorViewport` and `ViewportStage` separate the image from overlays that stay fixed in the viewport. -- Features describe numeric controls as `Parameter`s, rendered as library sliders on desktop and local dials in the mobile dock. Reuse `DockControls`, `DockChips`, and `DialRow` for that composition. -- Layer rows use `ListItem` with their own thumbnails, controls, and drag behavior. - -Keep editor-specific controls with their feature. `src/components/ui` holds shared interactions missing from the library, such as `Dial`, `TreeDrag`, and `TextLink`. Add a local component only when existing primitives cannot provide the required behavior. - -See [ARCHITECTURE.md](ARCHITECTURE.md#layouts) for layout ownership and [REVIEW.md](REVIEW.md#verify) for visual verification of UI changes. diff --git a/PERFORMANCE.md b/PERFORMANCE.md deleted file mode 100644 index 6fc573f0..00000000 --- a/PERFORMANCE.md +++ /dev/null @@ -1,32 +0,0 @@ -# Rendering performance - -Changes to GPU work, such as shaders, pipelines, scheduling, or resource lifetimes, carry before/after measurements in the PR. Other changes need none. - -## Measure the workload - -1. Compare base and changed revisions with the same fixture, parameters, device, backend, and browser flags, and record the command that reproduces the result. -2. Separate setup and first render from repeated rendering. Warm up, then report sample count, median, and p95. -3. For a new effect, compare the previous workload with the changed revision's neutral or bypassed path, then report its active cost. -4. Check correctness alongside timing, and explain intentional tradeoffs. - -| Measurement | Scope | -| --- | --- | -| CPU preparation | Encoding commands, measured with `performance.now()`. | -| GPU duration | Timestamped passes; a sum covers only the passes measured. | -| Completed-render latency | Time until submitted work completes; state what it includes. | - -SwiftShader results describe that software backend only; do not extrapolate them to physical GPUs. If timestamps are unsupported, report GPU timing as unavailable rather than substituting CPU time. - -## Timing and inspection - -Enable the optional `timestamp-query` device feature, create a vgpu `timer(gpu)`, and pass it to `createRenderGraph` or `createEditorRenderer`; the graph times every node and results arrive through `timer.onResults`. `inspect()` on the graph reports executed passes, prepared effects, and retained intermediate textures. Interactive edits render a reduced proxy, so state the proxy factor when measuring them. See the [vgpu guide](https://github.com/vercel-labs/vgpu/blob/main/docs/topics/performance-playbook.docs.md#13-time-passes-before-optimizing-timer). - -## Running the benchmark - -Run `bun run test:browser --config playwright.bench.config.ts` apart from other browser work, and select a workload with `--grep`, e.g. `--grep 'rendering heal'`. Workloads and settings live in [the benchmark](tests/rendering-benchmark.ts): a deterministic 2400×1600 linear Rec.2020 fixture, 8 warmups, and 40 samples. Results and rendered PNGs go to `test-results/benchmarks`; attach them to the PR rather than committing them. - -## Object removal - -`bun run test:browser --config playwright.bench.config.ts --grep 'inpainting|rendering remove'` runs three headless workloads. `inpainting solver` measures a fresh local synthesis and mask raster without app composition; `inpainting editor` measures the same moving, 240 px patch in a 2400×1600 scene through `createEditorRenderer`, including the final blend. Both move the stroke one pixel each sample to require a new solve. `rendering remove` retains an unchanged stroke, measuring composition once the field is synthesized. All use 8 warmups and 40 samples, separate first render and CPU encoding, and profile timestamps in a second run. None mounts React. Images are already resident on the GPU; decoding, display, and export are excluded. - -Remove keeps one field per patch, offsets rather than a full-resolution RGB crop (at most 1 MiB), among the `rasters` of `inspect()`; include them alongside `textures` when calculating memory. Hiding the layer retains it, and removing the patch releases it. Matching uses a local grid capped at 512 texels rather than scanning the whole photograph. diff --git a/README.md b/README.md index 279034c8..212a0c60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenLight -**An open-source photo editor for the browser, built with [vgpu.sh](https://vgpu.sh).** +**An open-source photo editor for the browser, built with [vgpu.sh](https://vgpu.sh).** Try it at [openlight.app](https://openlight.app). Image processing runs on your own GPU with WebGPU. Photos never leave your device. @@ -9,49 +9,12 @@ Image processing runs on your own GPU with WebGPU. Photos never leave your devic ## Features -**Adjust** +- **Adjust:** exposure, contrast, highlights, shadows, whites, blacks, white balance with auto, vibrance, saturation, tone curves, clarity, sharpening, Color Mixer, vignette, and film grain. +- **Layers and masks:** effect layers, Color and Paint layers with Photoshop blend modes, 3D LUTs, and linear, radial, brush, luminance, and color range masks that add, subtract, or intersect. +- **Tools:** Healing with Heal, Clone, and Remove; Brush with pen pressure; crop, rotate, straighten, flip, and perspective correction. +- **Files:** open JPEG, PNG, WebP, AVIF, GIF, BMP, SVG, HEIC, TIFF, and camera RAW/DNG; import Camera Raw XMP; export PNG, JPEG, or WebP; save scenes to keep editing, with a draft recovered after closing the tab. -- Exposure, contrast, highlights, shadows, whites, blacks, temperature, tint, vibrance, and saturation. -- Auto white balance, which neutralizes the photo's color cast as a starting point for temperature and tint. -- Tone curves with a live input histogram. -- Clarity and sharpening. -- Color Mixer with eight hue, saturation, and luminance ranges. -- Vignette and film grain. - -**Layers and masks** - -- Draggable effect layers: Details, Exposure, Color Mixer, Vignette, Grain, Color, which paints one color with Photoshop blend modes, Paint, which holds brush strokes in any colors with the same modes, and LUT, which grades the photo with a 3D LUT from a `.cube` file. -- Linear and radial masks with their own adjustments and child effects. -- Add and Subtract submasks, from a mask's actions menu, to shape its coverage. - -**Tools** - -- Healing paints patches from an automatic or manually positioned donor. Repairs remain editable and each patch builds on the previous result. -- Brush, linear, and radial gradient tools on the rail draw masks on top of the stack; their options sit in the bar over the image. -- The Brush paints colors on a Paint layer or coverage on a brush mask, with size, feather, flow, pen pressure, and Alt to erase. Its primary and secondary colors sit at the foot of the tool rail, as in Photoshop. -- Photoshop's keys: B on the Brush switches Color and Mask; X swaps the colors, or paint and erase on a mask; D resets them to black and white; I picks one from the screen where the browser offers it; `[` and `]` size the brush and Shift with them its feather; digits set the flow and Shift with them the layer's opacity. A right click opens size and feather where you paint. -- A photo holds up to 4 Paint layers and 10 brush masks, since each keeps a raster at half the photo's resolution. A Paint layer's or brush mask's strokes settle into pixels every 100 strokes, so undo stays quick however much you paint, and still reaches back through them. -- The mask overlay shows until a mask changes the image, then the image shows the mask; the overlay button beside the mask's adjustments, or O, shows or hides it for every mask. -- Edits preview at a reduced resolution while you drag, then render in full. - -**Compose** - -- Crop with aspect presets, rotate, straighten, and flip. -- Pan and zoom the canvas. - -**Review** - -- Undo and redo, before/after comparison, RGB histogram, and clipping overlays. - -**Files** - -- Open JPEG, PNG, WebP, AVIF, GIF, BMP, SVG, HEIC, TIFF at 8-bit, 16-bit, and floating-point precision, and camera RAW/DNG with absolute white balance and As Shot reset. -- Import Camera Raw XMP settings, and `.cube` LUTs as layers. -- Export PNG, JPEG, or WebP with resizing, a live preview, and the resulting file size. -- Save the photo with every edit as an OpenLight scene, then open it to continue editing. -- Recover the latest edited photo after closing the tab, from a draft kept in the browser. - -Export saves a flattened image, a scene file keeps the photo with its edits, and the browser keeps the latest edited document as a draft. HEIC needs a browser with a WebCodecs HEVC decoder. RAW decoding uses [raw-webgpu](https://github.com/roprgm/raw-webgpu), which documents format support and limitations. +RAW decoding uses [raw-webgpu](https://github.com/roprgm/raw-webgpu). HEIC needs a browser with a WebCodecs HEVC decoder. ## Browser support @@ -64,9 +27,7 @@ bun install bun dev ``` -The shared controls, such as buttons, sliders, menus, and panels, come from [@roprgm/ui](https://ui.roprgm.com). See [AGENTS.md](AGENTS.md) for browser setup and checks, and [ARCHITECTURE.md](ARCHITECTURE.md) for how the code is organized. `window.openlight` exposes a scripting [API](API.md). - -Add `?stats` to the URL to see frames per second and every texture and buffer the GPU makes, frees, and keeps, with their memory; Reset counts a workflow from zero. It wraps WebGPU's own methods, so nothing else in the code reports to it. +See [AGENTS.md](AGENTS.md) for checks and conventions. `window.openlight` exposes a scripting [API](API.md). ## License diff --git a/REVIEW.md b/REVIEW.md deleted file mode 100644 index a8d013ae..00000000 --- a/REVIEW.md +++ /dev/null @@ -1,42 +0,0 @@ -# Review - -A review checks a change before its PR is ready: substantial changes get one, and anyone can ask for one. Code should stay a small, readable example of how the application works; [AGENTS.md](AGENTS.md) and [ARCHITECTURE.md](ARCHITECTURE.md) hold the rules. Working code is half of the question; the other half is whether the change is the smallest correct one. - -## Scope - -Read what changed: the diff against the base and what it was meant to do. Then follow the changed code to the user actions that reach it. That set is what the review verifies; workflows the change cannot reach need no check. A sidebar layout change does not call for export tests. - -## Verify - -`bun run check`, `bun run build`, and `bun run test` are fast; run them for any code change. Beyond that, pick the cheapest check that would catch a regression in the scope: - -| Change | Check | -| --- | --- | -| Documentation | Links and the references it describes. | -| Layout, styling, copy | Look at the affected screens at the widths they change. New UI keeps the spacing of the components around it: even padding to its container's edges and corners, and the same gaps. | -| Document, edits, history, loaders | The Bun tests covering them. | -| Processing, shaders, rendering | The browser tests of that feature, e.g. `bun run test:browser tests/vignette.e2e.ts`; before/after measurements when GPU work changes ([PERFORMANCE.md](PERFORMANCE.md)). | -| Shared primitives in `core/` or `components/` | The browser tests of the workflows that use them. | - -Run the whole browser suite only when a change cuts across the editor. A failure that also happens on the base is not the change's; report it as a gap. - -## Screenshots - -New features and substantial UI or design changes show the affected interface in the PR description: before and after for changed UI, the new states for new UI, with the same fixture and viewport on both sides. For a smaller visible change, ask whether screenshots are wanted. Upload images to GitHub as PR attachments; never commit them. - -## Read the diff - -Before judging the lines, work out how the request could be solved with what the code already has, then compare that with the change. - -- **Size:** Does each added module, option, branch, and test come from the request? Could the change extend an existing model or primitive instead of adding one beside it? Flag abstractions with one caller and code for cases nothing reaches. -- **Root cause:** Is the fix where the problem starts, rather than an adapter, conversion, or special case around a model? Does it repeat something that exists, such as a second brush or loader, where one shared primitive would be less code? When the right fix is larger than the request, name it. -- **Ownership:** Does each module keep one responsibility and stay in its layer? Does feature behavior stay in its feature? Do edits, processing, and rendering run without React? -- **State:** Is each fact stored once and behavior driven by it rather than by DOM structure or timing? Is external input validated at its boundary? -- **Performance:** Does pixel work run on the GPU, without readbacks the result does not need? Does work follow what changed rather than repeat on every render or pointer event? -- **Lifetime:** Do edits, grouping, cancellation, and undo work together? Can async work outlive its document? Is every resource and subscription released? -- **Tests:** Would a user workflow test fail if the change broke, and is it the smallest test that would? Flag tests that protect no workflow or regression. -- **Architecture:** If the structure changed, was it agreed and is [ARCHITECTURE.md](ARCHITECTURE.md) updated? - -## Report - -For each finding, name the code, the concrete problem, and the smallest fix; separate required changes from optional ones. A simpler implementation that removes code without losing behavior is required. List the checks run and any gaps. diff --git a/src/components/editor/layout.tsx b/src/components/editor/layout.tsx index cc3564b0..4da369a8 100644 --- a/src/components/editor/layout.tsx +++ b/src/components/editor/layout.tsx @@ -38,7 +38,8 @@ export function EditorFrame({ /** * A view's canvas and controls in the frame: the sidebar beside the canvas on desktop, the dock under * it on mobile. The canvas comes first in both, so crossing the breakpoint moves it without mounting - * it again; the sidebar and the dock swap. `dock` defaults to `panel`. + * it again; the sidebar and the dock swap, and neither mounts hidden, since each subscribes to the + * document and the histogram reads the GPU. `dock` defaults to `panel`. */ export function EditorLayout({ canvas, diff --git a/src/features/heal/inpaint/algorithm.md b/src/features/heal/inpaint/algorithm.md index 99359068..40caf99d 100644 --- a/src/features/heal/inpaint/algorithm.md +++ b/src/features/heal/inpaint/algorithm.md @@ -1,29 +1,17 @@ # GPU object removal -`inpaint(source, coverage, region, name)` declares a pure render graph; it requires no React, document store, model weights, workers, or pixel readback. `inpaintField` returns each texel's offset to its donor, in texels, as `rg16float`, zero where nothing replaces the pixel: at most 1 MiB. The editor keeps that field, so `pass.ts` synthesizes once per set of strokes and otherwise only calls `sampleInpaint` and blends the result into the scene. All image processing runs in WGSL; TypeScript only describes lattices, levels, and passes. The working image remains linear Rec.2020, with HDR values and the destination alpha preserved by the final blend. The solver works on `[dx, dy, cost, valid]` in `rgba32float`. `sampleInpaint` returns donor RGB with validity in alpha for the compositor. +`inpaintField` declares a pure render graph: no React, document store, model weights, workers, or pixel readback. It returns a field of each hole texel's offset to its donor. The editor keeps that field, so a patch synthesizes once per set of strokes, and composition samples donor color from the current image below: the patch follows later edits under it without solving again. -The algorithm follows non-local, multiscale texture synthesis with PatchMatch, adapted for parallel GPU execution. It is not a reproduction of every step of the IPOL reference and does not infer hidden semantic content. +The algorithm adapts non-local, multiscale texture synthesis with PatchMatch to parallel GPU execution; it does not infer hidden semantic content. -1. Crop the accumulated patch with surrounding context: at least 32 source pixels, otherwise the largest painted brush diameter on every side, clipped to the image. Combine ordered paint/erase strokes in the shared GPU coverage raster; subtraction does not enlarge the region. Exclude the remaining painted object conservatively before creating a color pyramid. -2. Work on at most 512 texels along the long side. Precompute logarithmic color once. Keep horizontal and vertical texture energy through the pyramid, so coarse colors do not erase fine texture differences. -3. Initialize from nearby wholly unmasked donors using jump flooding and grow the fill inward at the coarsest level; upsample and preserve the previous level's offsets. A donor's entire 7×7 footprint must be clean. -4. Alternate parallel PatchMatch propagation at jumps 8, 4, 2, 1 and eight shrinking-radius random proposals with weighted overlapping reconstruction. Use six iterations at the coarsest level, four at finer levels. Compare nine samples across each 7×7 footprint plus texture descriptors; increase reconstructed-region confidence as refinement proceeds. -5. Keep each hole texel's offset. Sample donors at whole source texels from the image below for the final image rather than exporting the averaged matching pyramid, so the patch follows later changes to that image without solving again. Different pixels can use different offsets. Apply the existing inward feather, patch opacity, and original alpha in a separate blend. - -A stroke added to or erased from a patch synthesizes its whole new shape, so what an earlier stroke missed of an object no longer shapes the fill. - -Known, wholly clean patches skip search. Candidate comparison stops once its nonnegative partial error cannot beat the current score. Each search pass owns its uniforms; vgpu shares shader modules and pipelines, and the graph reuses temporary textures across iterations. Reconstruction passes share an effect because they have no uniforms. Random proposals are deterministic for the same inputs; since the inputs include the image below, a change there would find other donors, which is why the editor keeps the field instead of solving again. +1. Crop the patch with surrounding context and exclude the painted object. +2. Match on a pyramid of at most 512 texels along the long side, keeping texture energy so coarse colors do not erase fine texture differences. +3. Initialize from the nearest wholly clean donors and grow the fill inward from the coarsest level. +4. Alternate parallel propagation and random search with overlapping reconstruction. +5. Sample donors at full source resolution. ## Limits -Small defects in uniform or recurring texture and locally supported boundaries work best. Large missing regions, perspective, unique structures, and several competing backgrounds are ambiguous. Straight edges can continue when they have good local donors; mortar, rails, text, faces, and other precise structures can break or repeat. The local search cannot recover content that only appears elsewhere in the photo. A coarse matching grid can lose features smaller than its texel spacing; output sampling preserves donor detail but cannot recover correspondence detail lost during matching. If there are no clean donors, the affected pixels remain unchanged. - -The user should paint over the entire object, including its halo or shadow. Pixels left outside the stroke are treated as valid context. A larger selection is not necessarily easier: it removes more evidence. Adding distant strokes to one patch expands its bounding region and makes the shared matching grid coarser; separate patches preserve more detail for distant spots. - -## Verification and measurement - -`tests/inpaint-gpu.ts` creates known flat, gradient, periodic, edge, and brick backgrounds, allowing error measurement against a clean reference. Browser tests check actual removal, HDR/alpha, untouched pixels, proxy rendering, a kept field that follows the exposure below without solving again, a stroke added over a missed tip that fills as painting the whole shape at once does, release, unavailable donors, and the H/paint/export/undo workflow. Scene files round-trip Remove patches with their fields and without offsets. `tests/inpaint-photo.ts` runs decoding, removal, and export on the demo photograph without mounting React. - -See [PERFORMANCE.md](../../../../PERFORMANCE.md#object-removal) for standalone and integrated benchmarks, including an unchanged-patch cache workload. Report the actual adapter; software-backend timings do not predict physical-GPU latency. +Small defects in uniform or recurring texture work best. Large regions, unique structures, text, faces, and competing backgrounds are ambiguous, and the local search cannot recover content that only appears elsewhere. Pixels outside the stroke count as valid context, so paint over the whole object, including its halo or shadow. Distant strokes in one patch coarsen the matching grid; separate patches keep more detail. -Algorithm references: [Newson et al., non-local multiscale inpainting](https://www.ipol.im/pub/art/2017/189/), [Barnes et al., PatchMatch](https://pixl.cs.princeton.edu/gfx/pubs/Barnes_2009_PAR/index.php). +References: [Newson et al., non-local multiscale inpainting](https://www.ipol.im/pub/art/2017/189/), [Barnes et al., PatchMatch](https://pixl.cs.princeton.edu/gfx/pubs/Barnes_2009_PAR/index.php). diff --git a/tests/crop.e2e.ts b/tests/crop.e2e.ts index 71bbaeb8..c2547b1e 100644 --- a/tests/crop.e2e.ts +++ b/tests/crop.e2e.ts @@ -3,7 +3,7 @@ import { expect, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; import { box, choose, drag, zoom } from "./pointer"; -/** Where API.md puts the point Vertical −50 sends to infinity: 1 / (0.0045 · 50) half-heights above the center. */ +/** Where the frame's perspective weights put the point Vertical −50 sends to infinity: 1 / (0.0045 · 50) half-heights above the center. */ const vanishing = 400 - 400 / (0.0045 * 50); const dot = [698, 520] as const; /** Black lines that meet above the photo, as a building's edges do when the camera tilts up, and a blue dot between two. */ diff --git a/tests/fixtures/raw/README.md b/tests/fixtures/raw/README.md index be95c2d4..ac19a1cb 100644 --- a/tests/fixtures/raw/README.md +++ b/tests/fixtures/raw/README.md @@ -1,7 +1,3 @@ # RAW fixtures -These two synthetic MIT-licensed fixtures are copied unchanged from [raw-webgpu](https://github.com/roprgm/raw-webgpu/tree/7aa3573e23c1cfd71cb4f7227beff82484a9d1fd/tests/fixtures), which owns their generator and codec tests. They contain no camera photographs. - -`bayer.dng` stores RGGB samples; `linear-jxl.dng` stores equivalent linear RGB with JPEG XL compression. Both use an sRGB-like camera profile, samples `[32768, 16384, 8192]`, white level 65535 and orientation 6. Expected output is 96×128 with sRGB pixels approximately `[188, 137, 99]`. - -OpenLight tests loading, white-balance edits, preview/export agreement, history and resource lifetime with these files. +These two synthetic MIT-licensed fixtures are copied unchanged from [raw-webgpu](https://github.com/roprgm/raw-webgpu/tree/7aa3573e23c1cfd71cb4f7227beff82484a9d1fd/tests/fixtures), which owns their generator and codec tests. They contain no camera photographs. `bayer.dng` stores RGGB samples; `linear-jxl.dng` stores equivalent linear RGB with JPEG XL compression. From 3410489cd431c584ce544aab4f61253cc7677baa Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Tue, 13 Oct 2026 14:27:04 -0700 Subject: [PATCH 2/2] Restore creation defaults and the primitives rule from review Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +++--- API.md | 8 ++++---- CONTEXT.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88cf59be..ade39ef3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A professional photo editor with little, readable code: Vite, React, [vgpu](http ## Commands -`bun install`, then `bun dev`. On every code change run `bun run check` (writes fixes), `bun run build`, and `bun run test` (Bun with `vgpu/mock`); CI runs them too. `bun run test:browser [file]` runs `*.e2e.ts` in Chromium with real pixels; install its browser once per Playwright version with `bunx --no-install playwright install chromium`. It starts its own Vite: stop any other Vite server first, since concurrent servers reload each other's pages mid-test. Any other scripted browser reuses the launch flags in [playwright.config.ts](playwright.config.ts). The assistant at `/?assistant` needs `vercel dev` with `AI_GATEWAY_API_KEY` in `.env.local`. +`bun install`, then `bun dev`. On every code change run `bun run check` (writes fixes), `bun run build`, and `bun run test` (Bun; GPU tests use `vgpu/mock`). `bun run test:browser [file]` runs `*.e2e.ts` in Chromium with real pixels; install its browser once per Playwright version with `bunx --no-install playwright install chromium`. It starts its own Vite: stop any other Vite server first, since concurrent servers reload each other's pages mid-test. Any other scripted browser reuses the launch flags in [playwright.config.ts](playwright.config.ts). The assistant at `/?assistant` needs `vercel dev` with `AI_GATEWAY_API_KEY` in `.env.local`. ## Structure @@ -13,7 +13,7 @@ A professional photo editor with little, readable code: Vite, React, [vgpu](http - `features/`: one removable capability, usually `model.ts`, `edits.ts`, `pass.ts` with its `.wgsl`, and `controls.tsx`. Most product behavior lives here. - `app/`: shell, entry points, and the explicit composition of features. -Dependencies point down. Features never import each other; `app/` connects them. Keep module internals private; no application-wide barrel. +Dependencies point down: `core/`, `components/`, and `hooks/` never import features, and features never import each other; `app/` connects them. Keep module internals private; no application-wide barrel. Edits and rendering run without React or an implicit active document: pass the workspace, document, and GPU explicitly. UI controls, `window.openlight`, and commands call the same edits; a change to them updates API.md in the same change. The workspace owns document replacement; each document owns its scene, history, and resources. Scenes hold immutable, serializable content; files and GPU resources stay outside history. A gesture is one edit; preview and navigation stay outside history. Async work never outlives its document. @@ -56,5 +56,5 @@ Tests follow what a person verifies: open an image, use a tool, undo, export, ch ## Review and PRs - Verify what the change can reach with the cheapest check that catches a regression: Bun tests for document, history, and loaders; the feature's `*.e2e.ts` for processing and rendering; the whole suite only when a change cuts across the editor; the affected screens for UI. A failure that also happens on the base is a gap to report. -- Review substantial changes before the PR is ready; offer a review after small ones. Name each finding's code, problem, and smallest fix, separating required from optional. A simpler version that removes code without losing behavior is required. +- Review substantial changes before the PR is ready; offer a review after small ones. Name each finding's code, problem, and smallest fix, separating required from optional. A simpler version that removes code without losing behavior is a required change. - Short PR descriptions with the [template](.github/pull_request_template.md). New features and substantial UI changes show before/after screenshots with the same fixture and viewport, uploaded as PR attachments. Generated output never goes in the repository. diff --git a/API.md b/API.md index 8b712666..1b9bc4a2 100644 --- a/API.md +++ b/API.md @@ -30,7 +30,7 @@ File loads are queued and `loadUrl` starts at once; the last load started wins. ## Editing -Commands take explicit layer IDs, never the UI selection. Without an ID, adjustments, the tone curve, and white balance address the image; effect setters use the first matching root effect or create one on top. Values must be finite and in range; invalid input throws. Edits update the scene and history synchronously; rendering finishes later. +Commands take explicit layer IDs, never the UI selection. Without an ID, adjustments, the tone curve, and white balance address the image; effect setters use the first matching root effect or create one on top, as `addLayer` does. Values must be finite and in range; invalid input throws. Edits update the scene and history synchronously; rendering finishes later. | Method | Arguments | | --- | --- | @@ -57,7 +57,7 @@ A `BrushStroke` has `mode` (`"paint"` or `"erase"`), `size` (diameter in source | Method | Behavior | | --- | --- | -| `addLayer(kind, placement?)` | `"exposure"`, `"color-mixer"`, `"details"`, `"vignette"`, `"grain"`, `"fill"`, `"heal"`, `"paint"`, or `"mask"`; `{ inside: id }` or `{ above: id }`, else on top. Selects and returns the ID. | +| `addLayer(kind, placement?)` | `"exposure"`, `"color-mixer"`, `"details"`, `"vignette"`, `"grain"`, `"fill"`, `"heal"`, `"paint"`, or `"mask"`; `{ inside: id }` or `{ above: id }`, else on top. Exposure starts at +1 EV, Vignette at intensity 50, Grain at amount 25, and Color at `#f0763c`; the rest start neutral. Selects and returns the ID. | | `selectLayer(id)` | Selects a layer, without history. | | `setLayer(id, change)` | `name`, `visible`, or `opacity` 0…1. | | `setExposure(id, value)` | Exposure layer, −5…5 EV. | @@ -87,7 +87,7 @@ try { `setPreview(change)` merges `comparison` (`"edited"`, `"original"`, `"split"`), `split` (0…1), `shadows`, and `highlights` (clipping, booleans), outside history and without validation. -`exportImage({ format, quality, longEdge })` returns a `File` without downloading it: `format` `"png"` (default), `"jpeg"`, or `"webp"`; `quality` 1…100, default 80; `longEdge` up to the document's size. It renders its own full-resolution snapshot, unaffected by the preview. `exportScene()` returns the document as an `.openlight` scene file. +`exportImage({ format, quality, longEdge })` returns a `File` without downloading it: `format` `"png"` (default), `"jpeg"`, or `"webp"`; `quality` 1…100, default 80; `longEdge` from 1 to the document's longest side. It renders its own full-resolution snapshot, unaffected by the preview. `exportScene()` returns the document as an `.openlight` scene file. ## State @@ -109,7 +109,7 @@ Types: `set-adjustments`, `set-tone-curve`, `set-white-balance`, `set-color-mixe ## WebMCP -In browsers with [WebMCP](https://webmachinelearning.github.io/webmcp/), such as Chrome 149 through an origin trial or `chrome://flags/#enable-webmcp-testing`, every command is a tool on `document.modelContext` with a JSON Schema from the same models. Tools return `{ layerId }` or `"Done."`; errors come back as text. +In browsers with [WebMCP](https://webmachinelearning.github.io/webmcp/), in Chrome through an origin trial or `chrome://flags/#enable-webmcp-testing`, every command is a tool on `document.modelContext` with a JSON Schema from the same models. Tools return `{ layerId }` or `"Done."`; errors come back as text. - `get-state` summarizes the file, size, frame, layers, comparison, and history. - `open-image` opens an http(s) `url` like `loadUrl`, since tools carry no files; serve a local file over HTTP with CORS. From a public origin, Chrome's [Local Network Access](https://developer.chrome.com/blog/local-network-access) asks before the page reaches a local address. diff --git a/CONTEXT.md b/CONTEXT.md index 36c89d94..09d1a6b6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -15,6 +15,6 @@ | **Edit** | One reversible content change; a whole gesture is one edit. | | **Scene file** | A document saved as an `.openlight` archive to continue editing. | | **Draft** | The latest edited document, kept in the browser to survive closing the tab. | -| **Tool** | What a drag on the canvas does: Brush, Healing, Linear and Radial gradient; Adjust pans and zooms. Crop and Export bring their own view. | +| **Tool** | What a drag on the canvas does: Brush, Healing, Linear and Radial gradient; Color range picks a range's color from the photo. Adjust pans and zooms. Crop and Export bring their own view. | Use these names consistently in code, UI text, and documentation.