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
49 changes: 33 additions & 16 deletions e2e/drivers/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,24 @@ export class SvelteDriver implements UiDriver {
return this.page.getByTestId('slice-thumbnail');
}

/** The layer panel lists slices nearest first; rows carry their slice index. */
private sliceRow(index: number): Locator {
return this.page.locator(`[data-testid="slice-thumbnail-wrapper"][data-slice-index="${index}"]`);
}

sliceImage(index: number): Locator {
return this.sliceRow(index).getByTestId('slice-thumbnail');
}

/** Per-slice actions live in the Inspector's header for the selected slice. */
private async ensureSelected(index: number): Promise<void> {
const row = this.sliceRow(index);
if ((await row.getAttribute('aria-selected')) !== 'true') {
await row.getByTestId('slice-thumbnail').click();
await expect(row).toHaveAttribute('aria-selected', 'true');
}
}

candidateImages(): Locator {
return this.page.getByTestId('candidate-image');
}
Expand Down Expand Up @@ -241,15 +259,10 @@ export class SvelteDriver implements UiDriver {
}

async selectSlice(projectId: string, index: number): Promise<Locator> {
const image = this.sliceImages().nth(index);
const image = this.sliceImage(index);
await expect(image).toBeVisible();
const bounds = await image.boundingBox();
if (!bounds) throw new Error(`Slice ${index} has no clickable bounds`);
// The depth-number overlay covers the center and the label covers the bottom.
// Click the unobstructed upper-left area with normal browser hit-testing.
await image.click({ position: { x: bounds.width * 0.1, y: bounds.height * 0.15 } });
const wrapper = this.page.getByTestId('slice-thumbnail-wrapper').nth(index);
await expect(wrapper).toHaveAttribute('aria-selected', 'true');
await image.click();
await expect(this.sliceRow(index)).toHaveAttribute('aria-selected', 'true');
await expect.poll(async () => (await readE2EState(this.page, projectId)).selected_slice).toBe(index);
return image;
}
Expand Down Expand Up @@ -368,12 +381,14 @@ export class SvelteDriver implements UiDriver {
await this.page.getByTestId('apply-inpainting').click();
}

