Skip to content

Commit 797f71e

Browse files
adulbrichclaude
andauthored
Show the lens mask whether or not a preview image is selected (#259)
A preset carries a lens mask, but the radius/x/y fields were rendered only in the branch that draws the preview, so applying a preset with no image clicked left nothing on screen describing the mask. It read as absent while the run was still going to crop to it. The fields now render in both branches, with a line saying the values apply as they are and that selecting an image lets you place the mask on it. The e2e suite had already worked around the old behaviour by clicking a preview image purely so the fields would exist in the DOM. Applying a preset also never moved the drawn circle. useMotionValueFormState syncs the motion values into the form and never back out of it, so setValue("lensMask", ...) updated the fields while the circle stayed where it was, and the next drag wrote the stale centre back over the preset's. PresetBar now hands the mask to the page, which sets the three motion values. The size a preset's mask was drawn against travels with it, so the mismatch warning no longer depends on an image having been selected at the moment the preset was applied. It is rendered beside the mask for as long as it holds, and dropped as soon as the user moves the circle, since at that point they have placed it against what they can see. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b39b00e commit 797f71e

5 files changed

Lines changed: 407 additions & 70 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from "@jest/globals";
2+
import { act, render, screen } from "@testing-library/react";
3+
import { motionValue } from "framer-motion";
4+
import { useForm } from "react-hook-form";
5+
6+
// jest.mock must be hoisted above the imports below, which the SWC transform
7+
// only does for the global binding, not one imported from @jest/globals.
8+
declare const jest: typeof import("@jest/globals").jest;
9+
10+
// The hook returns nothing when there is no path, which is the state under
11+
// test: no preview image has been clicked.
12+
jest.mock("@/lib/generic-image-metadata", () => ({
13+
useGenericImageMetadata: (path?: string) =>
14+
path ? Promise.resolve({ size: [5616, 3744] }) : undefined,
15+
}));
16+
17+
import { TooltipProvider } from "@/components/ui/tooltip";
18+
import type { pipelineConfig } from "../src/app/pipeline/(pipeline-configuration)/config-provider";
19+
import { LensMaskInput } from "../src/app/pipeline/lens-mask-input";
20+
21+
/** The mask a preset applies, in image pixels. */
22+
const PRESET_MASK = { radius: 1800, x: 2808, y: 1935 };
23+
24+
/** The image the mocked metadata hook describes. */
25+
const PREVIEW_SIZE: [number, number] = [5616, 3744];
26+
27+
const DRAWN_AGAINST = /Drawn against a 5796×3870 image/;
28+
const MISMATCH =
29+
/drawn against a 5796×3870 image and the selected one is 5616×3744/;
30+
const ANY_ORIGIN = /drawn against/;
31+
32+
function Harness({
33+
maskPreviewImage,
34+
maskSourceSize,
35+
}: {
36+
maskPreviewImage?: string;
37+
maskSourceSize?: [number, number] | null;
38+
}) {
39+
const form = useForm<pipelineConfig>({
40+
defaultValues: { lensMask: PRESET_MASK },
41+
});
42+
43+
return (
44+
<TooltipProvider>
45+
<LensMaskInput
46+
centerX={motionValue(PRESET_MASK.x)}
47+
centerY={motionValue(PRESET_MASK.y)}
48+
maskPreviewImage={maskPreviewImage}
49+
maskSourceSize={maskSourceSize}
50+
radius={motionValue(PRESET_MASK.radius)}
51+
register={form.register}
52+
/>
53+
</TooltipProvider>
54+
);
55+
}
56+
57+
async function renderMask(props: Parameters<typeof Harness>[0] = {}) {
58+
await act(() => {
59+
render(<Harness {...props} />);
60+
return Promise.resolve();
61+
});
62+
}
63+
64+
describe("LensMaskInput with no preview image selected", () => {
65+
it("still shows the mask, so a preset's values do not read as absent", async () => {
66+
await renderMask();
67+
68+
expect(screen.getByText("No image selected")).toBeInTheDocument();
69+
70+
const radius = screen.getByPlaceholderText("Radius") as HTMLInputElement;
71+
const x = screen.getByPlaceholderText("X") as HTMLInputElement;
72+
const y = screen.getByPlaceholderText("Y") as HTMLInputElement;
73+
74+
expect(radius.value).toBe(String(PRESET_MASK.radius));
75+
expect(x.value).toBe(String(PRESET_MASK.x));
76+
expect(y.value).toBe(String(PRESET_MASK.y));
77+
});
78+
79+
it("names the image size a preset's mask was drawn against", async () => {
80+
await renderMask({ maskSourceSize: [5796, 3870] });
81+
82+
expect(screen.getByText(DRAWN_AGAINST)).toBeInTheDocument();
83+
});
84+
});
85+
86+
describe("LensMaskInput with a preview image selected", () => {
87+
it("warns for as long as the mask does not fit the image", async () => {
88+
await renderMask({
89+
maskPreviewImage: "/fake/image.jpg",
90+
maskSourceSize: [5796, 3870],
91+
});
92+
93+
// The toast raised when the preset was applied cannot cover this: the
94+
// image may well have been selected after the preset, and it is gone by
95+
// the time the mask is looked at either way.
96+
expect(screen.getByText(MISMATCH)).toBeInTheDocument();
97+
});
98+
99+
it("stays quiet when the mask was drawn against this same size", async () => {
100+
await renderMask({
101+
maskPreviewImage: "/fake/image.jpg",
102+
maskSourceSize: PREVIEW_SIZE,
103+
});
104+
105+
expect(screen.queryByText(ANY_ORIGIN)).not.toBeInTheDocument();
106+
});
107+
108+
it("stays quiet when the mask has no recorded origin", async () => {
109+
await renderMask({ maskPreviewImage: "/fake/image.jpg" });
110+
111+
expect(screen.queryByText(ANY_ORIGIN)).not.toBeInTheDocument();
112+
});
113+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it } from "@jest/globals";
2+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3+
import { useForm } from "react-hook-form";
4+
5+
// jest.mock must be hoisted above the imports below, which the SWC transform
6+
// only does for the global binding, not one imported from @jest/globals.
7+
declare const jest: typeof import("@jest/globals").jest;
8+
9+
const PRESET_MASK = { radius: 1800, x: 2808, y: 1935 };
10+
11+
jest.mock("@/lib/presets", () => ({
12+
changedSources: () => Promise.resolve([]),
13+
deletePreset: () => Promise.resolve(),
14+
presetFilePath: () => null,
15+
presetId: (name: string) => name,
16+
readPresets: () =>
17+
Promise.resolve([
18+
{
19+
files: {},
20+
fisheyeView: {
21+
horizontalViewDegrees: 180,
22+
projection: "vta",
23+
verticalViewDegrees: 180,
24+
},
25+
id: "sigma-8mm",
26+
lensMask: PRESET_MASK,
27+
lensMaskImageSize: [5616, 3744],
28+
name: "Sigma 8mm",
29+
outputSettings: { filterIrrelevantSrcImages: false, targetRes: 1000 },
30+
},
31+
]),
32+
renamePreset: () => Promise.resolve(),
33+
savePreset: () => Promise.resolve(),
34+
}));
35+
36+
jest.mock("@/lib/generic-image-metadata", () => ({
37+
useGenericImageMetadata: () => undefined,
38+
}));
39+
40+
import type { pipelineConfig } from "../src/app/pipeline/(pipeline-configuration)/config-provider";
41+
import { PresetBar } from "../src/app/pipeline/preset-bar";
42+
43+
// Radix Select drives its trigger with pointer capture and scrolls the active
44+
// item into view; jsdom implements neither.
45+
function stubPointerApis() {
46+
Element.prototype.hasPointerCapture ||= () => false;
47+
Element.prototype.setPointerCapture ||= () => undefined;
48+
Element.prototype.releasePointerCapture ||= () => undefined;
49+
Element.prototype.scrollIntoView ||= () => undefined;
50+
}
51+
52+
function Harness({
53+
onApplyLensMask,
54+
}: {
55+
onApplyLensMask: (
56+
mask: pipelineConfig["lensMask"],
57+
drawnAgainst: [number, number] | null
58+
) => void;
59+
}) {
60+
const form = useForm<pipelineConfig>({
61+
defaultValues: { lensMask: { radius: 0, x: 0, y: 0 } },
62+
});
63+
64+
return (
65+
<PresetBar
66+
form={form}
67+
maskImagePath={undefined}
68+
onApplyLensMask={onApplyLensMask}
69+
/>
70+
);
71+
}
72+
73+
describe("applying a preset", () => {
74+
it("hands the lens mask and its origin to the caller", async () => {
75+
stubPointerApis();
76+
const applied: {
77+
drawnAgainst: [number, number] | null;
78+
mask: pipelineConfig["lensMask"];
79+
}[] = [];
80+
81+
render(
82+
<Harness
83+
onApplyLensMask={(mask, drawnAgainst) => {
84+
applied.push({ drawnAgainst, mask });
85+
}}
86+
/>
87+
);
88+
89+
// The listbox opens on a key rather than a click: Radix opens it from
90+
// pointerdown, which jsdom has no pointer events to deliver.
91+
fireEvent.keyDown(screen.getByRole("combobox"), { key: " " });
92+
const item = await screen.findByText("Sigma 8mm");
93+
fireEvent.click(item);
94+
95+
await waitFor(() =>
96+
expect(applied).toEqual([
97+
{ drawnAgainst: [5616, 3744], mask: PRESET_MASK },
98+
])
99+
);
100+
});
101+
});

0 commit comments

Comments
 (0)