Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
26 changes: 26 additions & 0 deletions e2e/drivers/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,32 @@ export class SvelteDriver implements UiDriver {
return this.page.getByTestId('canvas-image');
}

async visibleCanvasPixel(x: number, y: number): Promise<number[]> {
const [naturalWidth, naturalHeight] = await this.mainImage().evaluate((img: HTMLImageElement) => [
img.naturalWidth,
img.naturalHeight,
]);
const png = await this.canvasImage().screenshot();
Comment on lines +116 to +123
return this.page.evaluate(
async ({ data, x, y, naturalWidth, naturalHeight }) => {
const shot = new Image();
shot.src = `data:image/png;base64,${data}`;
await shot.decode();
const canvas = document.createElement('canvas');
canvas.width = shot.width;
canvas.height = shot.height;
const context = canvas.getContext('2d');
if (!context) throw new Error('No 2D context');
context.drawImage(shot, 0, 0);
// Sample the center of the source pixel in screenshot pixels.
const px = Math.floor(((x + 0.5) * shot.width) / naturalWidth);
const py = Math.floor(((y + 0.5) * shot.height) / naturalHeight);
return Array.from(context.getImageData(px, py, 1, 1).data);
},
{ data: png.toString('base64'), x, y, naturalWidth, naturalHeight },
);
}

