Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Changes added by this fork to RapidRAW. Upstream application changes remain in t
- Add `link` to `rapidraw_mask_duplicate`. A linked inverse copy (`invert: true, link: true`) stays the inverted selection of its source: refinements, brushes, grow and feather changes and added components are copied into it on every MCP edit, while its own name, opacity and adjustments are kept. The link is stored as `linkedInverseOf` on the mask; removing the source leaves an ordinary mask with a warning, and a `mask_update` patch of `{"linkedInverseOf": null}` unlinks it. Edits made in the desktop editor are picked up at the next MCP edit.
- Add `coordinate_space` to `rapidraw_mask_create`, `rapidraw_mask_update` and `rapidraw_mask_generate`. Positions, rectangles, radii, brush sizes and linear fade widths can be given in full-resolution rendered pixels or in the pixels of an earlier preview; the bridge converts them to the pre-crop mask canvas and reports the conversion.
- Add `stage: "aligned_original"` to `rapidraw_sample_region`: the photograph with the edit's crop, geometry and retouching but default tone and colour, so one region compares directly with the edited stage.
- Add `--on-rejected continue` to the skill's MCP client. It keeps the connection after a request the server rejected before changing anything; the default still stops after every error.
- Keep the skill's MCP client connected after schema validation and other errors that reject a request before execution. Other MCP result errors stop the client by default; `--continue-on-error` keeps reading after them. Transport failures and timeouts still stop the client.

### Editor performance

Expand Down
29 changes: 18 additions & 11 deletions mcp/test/skill-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -120,28 +120,35 @@ test('skill client stops on native error instead of executing queued edits', asy
assert.equal((await readdir(records[0].output_dir)).filter((p) => p.endsWith('.json')).length, 1);
});

test('skill client can continue after a request rejected before any change', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'rr-skill-rejected-'));
test('skill client keeps running after a request rejected by schema validation', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'rr-skill-validation-'));
const { code, records } = await run(workspace, [
{ tool: 'set_adjustments', arguments: { session_id: 'example', unknown_argument: 1 } },
{ tool: 'capabilities' },
{ close: true },
]);
assert.equal(code, 0);
assert.equal(records.length, 3);
assert.equal(records[1].isError, true);
assert.equal(records[2].isError, false);
});

test('--continue-on-error keeps reading after a native error', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'rr-skill-continue-'));
const { code, records } = await run(
workspace,
[
{ tool: 'set_adjustments', arguments: { session_id: 'example', patch: { bad: 1 } } },
{ tool: 'get_session', arguments: { session_id: 'example' } },
{ tool: 'capabilities' },
{ close: true },
],
['--on-rejected', 'continue'],
['--continue-on-error'],
);
assert.equal(code, 0);
assert.equal(records.length, 3);
assert.equal(records[1].data.error.code, 'INVALID_ADJUSTMENT');
assert.equal(records[2].isError, false);
});

test('skill client rejects an unknown --on-rejected value', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'rr-skill-rejected-arg-'));
const { code } = await run(workspace, [], ['--on-rejected', 'ignore']);
assert.equal(code, 2);
});