undoButton(index: number): Locator {
return this.page.getByTestId('slice-undo').nth(index);
// Undo and redo act on the selected slice (the header's buttons), so
// `index` must be the selected slice.
undoButton(_index: number): Locator {
return this.page.getByTestId('header-undo');
}

redoButton(index: number): Locator {
return this.page.getByTestId('slice-redo').nth(index);
redoButton(_index: number): Locator {
return this.page.getByTestId('header-redo');
}

// Slice editing / mask tools
Expand Down Expand Up @@ -421,7 +436,7 @@ export class SvelteDriver implements UiDriver {
}

async setSliceDepth(index: number, depth: number): Promise<void> {
const display = this.page.getByTestId('slice-depth-display').nth(index);
const display = this.sliceRow(index).getByTestId('slice-depth-display');
await expect(display).toBeVisible();
await display.click();
const input = this.page.getByTestId('slice-depth-input');
Expand All @@ -441,8 +456,9 @@ export class SvelteDriver implements UiDriver {
index: number,
file: { name: string; mimeType: string; buffer: Buffer },
): Promise<void> {
await this.ensureSelected(index);
const before = await this.log().innerText();
const input = this.page.getByTestId('slice-upload-input').nth(index);
const input = this.page.getByTestId('slice-upload-input');
await input.setInputFiles(file);
await expect.poll(() => this.log().innerText()).not.toBe(before);
}
Expand Down Expand Up @@ -513,7 +529,7 @@ export class SvelteDriver implements UiDriver {
}

async expectGroundSlice(index: number, isGround: boolean): Promise<void> {
const badge = this.page.getByTestId('slice-thumbnail-wrapper').nth(index).getByTestId('ground-badge');
const badge = this.sliceRow(index).getByTestId('ground-badge');
await expect(badge).toHaveCount(isGround ? 1 : 0);
}

Expand Down Expand Up @@ -657,8 +673,9 @@ export class SvelteDriver implements UiDriver {
}

async downloadSlice(index: number): Promise<Download> {
await this.ensureSelected(index);
const downloadPromise = this.page.waitForEvent('download');
await this.page.getByTestId('slice-download').nth(index).click();
await this.page.getByTestId('slice-download').click();
return downloadPromise;
}
}
6 changes: 4 additions & 2 deletions e2e/drivers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export interface UiDriver {
// Observable elements
mainImage(): Locator;
depthImage(): Locator;
/** Slice thumbnails as displayed (checkerboard-composited), in slice order. */
/** Every slice thumbnail as displayed (checkerboard-composited), in no particular order. */
sliceImages(): Locator;
/** The displayed thumbnail of the slice at `index` (slice order, farthest first). */
sliceImage(index: number): Locator;
/** Inpainting candidate images, in candidate order. */
candidateImages(): Locator;
/** Threshold handles; there are `num_slices - 1` of them. */
Expand Down Expand Up @@ -124,7 +126,7 @@ export interface UiDriver {
/** Selects a candidate and waits until it is visibly selected and Apply is enabled. */
selectCandidate(index: number): Promise<void>;
applyCandidate(): Promise<void>;
/** Undo/redo controls of the slice at `index` in the slice list. */
/** Undo/redo controls for the slice at `index`, which must be the selected slice. */
undoButton(index: number): Locator;
redoButton(index: number): Locator;

Expand Down
12 changes: 6 additions & 6 deletions e2e/parallax-maker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test('upload generates deterministic depth and three real slices', async ({ ui }
await ui.openTab('Segmentation');
await ui.generateSlices();
await expect(ui.sliceImages()).toHaveCount(3);
const slices = await ui.sliceImages().all();
const slices = [0, 1, 2].map((index) => ui.sliceImage(index));
for (const image of slices) {
expect(await imageDimensions(image)).toEqual({ width: 320, height: 240 });
}
Expand Down Expand Up @@ -275,9 +275,9 @@ test('painted mask drives three checkerboard candidates, apply, and undo', async
await ui.openTab('Segmentation');
const undo = ui.undoButton(1);
await expect(undo).toBeEnabled();
expect(await imageHash(ui.sliceImages().nth(1))).not.toBe(originalHash);
expect(await imageHash(ui.sliceImage(1))).not.toBe(originalHash);
await undo.click();
await expect.poll(() => imageHash(ui.sliceImages().nth(1))).toBe(originalHash);
await expect.poll(() => imageHash(ui.sliceImage(1))).toBe(originalHash);
// Undo restores the previous version together with its saved mask.
await ui.openTab('Inpainting');
await expect.poll(() => ui.maskCanvasPainted()).toBe(true);
Expand Down Expand Up @@ -394,20 +394,20 @@ test('erase removes painted alpha and supports undo and redo', async ({ page, ui
expect(originalInside[3]).toBeGreaterThan(0);

await ui.openTab('Segmentation');
const erasedHash = await imageHash(ui.sliceImages().nth(1));
const erasedHash = await imageHash(ui.sliceImage(1));
expect(erasedHash).not.toBe(originalHash);
const undo = ui.undoButton(1);
await expect(undo).toBeEnabled();
await undo.click();
await expect.poll(() => imageHash(ui.sliceImages().nth(1))).toBe(originalHash);
await expect.poll(() => imageHash(ui.sliceImage(1))).toBe(originalHash);
await expect
.poll(async () => (await readE2EState(page, projectId)).slice_filenames[1])
.toBe('image_slice_1.png');

const redo = ui.redoButton(1);
await expect(redo).toBeEnabled();
await redo.click();
await expect.poll(() => imageHash(ui.sliceImages().nth(1))).toBe(erasedHash);
await expect.poll(() => imageHash(ui.sliceImage(1))).toBe(erasedHash);
await expect
.poll(async () => (await readE2EState(page, projectId)).slice_filenames[1])
.toBe('image_slice_1_v2.png');
Expand Down
4 changes: 2 additions & 2 deletions e2e/slice-editing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ test('uploading a matching-aspect image replaces the slice content and bumps its
requireWorkflow(ui, 'slice-editing');
const projectId = await ui.restoreFixtureState();
await ui.openTab('Segmentation');
const thumbnailBefore = await imageHash(ui.sliceImages().nth(1));
const thumbnailBefore = await imageHash(ui.sliceImage(1));

// The 320x240 input fixture shares the slice canvas's exact aspect ratio
// (every slice image is full-canvas size, only alpha differs), so this
Expand All @@ -308,7 +308,7 @@ test('uploading a matching-aspect image replaces the slice content and bumps its
.poll(async () => (await readE2EState(page, projectId)).slice_filenames[1])
.toBe('image_slice_1_v2.png');
expect((await readE2EState(page, projectId)).slice_versions[1]).toBe(2);
await expect.poll(() => imageHash(ui.sliceImages().nth(1))).not.toBe(thumbnailBefore);
await expect.poll(() => imageHash(ui.sliceImage(1))).not.toBe(thumbnailBefore);

// The fixture draws a solid red rectangle at (24,24)-(104,96); (30, 30) is
// inside it. No resize occurred, so the uploaded pixel lands unchanged and
Expand Down
206 changes: 206 additions & 0 deletions frontend/src/lib/components/layers/DepthRuler.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<script lang="ts" module>
/** One slice on the ruler: its depth and the vertical center of its row. */
export type RulerEntry = {
index: number;
depth: number;
rowCenter: number;
selected: boolean;
ground: boolean;
};

/** Top of the axis and the ruler's width, in px (the list starts right of it). */
export const RULER_TOP = 24;
export const RULER_WIDTH = 52;
</script>

<script lang="ts">
/**
* Vertical depth axis beside the layer list (docs/redesign/HANDOFF.md §3):
* 255 (near) at the top, 0 (far) at the bottom, one draggable handle per
* slice at its depth, and a curve from each handle to its row. Dragging
* (or arrow keys on a focused handle) previews the new depth locally and
* commits it with `setSliceDepth` on release.
*/
import { isBusy } from '../../state/busy.svelte';
import * as workflow from '../../workflow';

let { entries, axisHeight }: { entries: RulerEntry[]; axisHeight: number } = $props();

// The handle being dragged or nudged, and its uncommitted depth.
let draft = $state<{ index: number; depth: number } | null>(null);
let axisEl: HTMLDivElement | undefined = $state();

const depthOf = (entry: RulerEntry) => (draft?.index === entry.index ? draft.depth : entry.depth);
const handleY = (depth: number) => RULER_TOP + ((255 - depth) / 255) * axisHeight;

function depthAt(clientY: number): number {
const rect = axisEl?.getBoundingClientRect();
if (!rect || rect.height <= 0) return 0;
const depth = Math.round(255 - ((clientY - rect.top) / rect.height) * 255);
return Math.max(0, Math.min(255, depth));
}

function commit(): void {
if (!draft) return;
const { index, depth } = draft;
const entry = entries.find((e) => e.index === index);
draft = null;
if (entry && entry.depth !== depth) void workflow.setSliceDepth(index, depth);
}

function onPointerDown(entry: RulerEntry, event: PointerEvent): void {
if (isBusy() || event.button !== 0) return;
event.preventDefault();
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
draft = { index: entry.index, depth: entry.depth };
}

function onPointerMove(entry: RulerEntry, event: PointerEvent): void {
if (draft?.index !== entry.index) return;
draft = { index: entry.index, depth: depthAt(event.clientY) };
}

function onKeydown(entry: RulerEntry, event: KeyboardEvent): void {
const step = event.shiftKey ? 10 : 1;
const delta = event.key === 'ArrowUp' ? step : event.key === 'ArrowDown' ? -step : 0;
if (delta === 0 || isBusy()) return;
event.preventDefault();
const current = depthOf(entry);
draft = { index: entry.index, depth: Math.max(0, Math.min(255, current + delta)) };
}

function onKeyup(event: KeyboardEvent): void {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') commit();
}
</script>

<div class="ruler" style={`height: ${RULER_TOP + axisHeight + 28}px`}>
<span class="end mono" style="top: 4px">255 near</span>
<div class="axis" bind:this={axisEl} style={`top: ${RULER_TOP}px; height: ${axisHeight}px`}></div>
<span class="end mono" style={`top: ${RULER_TOP + axisHeight + 8}px`}>0 far</span>

<svg class="links" width="60" height={RULER_TOP + axisHeight + 28} aria-hidden="true">
{#each entries as entry (entry.index)}
{@const y = handleY(depthOf(entry))}
<path
d={`M40 ${y.toFixed(1)} C 50 ${y.toFixed(1)}, 50 ${entry.rowCenter}, 60 ${entry.rowCenter}`}
class:selected={entry.selected}
class:ground={entry.ground && !entry.selected}
/>
{/each}
</svg>

{#each entries as entry (entry.index)}
{@const depth = depthOf(entry)}
<button
type="button"
class="handle"
class:selected={entry.selected}
class:ground={entry.ground}
class:dragging={draft?.index === entry.index}
style={`top: ${handleY(depth) - (entry.selected ? 6 : 5)}px`}
role="slider"
aria-orientation="vertical"
aria-valuemin="0"
aria-valuemax="255"
aria-valuenow={depth}
aria-label={`Depth of image_slice_${entry.index}${entry.ground ? ', ground' : ''}`}
title={`Depth ${depth}. Drag to change.`}
data-testid="depth-handle"
data-slice-index={entry.index}
disabled={isBusy()}
onpointerdown={(event) => onPointerDown(entry, event)}
onpointermove={(event) => onPointerMove(entry, event)}
onpointerup={commit}
onpointercancel={() => (draft = null)}
onkeydown={(event) => onKeydown(entry, event)}
onkeyup={onKeyup}
onblur={commit}
></button>
{/each}
</div>

<style>
.ruler {
position: absolute;
left: 0;
top: 0;
width: 52px;
background: var(--color-bg);
border-right: 1px solid var(--color-border);
}

.end {
position: absolute;
left: 0;
width: 52px;
text-align: center;
font-size: 10px;
color: var(--color-text-muted);
white-space: nowrap;
}

.axis {
position: absolute;
left: 24px;
width: 4px;
border-radius: 2px;
background: linear-gradient(180deg, var(--color-text), var(--color-surface-hover));
}

.links {
position: absolute;
left: 0;
top: 0;
overflow: visible;
pointer-events: none;
}

.links path {
fill: none;
stroke: var(--color-border-strong);
stroke-width: 1.3;
}

.links path.ground {
stroke: var(--color-success);
}

.links path.selected {
stroke: var(--color-selection);
}

.handle {
position: absolute;
left: 12px;
width: 28px;
height: 10px;
padding: 0;
border: none;
border-radius: 3px;
background: var(--color-text-secondary);
cursor: ns-resize;
touch-action: none;
}

.handle.ground {
background: var(--color-success-soft);
box-shadow: inset 0 0 0 2px var(--color-success);
}

.handle.selected {
background: var(--color-selection);
height: 12px;
left: 10px;
width: 32px;
}

.handle:focus-visible {
outline: 2px solid var(--color-selection);
outline-offset: 2px;
}

.handle:disabled {
cursor: default;
}
</style>
Loading
Loading