depthImage(): Locator {
return this.page.getByTestId('depth-image');
}
Expand Down
5 changes: 5 additions & 0 deletions e2e/drivers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export interface UiDriver {
* (its box follows zoom/pan); use it for geometry, `mainImage()` for pixels.
*/
canvasImage(): Locator;
/**
* The RGBA the user sees on the canvas at source pixel (x, y), whatever
* the current view draws there (a screenshot, so overlays count too).
*/
visibleCanvasPixel(x: number, y: number): Promise<number[]>;
depthImage(): Locator;
/** Every slice thumbnail as displayed (checkerboard-composited), in no particular order. */
sliceImages(): Locator;
Expand Down
11 changes: 11 additions & 0 deletions e2e/parallax-maker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,18 @@ test('painted mask drives three checkerboard candidates, apply, and undo', async
expect(candidateOutside).toEqual(originalOutside);

expect(await ui.maskCanvasPainted()).toBe(true);
// Picking a candidate previews it on the canvas, in place of the slice and
// its painted mask, so each one can be inspected before applying it.
const nearest = (rgba: number[], palette: number[][]) =>
Math.min(...palette.map((color) => Math.max(...color.map((value, i) => Math.abs(rgba[i] - value)))));
await ui.selectCandidate(0);
await expect
.poll(async () => nearest(await ui.visibleCanvasPixel(...inside), [[0, 255, 255], [255, 0, 255]]))
.toBeLessThanOrEqual(24);
await ui.selectCandidate(1);
await expect
.poll(async () => nearest(await ui.visibleCanvasPixel(...inside), [[255, 128, 0], [0, 64, 255]]))
.toBeLessThanOrEqual(24);
await ui.applyCandidate();
await expect(ui.log()).toContainText(/Inpainting applied to slice 1/);
// The applied version has no mask; the canvas must not keep showing the old stroke.
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/candidatePreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* The inpainting candidate being previewed: the one picked in the Inpaint
* panel, while it belongs to the selected slice. The canvas shows it in
* place of that slice (Slice and Composite views; the Input view's server
* display image already is the candidate) and hides the painted mask, so
* the filled-in area can be inspected.
*/
import type { ProjectView } from './api/types';

export type CandidatePreview = { sliceIndex: number; candidate: number; url: string };

export function candidatePreview(view: ProjectView | null | undefined): CandidatePreview | null {
const candidates = view?.inpainting.candidates;
const picked = view?.inpainting.selectedCandidate;
if (!view || !candidates || picked == null) return null;
if (candidates.sliceIndex !== view.selectedSlice) return null;
const image = candidates.images[picked];
return image ? { sliceIndex: candidates.sliceIndex, candidate: picked, url: image.url } : null;
}
8 changes: 6 additions & 2 deletions frontend/src/lib/components/canvas/MaskCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*/
import { projectStore } from '../../state/project.svelte';
import { uiStore } from '../../state/ui.svelte';
import { candidatePreview } from '../../candidatePreview';
import { isBusy } from '../../state/busy.svelte';
import { logStore } from '../../state/logs.svelte';
import { canvasSaveStore } from '../../state/canvas.svelte';
Expand Down Expand Up @@ -59,8 +60,11 @@

// Painting is the Brush tool's job; the painted mask shows only in the
// views it is drawn over (the selected slice, or the working image).
const interactiveNow = $derived(uiStore.tool === 'brush');
const visibleNow = $derived(uiStore.view === 'slice' || uiStore.view === 'input');
// While a picked candidate is previewed, the mask steps aside so the
// filled-in area can be inspected (and can't be painted over unseen).
const previewing = $derived(candidatePreview(projectStore.view) !== null);
const interactiveNow = $derived(uiStore.tool === 'brush' && !previewing);
const visibleNow = $derived((uiStore.view === 'slice' || uiStore.view === 'input') && !previewing);

// The brush preview is only meaningful while painting is possible; drop
// it as soon as another tool is picked rather than leaving a stale circle
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/lib/components/inpainting/InpaintPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@
<p class="faint">Candidates appear here after you generate.</p>
{/each}
</div>
{#if selectedCandidate !== null && hasCandidates}
<p class="faint" data-testid="candidate-preview-hint">
Candidate {selectedCandidate + 1} is shown on the canvas. Click it again to go back to the mask.
</p>
{/if}
<button
type="button"
class="btn"
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/lib/components/upload/InputImagePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* selected slice's highlight, or the latest camera render). Depth, Slice
* and Composite are drawn here from the project's assets.
*/
import { candidatePreview } from '../../candidatePreview';
import ImageUp from '@lucide/svelte/icons/image-up';
import { projectStore } from '../../state/project.svelte';
import { uiStore } from '../../state/ui.svelte';
Expand Down Expand Up @@ -82,6 +83,10 @@
),
);
const showsMain = $derived(uiStore.view === 'input' || uiStore.view === 'parallax');
// A picked inpainting candidate stands in for its slice on the canvas.
const preview = $derived(candidatePreview(projectStore.view));
const sliceUrl = (slice: { index: number; image: { url: string } }): string =>
preview && preview.sliceIndex === slice.index ? preview.url : slice.image.url;

/**
* A click on the display image with the Segment tool selects by object
Expand Down Expand Up @@ -299,14 +304,21 @@
<img class="layer" data-testid="view-depth-image" alt="Depth map" src={projectStore.view.assets.depth.url} draggable="false" />
{:else if uiStore.view === 'slice'}
{#if selectedSlice}
<img class="layer" data-testid="view-slice-image" alt={`image_slice_${selectedSlice.index}`} src={selectedSlice.image.url} draggable="false" />
<img
class="layer"
data-testid="view-slice-image"
data-candidate={preview?.sliceIndex === selectedSlice.index ? preview.candidate : undefined}
alt={`image_slice_${selectedSlice.index}`}
src={sliceUrl(selectedSlice)}
draggable="false"
/>
{:else}
<div class="layer placeholder" data-testid="view-slice-empty">Select a layer to see it on its own.</div>
{/if}
{:else if uiStore.view === 'composite'}
<div class="layer" data-testid="view-composite-layers">
{#each compositeLayers as slice (slice.index)}
<img class="layer" alt="" src={slice.image.url} draggable="false" />
<img class="layer" alt="" src={sliceUrl(slice)} draggable="false" />
{/each}
</div>
{/if}
Expand Down
57 changes: 56 additions & 1 deletion frontend/src/lib/components/upload/InputImagePanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { jobStore } from '../../state/jobs.svelte';
import { logStore } from '../../state/logs.svelte';
import { uiStore } from '../../state/ui.svelte';
import { viewportStore } from '../../state/viewport.svelte';
import type { ProjectView } from '../../api/types';
import type { ProjectView, SliceView } from '../../api/types';

function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
Expand Down Expand Up @@ -400,4 +400,59 @@ describe('InputImagePanel', () => {
expect(JSON.parse(fetchMock.mock.calls[0][1]!.body as string).camera.horizonRow).toBe(110);
});
});

describe('inpainting candidate preview', () => {
const slice = (index: number) =>
({ index, depth: 10 * index, image: { url: `/slice-${index}` }, thumbnail: { url: `/t-${index}` } }) as unknown as SliceView;
const withCandidates = (selectedCandidate: number | null, selectedSlice = 1): ProjectView =>
makeView({
slices: [slice(0), slice(1)],
selectedSlice,
inpainting: {
...makeView().inpainting,
candidates: {
generationId: 'gen',
sliceIndex: 1,
images: [{ url: '/cand-0' }, { url: '/cand-1' }, { url: '/cand-2' }],
},
selectedCandidate,
},
});

beforeEach(() => uiStore.setView('slice'));

it('shows the picked candidate in place of the slice and hides the mask', () => {
projectStore.applyView(withCandidates(2));
render(InputImagePanel);
expect(screen.getByTestId('view-slice-image')).toHaveAttribute('src', '/cand-2');
expect(screen.getByTestId('view-slice-image')).toHaveAttribute('data-candidate', '2');
expect(screen.getByTestId('mask-canvas')).toHaveClass('hidden');
});

it('shows the slice and its mask again when no candidate is picked', () => {
projectStore.applyView(withCandidates(2));
render(InputImagePanel);
projectStore.applyView(withCandidates(null));
flushSync();
expect(screen.getByTestId('view-slice-image')).toHaveAttribute('src', '/slice-1');
expect(screen.getByTestId('mask-canvas')).not.toHaveClass('hidden');
});

it("ignores candidates of a slice that isn't selected", () => {
projectStore.applyView(withCandidates(0, 0));
render(InputImagePanel);
expect(screen.getByTestId('view-slice-image')).toHaveAttribute('src', '/slice-0');
expect(screen.getByTestId('mask-canvas')).not.toHaveClass('hidden');
});

it('swaps the candidate into the composite view', () => {
uiStore.setView('composite');
projectStore.applyView(withCandidates(1));
render(InputImagePanel);
const sources = [...screen.getByTestId('view-composite-layers').querySelectorAll('img')].map((img) =>
img.getAttribute('src'),
);
expect(sources).toEqual(['/slice-0', '/cand-1']);
});
});
});
Loading