test('client reads JSON resources selectively and never hides errors behind pick', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'rr-client-resource-'));
const { code, records } = await run(workspace, [
Expand Down
4 changes: 1 addition & 3 deletions skills/rapidraw-mcp/references/connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,9 @@ The client prints method-specific state, `response_path` and image paths. Job/re

It supports `resource` for MCP resource reads and `timeout_ms` for tool calls. For a longer native operation, launch this client with `--timeout-ms 900000` and give that request a larger client wait, for example `"timeout_ms": 960000`. These are separate limits: the launch flag is forwarded to the server and controls native processing; the JSON field controls how long the client waits. Both default to 300000 ms. The launch flag accepts a positive safe integer; request waits accept 100–1800000 ms. Raising only the request wait does not extend native processing. This client's SDK transport does not forward arbitrary shell environment variables, so use the launch flag rather than relying on `RAPIDRAW_TIMEOUT_MS` set in the parent shell.

By default the client stops after any error so queued requests never run on unexpected state. When you send one request at a time and read each result, launch it with `--on-rejected continue`: it then keeps reading after a request the server rejected before changing anything (invalid arguments, unknown IDs, revision conflicts). Other errors, timeouts and transport failures still stop it.

`pick` selects dotted paths for stdout, including parsed JSON resources; errors always retain their complete recovery information. For older connected servers, use `{"tool":"capabilities","pick":["coordinate_space","adjustment_schema.properties.temperature"]}`. No-argument capabilities and `detail: "full"` retain the full discovery contract for existing programs. A schema resource is another full schema read; it need not follow a successful selected read.

Image files are raw MCP result bytes, not a substitute renderer. By default, on an MCP/transport error it stops and closes rather than running queued edits or retrying (see `--on-rejected` above for rejected requests). Inspect its response and persisted state before reconnecting. Keep request secrets out of durable files. If the server SDK dependencies or native binary are missing, report the exact path/error; install/build only when needed and authorized.
Image files are raw MCP result bytes, not a substitute renderer. A request the server rejects before running anything (schema validation, `INVALID_ARGUMENT`, `REVISION_CONFLICT`, `JOB_BUSY`, `MODEL_NOT_INSTALLED`) is reported and the client keeps reading. Other MCP result errors stop the client by default; `--continue-on-error` keeps reading after those errors when each request is sent and inspected one at a time. Transport failures and timeouts always stop it. Inspect its response and persisted state before reconnecting. Keep request secrets out of durable files. If the server SDK dependencies or native binary are missing, report the exact path/error; install/build only when needed and authorized.

Only one connection can own a workspace at a time. Use separate workspaces for concurrent clients; do not delete an active `.bridge.lock` or interrupt another editing session to claim its workspace.

Expand Down
40 changes: 15 additions & 25 deletions skills/rapidraw-mcp/references/craft-moves.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,26 @@ How to carry out the light-shaping moves a direction skill (such as Lightweft's
{"tool":"mask_generate","arguments":{"session_id":"…","expected_revision":1,"kind":"subject","name":"Subject",
"region":{"x":…,"y":…,"width":…,"height":…},
"adjustments":{"exposure":0.35,"shadows":15,"clarity":10,"structure":10,"temperature":6}}}
{"tool":"mask_duplicate","arguments":{"session_id":"…","expected_revision":2,"mask_id":"SUBJECT_MASK_ID","name":"Environment","invert":true,"link":true}}
{"tool":"mask_duplicate","arguments":{"session_id":"…","expected_revision":2,"mask_id":"SUBJECT_MASK_ID","name":"Environment","invert":true}}
{"tool":"mask_update","arguments":{"session_id":"…","expected_revision":3,"mask_id":"ENV_MASK_ID",
"patch":{"adjustments":{"exposure":-0.5,"saturation":-20,"clarity":-15,"temperature":-6}}}}
```
Choose how to select the subject first (see [Choosing the subject selection](#choosing-the-subject-selection)); the AI subject above is the quickest starting point. Render the subject mask (`render(mask_id, mask_mode: "grayscale")`) before duplicating it. If it misses high-contrast parts (white patches on dark plumage, beak, eye, prey), refine it with `mask_generate(kind: "subject", refine: {mask_id}, include_points: [...])` in pre-crop coordinates, or pass `coordinate_space` to give the points in the preview you inspected. With `link: true` the environment mask follows every later change to the subject selection (refinement, brushes, grow/feather); an unlinked duplicate doesn't, so after refining it you would have to remove and duplicate it again.
Render the subject mask (`render(mask_id, mask_mode: "grayscale")`) before duplicating it. If it misses high-contrast parts (white patches on dark plumage, beak, eye, prey), refine it with `mask_generate(kind: "subject", refine: {mask_id}, include_points: [...])` in pre-crop coordinates. By default, a duplicate isn't linked to its source: after refining, `mask_remove` the old environment mask and duplicate again.
`mask_generate` accepts `adjustments` directly. `region` is in pre-crop full-resolution pixels and should box the whole animal, including anything it's holding. `mask_duplicate` clears the copy's adjustments by default, so set them with `mask_update`.
4. **Burns and dodges:** `mask_create` with `type: "linear"` for sky, foreground or bright strips (the start→end line is the boundary; left→right selects above it). Use `type: "radial"` with `feather` 0.7–0.9 for an eye dodge or corner burns.
**Environment in parts and local control.** To grade only part of the environment (for example sky and trees apart from snow), set the duplicated environment's `ai-subject` submask to `invert: true` and add a geometric submask with `mode: "intersect"` through `mask_update` `submask_operations` (omit `id`; the engine generates it). A parent-level invert does not combine with an intersect. For local control on the subject (a clipped sunlit patch), duplicate the subject mask, intersect a radial, and adjust only highlights and whites. Cut a stray background object the AI selection grabbed with a subtractive geometric submask, since exclude points often do not remove it.
4. **Burns and dodges:** `mask_create` with `type: "linear"` for sky, foreground or bright strips (see [linear gradients](#linear-gradients)). Use `type: "radial"` with `feather` 0.7–0.9 for an eye dodge or corner burns.
5. **Vignette:** `vignetteAmount` −15 to −35, `vignetteMidpoint` 30–50, `vignetteFeather` 50–80. It applies to the cropped frame.
6. **Finish:** `sharpness` 15–30 globally at most; subject detail via `structure` or `clarity` inside the subject mask.

## Choosing the subject selection
## Linear gradients

A Marigold depth band follows the real silhouette better than the AI subject: it leaves openings (an open beak, the space between legs or wing feathers) out of the subject, where the AI subject fills them. The band covers everything nearer than its threshold, though, so pick the combination by what else sits at the subject's depth.

| Scene | Selection | How |
| -------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Subject clear of everything else in depth: a close portrait or a bird against distant water or sky | Marigold band, edge refined by matting | `mask_generate` with `kind: "depth"`, `depth_provider: "marigold"`, `parameters: {minDepth, maxDepth: 100, minFade: 5, maxFade: 0}`, then `enhance` with `request: {operation: "refine_mask", profile: "quality", boundary_radius: 24}` on the depth submask |
| Water, ice, a perch, splash or foreground at the subject's depth or nearer | Marigold band ∩ AI subject | AI subject mask first, then the same depth `mask_generate` with `target_mask_id` set to it and `mode: "intersect"` |

- **Threshold:** render the depth mask at a few `minDepth` values (for example 35–85 in steps of 10) and keep the one that best fits the subject's outline. Depth is relative to each photograph, so don't reuse a value between photographs.
- **Check extremities:** a tail, wingtip or far leg can fall outside the band. The intersection also inherits anything the AI subject misses. Compare with the AI subject mask and add missed parts back with `include_points` or a brush.
- **Check the edge at 100%:** a band edge can show a fine sawtooth along high-contrast outlines; matting reduces it but doesn't always remove it. Keep the environment adjustment moderate there, or soften it with the `grow`/`feather` approach under [Halos and edges](#halos-and-edges).
A linear mask's start and end points define its boundary line, and the mask covers one side of it; left→right selects above the line. For a horizontal burn, give both points the same y and choose the selected side by the order of the x values. `range` is a width in pre-crop pixels: 0.5 produces a hard step, while about 600 on a 4640 px frame gives a broad blend. Render the mask in grayscale before judging the effect.

## Gaps inside the subject

AI subject masks fill small openings; a Marigold selection usually avoids this. With an AI subject selection, background seen through an open beak, between legs or between wing feathers is treated as subject and lifted, while the background around it is darkened. Don't hand-paint the gap with scanline or single brush strokes; they leave staircase edges or bars. Create a small correction parent instead: a generous `brush` over the opening, then a `color` submask in `intersect` mode sampled on the background inside the gap (`tolerance` 12–20, `feather` 10–20). Give it roughly the environment adjustment minus the subject adjustment, then match the gap's brightness to the nearby open background (`inspect_edit` should no longer list the gap). Check that the colour sample isn't on the subject (an orange beak, a pale fish), or the intersect will select the subject instead.
AI subject masks fill small openings: background seen through an open beak, between legs or between wing feathers is treated as subject and lifted, while the background around it is darkened. Don't hand-paint the gap with scanline or single brush strokes; they leave staircase edges or bars. Create a small correction parent instead: a generous `brush` over the opening, then a `color` submask in `intersect` mode sampled on the background inside the gap (`tolerance` 12–20, `feather` 10–20). Give it roughly the environment adjustment minus the subject adjustment, then use `sample_region` to match the gap's brightness to the nearby open background. Check that the colour sample isn't on the subject (an orange beak, a pale fish), or the intersect will select the subject instead.

Masks can also end at a strong highlight inside the subject, such as the pale edge of a mandible, leaving the part beyond it in the environment mask. `inspect_edit` reports this as similar pixels treated differently. Refine the subject with `include_points` on the missed part; a linked environment mask follows automatically.
Masks can also end at a strong highlight inside the subject, such as the pale edge of a mandible, leaving the part beyond it in the environment mask. Compare local ratios (for example upper vs lower beak) between `stage: "original"` and `stage: "edited"`. If one side has darkened relative to the other, refine the subject with `include_points` on the missed part, then rebuild the inverted environment mask.

## Halos and edges

Expand All @@ -47,18 +39,16 @@ A strong environment darkening next to a soft subject mask can leave a pale rim
- Keep the environment exposure moderate (−0.3 to −0.6) and add depth with a gradient or vignette rather than one heavy inverse mask.
- A thin rim that follows real light (backlight, sky reflection on feathers) is fine. An even glow all the way round is not.
- If a rim remains, set the same `grow` (about −10) and a low `feather` (about 4) on the AI submask of **both** the subject and the environment parent, so the transition sits just inside the subject. Around −20 and beyond, a dark line appears along bright edges such as beaks. Then re-render both overlays.
- `inspect_edit` with `subject_mask_id` reports a `rim_index` for the band just outside the subject (about 1 means no halo) and lists the edge sectors that differ. To measure by hand, compare `sample_region` `stage: "edited"` with `stage: "aligned_original"` on the same region.
- To measure a halo, sample small boxes stepping away from the edge and divide `edited` by `original` at each step. A flat ratio means no halo; a ratio that rises toward the subject means a halo. `stage: "original"` ignores the crop, so add the crop offset to the original-stage coordinates.
- Pass `cache: false` to `render` when checking a mask or edge change you just made.

## Measure before delivering

Run `inspect_edit` (with `subject_mask_id` when there is a subject) on each candidate. It compares the edit with an aligned reference that keeps the crop, geometry and retouching but has default tone and colour and no masks. It returns:

- frame brightness against the reference, bright and deep-shadow fractions, and each corner relative to the frame mean;
- subject vs. surround brightness (in stops) and warmth difference, for the reference and the edit;
- `rim_index` for the band just outside the subject;
- `look_here` regions in rendered coordinates: edge sectors with a brighter or darker rim, and areas that looked like their neighbours but were treated much differently. The heatmap shows brightness change against the reference (red brightened, blue darkened), with the `look_here` regions boxed in yellow.
Use `sample_region` (`stage: "edited"`, rendered coordinates after crop) to check the hierarchy the direction skill asks for:

It compares pixels, not meaning. It can miss background fully enclosed by the subject, or a missed part that already looked different from its surroundings in the reference, so look at the mask overlay and the heatmap as well as the list.
- the subject vs. a band of background around it (a ratio of about 1.3× or more in either direction, or a clear warm/cool difference);
- the four corners vs. the frame average (a corner containing the subject or a high-key field is an expected exception);
- the bright-white fraction before and after (a large drop means highlight recovery or a mask miss has greyed the whites);
- `stage: "original"` vs. `"edited"` means over the whole frame, so dusk and blue-hour scenes aren't lifted into daylight by accident.

These are measurements, not rules. A deliberate glow, a darkened dusk, a subject that matches its background or a local accent can all produce values that look "wrong" out of context. Use them to find what to inspect at native size, then decide. Record the measurements you relied on next to the export.
Record the measurements next to the export so a reviewer can see why the candidate passed.
Loading
Loading