diff --git a/docs/svelte-migration/reference/dash-1024-mode.png b/docs/svelte-migration/reference/dash-1024-mode.png new file mode 100644 index 0000000..4427922 Binary files /dev/null and b/docs/svelte-migration/reference/dash-1024-mode.png differ diff --git a/docs/svelte-migration/reference/dash-1024-segmentation.png b/docs/svelte-migration/reference/dash-1024-segmentation.png new file mode 100644 index 0000000..5847637 Binary files /dev/null and b/docs/svelte-migration/reference/dash-1024-segmentation.png differ diff --git a/docs/svelte-migration/reference/dash-1440-configuration.png b/docs/svelte-migration/reference/dash-1440-configuration.png new file mode 100644 index 0000000..6ae43d6 Binary files /dev/null and b/docs/svelte-migration/reference/dash-1440-configuration.png differ diff --git a/docs/svelte-migration/reference/dash-1440-empty.png b/docs/svelte-migration/reference/dash-1440-empty.png new file mode 100644 index 0000000..2620fc8 Binary files /dev/null and b/docs/svelte-migration/reference/dash-1440-empty.png differ diff --git a/docs/svelte-migration/reference/dash-1440-export.png b/docs/svelte-migration/reference/dash-1440-export.png new file mode 100644 index 0000000..27a2d9a Binary files /dev/null and b/docs/svelte-migration/reference/dash-1440-export.png differ diff --git a/docs/svelte-migration/reference/dash-1440-inpainting.png b/docs/svelte-migration/reference/dash-1440-inpainting.png new file mode 100644 index 0000000..1baae61 Binary files /dev/null and b/docs/svelte-migration/reference/dash-1440-inpainting.png differ diff --git a/docs/svelte-migration/reference/dash-1440-mode.png b/docs/svelte-migration/reference/dash-1440-mode.png new file mode 100644 index 0000000..20f9b57 Binary files /dev/null and b/docs/svelte-migration/reference/dash-1440-mode.png differ diff --git a/docs/svelte-migration/reference/dash-1440-segmentation.png b/docs/svelte-migration/reference/dash-1440-segmentation.png new file mode 100644 index 0000000..4e14505 Binary files /dev/null and b/docs/svelte-migration/reference/dash-1440-segmentation.png differ diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts new file mode 100644 index 0000000..4c5c75d --- /dev/null +++ b/e2e/drivers/svelte.ts @@ -0,0 +1,255 @@ +import { expect, type Download, type Locator, type Page } from '@playwright/test'; +import { waitForImage } from '../helpers/image'; +import { fetchFixture } from '../helpers/oracle'; +import type { + MainTab, + Modifier, + SegmentationMode, + SliderName, + UiDriver, + UiTarget, + Workflow, +} from './types'; + +/** + * Svelte-specific implementation of `UiDriver`, built during the migration's + * first vertical slice (upload -> depth -> thresholds -> slices, plus legacy + * state restore and the log pane). Every Svelte selector and gesture used by + * the shared behavioral scenarios lives here; the scenario file itself never + * mentions a Svelte `data-testid`. + * + * Only `upload-depth-slices` is supported so far: every other workflow + * (segmentation, inpainting, export, ...) is not yet implemented in the new + * UI (see docs/svelte-migration/PARITY.md), so those methods throw. + */ +export class SvelteDriver implements UiDriver { + readonly target: UiTarget = 'svelte'; + + constructor(private readonly page: Page) {} + + supports(workflow: Workflow): boolean { + return workflow === 'upload-depth-slices'; + } + + // Navigation + + async goto(): Promise { + await this.page.goto('/next/', { waitUntil: 'domcontentloaded' }); + await expect(this.page.getByRole('heading', { name: 'Parallax Maker' })).toBeVisible(); + } + + async openTab(tab: MainTab): Promise { + const button = this.page.getByRole('tab', { name: tab, exact: true }); + await expect(button).toHaveCount(1); + await button.click(); + await expect(button).toHaveAttribute('aria-selected', 'true'); + } + + // Observable elements + + mainImage(): Locator { + return this.page.getByTestId('main-image'); + } + + depthImage(): Locator { + return this.page.getByTestId('depth-image'); + } + + sliceImages(): Locator { + return this.page.getByTestId('slice-thumbnail'); + } + + candidateImages(): Locator { + return this.page.getByTestId('candidate-image'); + } + + thresholdHandles(): Locator { + return this.page.getByTestId('threshold-handle'); + } + + log(): Locator { + return this.page.getByTestId('log'); + } + + // Upload / depth / slices + + async uploadInputImage(): Promise { + const response = await fetchFixture(this.page, 'input.png'); + expect(response.ok(), 'GET /__e2e__/fixture/input.png').toBeTruthy(); + await this.page.getByTestId('upload-image-input').setInputFiles({ + name: 'e2e-input.png', + mimeType: 'image/png', + buffer: await response.body(), + }); + await waitForImage(this.mainImage()); + await waitForImage(this.depthImage()); + } + + async restoreFixtureState(): Promise { + const response = await fetchFixture(this.page, 'state.json'); + expect(response.ok(), 'GET /__e2e__/fixture/state.json').toBeTruthy(); + const buffer = await response.body(); + const fixture = JSON.parse(buffer.toString('utf8')) as { filename?: unknown }; + expect(typeof fixture.filename).toBe('string'); + + let input = this.page.getByTestId('restore-state-input'); + if ((await input.count()) === 0) { + await this.openTab('Configuration'); + input = this.page.getByTestId('restore-state-input'); + } + await input.setInputFiles({ + name: 'appstate.json', + mimeType: 'application/json', + buffer, + }); + + await waitForImage(this.mainImage()); + await waitForImage(this.depthImage()); + + // Slice thumbnails are only rendered in the Segmentation tab; check the + // same way DashDriver does (no tab switch needed there), falling back to + // opening it here and returning to whichever tab was active before. + const slices = this.sliceImages(); + if ((await slices.count()) < 3) { + const previousTab = await this.activeMainTab(); + await this.openTab('Segmentation'); + await expect(slices).toHaveCount(3); + if (previousTab && previousTab !== 'Segmentation') { + await this.openTab(previousTab); + } + } else { + await expect(slices).toHaveCount(3); + } + + return fixture.filename as string; + } + + async generateSlices(): Promise { + await this.page.getByTestId('generate-slices').click(); + } + + private async activeMainTab(): Promise { + // Scoped to the workflow tablist specifically: the viewer (2D/3D) tabs + // also use role="tab" and are also selected by default, so a + // page-wide `getByRole('tab', { selected: true })` would be ambiguous. + const active = this.page.locator('[role="tablist"][aria-label="Workflow"] [role="tab"][aria-selected="true"]'); + const count = await active.count(); + if (count === 0) return null; + const name = await active.first().textContent(); + const trimmed = name?.trim(); + const known: MainTab[] = ['Mode', 'Segmentation', 'Inpainting', 'Export', 'Configuration']; + return (known.find((tab) => tab === trimmed) as MainTab | undefined) ?? null; + } + + // Segmentation + + async setSegmentationMode(_mode: SegmentationMode): Promise { + throw new Error('SvelteDriver: setSegmentationMode not implemented yet'); + } + + async expectSegmentationMode(_mode: SegmentationMode): Promise { + throw new Error('SvelteDriver: expectSegmentationMode not implemented yet'); + } + + async clickImagePixel(_x: number, _y: number, _modifiers?: Modifier[]): Promise { + throw new Error('SvelteDriver: clickImagePixel not implemented yet'); + } + + async selectSlice(_projectId: string, _index: number): Promise { + throw new Error('SvelteDriver: selectSlice not implemented yet'); + } + + async toggleMultiPoint(): Promise { + throw new Error('SvelteDriver: toggleMultiPoint not implemented yet'); + } + + async expectMultiPointEnabled(_enabled: boolean): Promise { + throw new Error('SvelteDriver: expectMultiPointEnabled not implemented yet'); + } + + async commitMultiPoint(): Promise { + throw new Error('SvelteDriver: commitMultiPoint not implemented yet'); + } + + // Canvas / inpainting + + async drawMaskStroke(): Promise { + throw new Error('SvelteDriver: drawMaskStroke not implemented yet'); + } + + async expectGenerateEnabled(): Promise { + throw new Error('SvelteDriver: expectGenerateEnabled not implemented yet'); + } + + async fillPrompts(_positive: string, _negative: string): Promise { + throw new Error('SvelteDriver: fillPrompts not implemented yet'); + } + + async expectPrompts(_positive: string, _negative: string): Promise { + throw new Error('SvelteDriver: expectPrompts not implemented yet'); + } + + async generateInpainting(): Promise { + throw new Error('SvelteDriver: generateInpainting not implemented yet'); + } + + async fillInpainting(): Promise { + throw new Error('SvelteDriver: fillInpainting not implemented yet'); + } + + async enhance(): Promise { + throw new Error('SvelteDriver: enhance not implemented yet'); + } + + async erase(): Promise { + throw new Error('SvelteDriver: erase not implemented yet'); + } + + async selectCandidate(_index: number): Promise { + throw new Error('SvelteDriver: selectCandidate not implemented yet'); + } + + async applyCandidate(): Promise { + throw new Error('SvelteDriver: applyCandidate not implemented yet'); + } + + undoButton(_index: number): Locator { + throw new Error('SvelteDriver: undoButton not implemented yet'); + } + + redoButton(_index: number): Locator { + throw new Error('SvelteDriver: redoButton not implemented yet'); + } + + // Project / configuration + + async expectDarkTheme(): Promise { + throw new Error('SvelteDriver: expectDarkTheme not implemented yet'); + } + + async expectSliderValue(_name: SliderName, _value: number): Promise { + throw new Error('SvelteDriver: expectSliderValue not implemented yet'); + } + + async setSlider(_name: SliderName, _value: number): Promise { + throw new Error('SvelteDriver: setSlider not implemented yet'); + } + + async expectDepthModel(_label: string): Promise { + throw new Error('SvelteDriver: expectDepthModel not implemented yet'); + } + + async expectInpaintingModel(_label: string): Promise { + throw new Error('SvelteDriver: expectInpaintingModel not implemented yet'); + } + + // Export + + async exportGltf(): Promise { + throw new Error('SvelteDriver: exportGltf not implemented yet'); + } + + async exportAnimation(): Promise { + throw new Error('SvelteDriver: exportAnimation not implemented yet'); + } +} diff --git a/e2e/drivers/types.ts b/e2e/drivers/types.ts index 68a5cef..954fc21 100644 --- a/e2e/drivers/types.ts +++ b/e2e/drivers/types.ts @@ -27,7 +27,7 @@ export type Workflow = | 'configuration' | 'export'; -export type MainTab = 'Segmentation' | 'Inpainting' | 'Export' | 'Configuration'; +export type MainTab = 'Mode' | 'Segmentation' | 'Inpainting' | 'Export' | 'Configuration'; export type SegmentationMode = 'Depth Map' | 'Instance Segmentation'; export type Modifier = 'Alt' | 'Control' | 'Meta' | 'Shift'; export type SliderName = diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index e16839a..32d918b 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,5 +1,6 @@ import { test as base, expect, type ConsoleMessage, type Page } from '@playwright/test'; import { DashDriver } from './drivers/dash'; +import { SvelteDriver } from './drivers/svelte'; import type { UiDriver, UiTarget, Workflow } from './drivers/types'; type BrowserDiagnostics = { @@ -45,7 +46,8 @@ export const test = base.extend({ return; } if (uiTarget === 'svelte') { - throw new Error('Svelte driver not implemented yet'); + await use(new SvelteDriver(page)); + return; } throw new Error(`Unknown uiTarget: ${String(uiTarget)}`); }, diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 3dce728..364bf36 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -47,6 +47,10 @@ export default defineConfig<{ uiTarget: UiTarget }>({ name: 'dash', use: { uiTarget: 'dash' }, }, + { + name: 'svelte', + use: { uiTarget: 'svelte' }, + }, ], webServer: process.env.E2E_SKIP_SERVER === '1' diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index c4f9cfc..eb97cef 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,80 +1,42 @@ -
-
-

Parallax Maker

-

- {#if status === 'checking'} - Checking server… - {:else if status === 'ok'} - Server ok{statusDetail ? ` (${statusDetail})` : ''} - {:else} - Server error{statusDetail ? `: ${statusDetail}` : ''} - {/if} -

-
+
+
-
-
-

Image workspace placeholder.

-
- - +
+ +
-
- © 2024 Niels Provos -
+ +
diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index 5a3c6f4..1b13e6a 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -1,41 +1,129 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import App from './App.svelte'; +import { projectStore } from './lib/state/project.svelte'; +import { jobStore } from './lib/state/jobs.svelte'; +import { logStore } from './lib/state/logs.svelte'; +import { uiStore } from './lib/state/ui.svelte'; +import type { ProjectView } from './lib/api/types'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeView(overrides: Partial = {}): ProjectView { + return { + id: 'appstate-e2e-test', + revision: 1, + image: { width: 320, height: 240 }, + assets: { input: null, depth: null }, + depthModel: 'dinov2', + numSlices: 3, + thresholds: [], + slices: [], + selectedSlice: null, + busy: null, + ...overrides, + }; +} describe('App', () => { afterEach(() => { vi.unstubAllGlobals(); + projectStore.reset(); + jobStore.end(); + logStore.reset(); + uiStore.reset(); }); - it('renders the "Parallax Maker" heading and workspace placeholders', () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true, version: 'test' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - vi.stubGlobal('fetch', fetchMock); - + it('renders the header, viewer and workflow tabs, and footer', () => { render(App); expect(screen.getByTestId('app-title')).toHaveTextContent('Parallax Maker'); - expect(screen.getByTestId('main-image-area')).toBeInTheDocument(); - expect(screen.getByTestId('tabs-panel')).toBeInTheDocument(); - expect(screen.getByTestId('health-status')).toBeInTheDocument(); + expect(screen.getByTestId('theme-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('input-image-panel')).toBeInTheDocument(); + expect(screen.getByTestId('main-image')).toBeInTheDocument(); + expect(screen.getByTestId('depth-image')).toBeInTheDocument(); + expect(screen.getByTestId('log')).toBeInTheDocument(); + expect(screen.getByTestId('app-footer')).toHaveTextContent('2024 Niels Provos'); }); - it('shows an error status when the health check fails', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ error: { code: 'internal', message: 'down' } }), { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }), - ); + it('the theme toggle switches the root dark class used by the theme tokens', async () => { + render(App); + const root = document.documentElement; + const initiallyDark = root.classList.contains('dark'); + + await fireEvent.click(screen.getByTestId('theme-toggle')); + await waitFor(() => expect(root.classList.contains('dark')).toBe(!initiallyDark)); + + await fireEvent.click(screen.getByTestId('theme-toggle')); + await waitFor(() => expect(root.classList.contains('dark')).toBe(initiallyDark)); + }); + + it('uploading an image creates a project, starts a depth job, and renders both images', async () => { + const projectId = 'appstate-e2e-test'; + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (url === '/api/v1/projects' && method === 'POST') { + return jsonResponse( + 201, + makeView({ assets: { input: { url: '/api/v1/projects/appstate-e2e-test/assets/input' }, depth: null } }), + ); + } + if (url.startsWith(`/api/v1/projects/${projectId}/logs`)) { + return jsonResponse(200, { entries: [], next: 0 }); + } + if (url === `/api/v1/projects/${projectId}/depth` && method === 'POST') { + return jsonResponse(202, { job: { id: 'job-1', kind: 'depth', status: 'queued', progress: 0 } }); + } + if (url === '/api/v1/jobs/job-1') { + return jsonResponse(200, { + id: 'job-1', + kind: 'depth', + status: 'succeeded', + progress: 1, + project: makeView({ + assets: { + input: { url: '/api/v1/projects/appstate-e2e-test/assets/input' }, + depth: { url: '/api/v1/projects/appstate-e2e-test/assets/depth' }, + }, + thresholds: [0, 85, 170, 255], + }), + }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); vi.stubGlobal('fetch', fetchMock); render(App); - const status = await screen.findByText(/Server error/); - expect(status).toBeInTheDocument(); + const file = new File(['fake-png-bytes'], 'input.png', { type: 'image/png' }); + const input = screen.getByTestId('upload-image-input') as HTMLInputElement; + await fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => + expect(screen.getByTestId('main-image')).toHaveAttribute( + 'src', + '/api/v1/projects/appstate-e2e-test/assets/input', + ), + ); + await waitFor(() => + expect(screen.getByTestId('depth-image')).toHaveAttribute( + 'src', + '/api/v1/projects/appstate-e2e-test/assets/depth', + ), + ); + + expect(fetchMock).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ method: 'POST' })); + expect(fetchMock).toHaveBeenCalledWith( + `/api/v1/projects/${projectId}/depth`, + expect.objectContaining({ method: 'POST' }), + ); }); }); diff --git a/frontend/src/app.css b/frontend/src/app.css index f502ed1..5c54921 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -58,6 +58,8 @@ --radius-md: 0.375rem; --radius-lg: 0.5rem; + + --color-depth-number: rgb(146 64 14 / 50%); /* amber-800 @ 50% */ } :root.dark { @@ -93,6 +95,8 @@ --color-help-bg: #fef08a; /* yellow-200 */ --color-help-border: #374151; /* gray-700 */ --color-help-text: #334155; /* slate-700 */ + + --color-depth-number: rgb(252 211 77 / 50%); /* amber-300 @ 50% */ } html, @@ -106,3 +110,120 @@ body { color: var(--color-text); font-family: var(--font-sans); } + +/* + * Shared, framework-agnostic look-alike classes for the Dash "general-*" + * utilities (see ../../tailwind.css). Kept here (rather than duplicated in + * every component's diff --git a/frontend/src/lib/components/depth/ModeTab.svelte b/frontend/src/lib/components/depth/ModeTab.svelte new file mode 100644 index 0000000..49b5694 --- /dev/null +++ b/frontend/src/lib/components/depth/ModeTab.svelte @@ -0,0 +1,144 @@ + + +
+ Depth Map +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+ +
+ Mode Selector +
+ +

+ Switch between depth map and instance segmentation. Depth map allows the creation of + slices from bands of depth based on the depth map. Instance segmentation allows the + creation of slices from selected objects on the image. +

+
+
+
+ + diff --git a/frontend/src/lib/components/segmentation/SegmentationTab.svelte b/frontend/src/lib/components/segmentation/SegmentationTab.svelte new file mode 100644 index 0000000..903695f --- /dev/null +++ b/frontend/src/lib/components/segmentation/SegmentationTab.svelte @@ -0,0 +1,161 @@ + + +
+
+
+ Thresholds +
+ {#each localValues as value, index (index)} + onSliderInput(index, event)} + onchange={onSliderChange} + /> + {/each} +
+
+ +
+ Actions +
+ {#each actions as action (action.label)} + + {/each} +
+
+
+ +
+ {#each projectStore.view?.slices ?? [] as slice (slice.index)} +
+ {slice.depth} + {`image_slice_${slice.index}`} + {`image_slice_${slice.index}`} +
+ {/each} +
+
+ + diff --git a/frontend/src/lib/components/segmentation/SegmentationTab.test.ts b/frontend/src/lib/components/segmentation/SegmentationTab.test.ts new file mode 100644 index 0000000..a799b5d --- /dev/null +++ b/frontend/src/lib/components/segmentation/SegmentationTab.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import SegmentationTab from './SegmentationTab.svelte'; +import { projectStore } from '../../state/project.svelte'; +import { jobStore } from '../../state/jobs.svelte'; +import { logStore } from '../../state/logs.svelte'; +import type { ProjectView, SliceView } from '../../api/types'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeSlice(index: number, depth: number): SliceView { + return { + index, + depth, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, + thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, + }; +} + +function makeView(overrides: Partial = {}): ProjectView { + return { + id: 'appstate-test', + revision: 1, + image: { width: 320, height: 240 }, + assets: { input: { url: '/input' }, depth: { url: '/depth' } }, + depthModel: 'dinov2', + numSlices: 3, + thresholds: [0, 85, 170, 255], + slices: [], + selectedSlice: null, + busy: null, + ...overrides, + }; +} + +describe('SegmentationTab', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders one thumbnail per slice', () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170), makeSlice(2, 255)] }), + ); + render(SegmentationTab); + + const thumbnails = screen.getAllByTestId('slice-thumbnail'); + expect(thumbnails).toHaveLength(3); + expect(thumbnails.map((img) => img.getAttribute('alt'))).toEqual([ + 'image_slice_0', + 'image_slice_1', + 'image_slice_2', + ]); + }); + + it('disables the Generate button while a job is in flight, and re-enables it after', async () => { + projectStore.applyView(makeView()); + render(SegmentationTab); + + expect(screen.getByTestId('generate-slices')).toBeEnabled(); + + jobStore.begin('slices'); + await waitFor(() => expect(screen.getByTestId('generate-slices')).toBeDisabled()); + + jobStore.end(); + await waitFor(() => expect(screen.getByTestId('generate-slices')).toBeEnabled()); + }); + + it('sends baseRevision on threshold change, and retries once on stale_revision', async () => { + projectStore.applyView(makeView()); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(409, { error: { code: 'stale_revision', message: 'stale' } }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, makeView({ revision: 2 }))); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { ...makeView({ revision: 3 }), changed: true })); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); + + render(SegmentationTab); + const [firstHandle] = screen.getAllByTestId('threshold-handle'); + await fireEvent.input(firstHandle, { target: { value: '90' } }); + await fireEvent.change(firstHandle, { target: { value: '90' } }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + + const [firstUrl, firstInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(firstUrl).toBe('/api/v1/projects/appstate-test/thresholds'); + expect(firstInit.method).toBe('PUT'); + expect(JSON.parse(firstInit.body as string)).toMatchObject({ baseRevision: 1, values: [90, 170] }); + + const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(secondUrl).toBe('/api/v1/projects/appstate-test'); + + const [thirdUrl, thirdInit] = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(thirdUrl).toBe('/api/v1/projects/appstate-test/thresholds'); + expect(JSON.parse(thirdInit.body as string)).toMatchObject({ baseRevision: 2, values: [90, 170] }); + }); +}); diff --git a/frontend/src/lib/components/shared/TabPlaceholder.svelte b/frontend/src/lib/components/shared/TabPlaceholder.svelte new file mode 100644 index 0000000..2c93cb1 --- /dev/null +++ b/frontend/src/lib/components/shared/TabPlaceholder.svelte @@ -0,0 +1,9 @@ +
Not yet available in the new UI.
+ + diff --git a/frontend/src/lib/components/shell/Footer.svelte b/frontend/src/lib/components/shell/Footer.svelte new file mode 100644 index 0000000..859ed98 --- /dev/null +++ b/frontend/src/lib/components/shell/Footer.svelte @@ -0,0 +1,9 @@ +
© 2024 Niels Provos
+ + diff --git a/frontend/src/lib/components/shell/Header.svelte b/frontend/src/lib/components/shell/Header.svelte new file mode 100644 index 0000000..5e8b3a7 --- /dev/null +++ b/frontend/src/lib/components/shell/Header.svelte @@ -0,0 +1,73 @@ + + +
+
+ +
+

Parallax Maker

+ +
+ + diff --git a/frontend/src/lib/components/shell/LogPanel.svelte b/frontend/src/lib/components/shell/LogPanel.svelte new file mode 100644 index 0000000..274ea6e --- /dev/null +++ b/frontend/src/lib/components/shell/LogPanel.svelte @@ -0,0 +1,31 @@ + + +
+
+ {#each logStore.last3 as entry (entry.seq)} +
{entry.message}
+ {/each} +
+
+ + diff --git a/frontend/src/lib/components/shell/MainTabs.svelte b/frontend/src/lib/components/shell/MainTabs.svelte new file mode 100644 index 0000000..f114eaf --- /dev/null +++ b/frontend/src/lib/components/shell/MainTabs.svelte @@ -0,0 +1,56 @@ + + +
+
+ {#each MAIN_TABS as tab (tab)} + + {/each} +
+ + +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + diff --git a/frontend/src/lib/components/upload/InputImagePanel.svelte b/frontend/src/lib/components/upload/InputImagePanel.svelte new file mode 100644 index 0000000..5d52ce6 --- /dev/null +++ b/frontend/src/lib/components/upload/InputImagePanel.svelte @@ -0,0 +1,115 @@ + + +
+ Input Image +
+ + +
+
+
+ + diff --git a/frontend/src/lib/components/upload/ViewerTabs.svelte b/frontend/src/lib/components/upload/ViewerTabs.svelte new file mode 100644 index 0000000..8e279c8 --- /dev/null +++ b/frontend/src/lib/components/upload/ViewerTabs.svelte @@ -0,0 +1,44 @@ + + +
+
+ {#each tabs as tab (tab)} + + {/each} +
+ +
+ +
+
+ 3D viewer is not yet available in the new UI. +
+
+ + diff --git a/frontend/src/lib/state/busy.svelte.ts b/frontend/src/lib/state/busy.svelte.ts new file mode 100644 index 0000000..172d7c9 --- /dev/null +++ b/frontend/src/lib/state/busy.svelte.ts @@ -0,0 +1,13 @@ +import { projectStore } from './project.svelte'; +import { jobStore } from './jobs.svelte'; + +/** + * Whether mutating controls should be disabled: either a locally-initiated + * request/job is in flight (`jobStore.active`), or the server itself + * reports the project as busy (`ProjectView.busy`, e.g. a job started from + * another tab). Per the architecture doc's "Concurrency" section, the UI + * must never send a request it knows will be rejected with `409 busy`. + */ +export function isBusy(): boolean { + return jobStore.active !== null || projectStore.view?.busy != null; +} diff --git a/frontend/src/lib/state/jobs.svelte.ts b/frontend/src/lib/state/jobs.svelte.ts new file mode 100644 index 0000000..8f3702b --- /dev/null +++ b/frontend/src/lib/state/jobs.svelte.ts @@ -0,0 +1,40 @@ +/** + * Tracks a locally-initiated, in-flight request/job so controls can be + * disabled immediately -- before the server's `ProjectView.busy` would + * reflect it (e.g. the window between `createProject` returning and + * `startDepth` being submitted), and for the duration of synchronous + * mutations (thresholds, slice-count) that don't have a job id at all. + * + * `busy.svelte.ts` combines this with `ProjectView.busy` to answer "should + * mutating controls be disabled right now?". + */ + +export type JobKind = 'upload' | 'depth' | 'slices' | 'thresholds' | 'slice-count' | 'restore'; + +function createJobStore() { + let active = $state(null); + let progress = $state(0); + + return { + get active(): JobKind | null { + return active; + }, + get progress(): number { + return progress; + }, + begin(kind: JobKind): void { + active = kind; + progress = 0; + }, + setProgress(value: number): void { + progress = value; + }, + end(): void { + active = null; + progress = 0; + }, + }; +} + +export const jobStore = createJobStore(); +export type JobStore = ReturnType; diff --git a/frontend/src/lib/state/logs.svelte.ts b/frontend/src/lib/state/logs.svelte.ts new file mode 100644 index 0000000..717c071 --- /dev/null +++ b/frontend/src/lib/state/logs.svelte.ts @@ -0,0 +1,59 @@ +import { getLogs } from '../api/client'; + +/** + * The log pane's entries: server-side log entries (from + * `GET /projects/{id}/logs?after=`, per the architecture doc) merged with + * client-side entries (e.g. `ApiError` messages) in the order they occur. + * Client entries use negative `seq` values so they never collide with the + * server's monotonically increasing sequence numbers. + */ +export type DisplayLogEntry = { + seq: number; + level: string; + message: string; +}; + +function createLogStore() { + let entries = $state([]); + let after = 0; + let nextClientSeq = -1; + + function pushClient(message: string, level: string = 'error'): void { + entries = [...entries, { seq: nextClientSeq, level, message }]; + nextClientSeq -= 1; + } + + async function refresh(projectId: string): Promise { + try { + const page = await getLogs(projectId, after); + if (page.entries.length > 0) { + entries = [...entries, ...page.entries]; + after = page.next; + } + } catch { + // Best-effort: a failed log fetch should not itself surface as an + // error (that would recurse), and should not block the caller. + } + } + + function reset(): void { + entries = []; + after = 0; + nextClientSeq = -1; + } + + return { + get entries(): DisplayLogEntry[] { + return entries; + }, + get last3(): DisplayLogEntry[] { + return entries.slice(-3); + }, + pushClient, + refresh, + reset, + }; +} + +export const logStore = createLogStore(); +export type LogStore = ReturnType; diff --git a/frontend/src/lib/state/ui.svelte.ts b/frontend/src/lib/state/ui.svelte.ts new file mode 100644 index 0000000..429fdc3 --- /dev/null +++ b/frontend/src/lib/state/ui.svelte.ts @@ -0,0 +1,87 @@ +/** + * Transient, client-only UI state: active tabs, theme, and controls whose + * value is not (yet) persisted by the backend. Per the architecture doc + * ("Frontend layout"), backend state comes only from `ProjectView`; this + * store never holds anything the server would consider authoritative. + */ + +export type ViewerTab = '2D' | '3D'; +export type MainTab = 'Mode' | 'Segmentation' | 'Inpainting' | 'Export' | 'Configuration'; +export type SegmentationMode = 'depth' | 'segment'; +export type Theme = 'light' | 'dark'; + +export const MAIN_TABS: MainTab[] = ['Mode', 'Segmentation', 'Inpainting', 'Export', 'Configuration']; + +/** Matches components.py's DROPDOWN_DEPTH_MODEL default. */ +const DEFAULT_DEPTH_MODEL = 'dinov2'; +/** Matches components.py's SLIDER_NUM_SLICES default. */ +const DEFAULT_NUM_SLICES = 3; + +function createUiStore() { + let viewerTab = $state('2D'); + let mainTab = $state('Mode'); + let theme = $state('light'); + let segmentationMode = $state('depth'); + let depthModel = $state(DEFAULT_DEPTH_MODEL); + let pendingNumSlices = $state(DEFAULT_NUM_SLICES); + + return { + get viewerTab(): ViewerTab { + return viewerTab; + }, + setViewerTab(tab: ViewerTab): void { + viewerTab = tab; + }, + + get mainTab(): MainTab { + return mainTab; + }, + setMainTab(tab: MainTab): void { + mainTab = tab; + }, + + get theme(): Theme { + return theme; + }, + toggleTheme(): void { + theme = theme === 'light' ? 'dark' : 'light'; + }, + setTheme(next: Theme): void { + theme = next; + }, + + get segmentationMode(): SegmentationMode { + return segmentationMode; + }, + setSegmentationMode(mode: SegmentationMode): void { + segmentationMode = mode; + }, + + get depthModel(): string { + return depthModel; + }, + setDepthModel(model: string): void { + depthModel = model; + }, + + get pendingNumSlices(): number { + return pendingNumSlices; + }, + setPendingNumSlices(value: number): void { + pendingNumSlices = value; + }, + + /** Test-only: restores default values so stores don't leak between tests. */ + reset(): void { + viewerTab = '2D'; + mainTab = 'Mode'; + theme = 'light'; + segmentationMode = 'depth'; + depthModel = DEFAULT_DEPTH_MODEL; + pendingNumSlices = DEFAULT_NUM_SLICES; + }, + }; +} + +export const uiStore = createUiStore(); +export type UiStore = ReturnType; diff --git a/frontend/src/lib/workflow.test.ts b/frontend/src/lib/workflow.test.ts new file mode 100644 index 0000000..03ca0bf --- /dev/null +++ b/frontend/src/lib/workflow.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as workflow from './workflow'; +import { projectStore } from './state/project.svelte'; +import { jobStore } from './state/jobs.svelte'; +import { logStore } from './state/logs.svelte'; +import type { ProjectView } from './api/types'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeView(overrides: Partial = {}): ProjectView { + return { + id: 'appstate-test', + revision: 1, + image: { width: 320, height: 240 }, + assets: { input: null, depth: null }, + depthModel: 'dinov2', + numSlices: 3, + thresholds: [], + slices: [], + selectedSlice: null, + busy: null, + ...overrides, + }; +} + +describe('workflow', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('uploadImage creates a project and starts a depth job with the given model', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (url === '/api/v1/projects' && method === 'POST') { + return jsonResponse(201, makeView()); + } + if (url.startsWith('/api/v1/projects/appstate-test/logs')) { + return jsonResponse(200, { entries: [], next: 0 }); + } + if (url === '/api/v1/projects/appstate-test/depth' && method === 'POST') { + expect(JSON.parse(init!.body as string)).toEqual({ model: 'midas' }); + return jsonResponse(202, { job: { id: 'job-1', kind: 'depth', status: 'queued', progress: 0 } }); + } + if (url === '/api/v1/jobs/job-1') { + return jsonResponse(200, { + id: 'job-1', + kind: 'depth', + status: 'succeeded', + progress: 1, + project: makeView({ + assets: { input: { url: '/input' }, depth: { url: '/depth' } }, + thresholds: [0, 85, 170, 255], + }), + }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + const file = new File(['bytes'], 'input.png', { type: 'image/png' }); + await workflow.uploadImage(file, 'midas'); + + expect(jobStore.active).toBeNull(); + expect(projectStore.view?.assets.depth?.url).toBe('/depth'); + expect(projectStore.view?.thresholds).toEqual([0, 85, 170, 255]); + }); + + it('records an ApiError message in the log store when a mutation fails', async () => { + projectStore.applyView(makeView()); + + const fetchMock = vi.fn(async () => + jsonResponse(409, { error: { code: 'busy', message: 'the project is busy' } }), + ); + vi.stubGlobal('fetch', fetchMock); + + await workflow.updateSliceCount(4); + + expect(jobStore.active).toBeNull(); + expect(logStore.entries.at(-1)?.message).toBe('the project is busy'); + }); + + it('generateSlices polls the job and applies the resulting slices', async () => { + projectStore.applyView(makeView()); + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (url === '/api/v1/projects/appstate-test/slices' && method === 'POST') { + return jsonResponse(202, { job: { id: 'job-2', kind: 'slices', status: 'queued', progress: 0 } }); + } + if (url === '/api/v1/jobs/job-2') { + return jsonResponse(200, { + id: 'job-2', + kind: 'slices', + status: 'succeeded', + progress: 1, + project: makeView({ + slices: [ + { + index: 0, + depth: 85, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + image: { url: '/slice-0' }, + thumbnail: { url: '/slice-0-thumb' }, + }, + ], + }), + }); + } + if (url.startsWith('/api/v1/projects/appstate-test/logs')) { + return jsonResponse(200, { entries: [], next: 0 }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await workflow.generateSlices(); + + expect(projectStore.view?.slices).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/workflow.ts b/frontend/src/lib/workflow.ts new file mode 100644 index 0000000..f54bfc5 --- /dev/null +++ b/frontend/src/lib/workflow.ts @@ -0,0 +1,151 @@ +/** + * Orchestrates API calls with the reactive stores: applies `ProjectView`s + * through `projectStore.applyView` (revision-guarded), tracks locally + * in-flight requests through `jobStore`, and mirrors errors and job + * completion into `logStore` (client-side entries plus a log re-fetch, per + * the architecture doc's "Logs" behavior). Components call these functions + * rather than `lib/api/client.ts` directly, so the busy/log/error handling + * stays in one place and is unit-testable without rendering a component. + */ + +import * as api from './api/client'; +import { ApiError } from './api/client'; +import { projectStore } from './state/project.svelte'; +import { jobStore } from './state/jobs.svelte'; +import { logStore } from './state/logs.svelte'; + +function errorMessage(err: unknown): string { + if (err instanceof ApiError) return err.message; + if (err instanceof Error) return err.message; + return String(err); +} + +async function refreshLogs(projectId: string): Promise { + await logStore.refresh(projectId); +} + +/** + * Uploads a new source image, then immediately starts a depth job using + * `depthModel` (the currently selected value of the Mode tab's dropdown). + */ +export async function uploadImage(file: File, depthModel: string): Promise { + jobStore.begin('upload'); + let projectId: string | null = null; + try { + const view = await api.createProject(file); + projectId = view.id; + projectStore.applyView(view); + } catch (err) { + logStore.pushClient(errorMessage(err)); + jobStore.end(); + return; + } + jobStore.end(); + if (projectId) await refreshLogs(projectId); + await startDepth(depthModel); +} + +/** Starts (or restarts) the depth job for the current project and polls it to completion. */ +export async function startDepth(model: string): Promise { + const projectId = projectStore.view?.id; + if (!projectId) return; + + jobStore.begin('depth'); + try { + const { job } = await api.startDepth(projectId, model); + const finished = await api.pollJob(job.id, { + onProgress: (j) => jobStore.setProgress(j.progress), + }); + if (finished.project) projectStore.applyView(finished.project); + } catch (err) { + logStore.pushClient(errorMessage(err)); + } finally { + jobStore.end(); + await refreshLogs(projectId); + } +} + +/** Starts the slice-generation job for the current project and polls it to completion. */ +export async function generateSlices(): Promise { + const projectId = projectStore.view?.id; + if (!projectId) return; + + jobStore.begin('slices'); + try { + const { job } = await api.startSlices(projectId); + const finished = await api.pollJob(job.id, { + onProgress: (j) => jobStore.setProgress(j.progress), + }); + if (finished.project) projectStore.applyView(finished.project); + } catch (err) { + logStore.pushClient(errorMessage(err)); + } finally { + jobStore.end(); + await refreshLogs(projectId); + } +} + +/** + * Sends the full interior thresholds `values` array with the current + * revision as `baseRevision`. On `stale_revision`, refetches the project + * once and retries with the fresh revision (per the architecture doc's + * "Concurrency" section). + */ +export async function updateThresholds(values: number[]): Promise { + const view = projectStore.view; + if (!view) return; + + jobStore.begin('thresholds'); + try { + let result: Awaited>; + try { + result = await api.setThresholds(view.id, values, view.revision); + } catch (err) { + if (err instanceof ApiError && err.code === 'stale_revision') { + const fresh = await api.getProject(view.id); + projectStore.applyView(fresh); + result = await api.setThresholds(view.id, values, fresh.revision); + } else { + throw err; + } + } + projectStore.applyView(result); + } catch (err) { + logStore.pushClient(errorMessage(err)); + } finally { + jobStore.end(); + await refreshLogs(view.id); + } +} + +/** Updates the slice count for the current project (Configuration tab). */ +export async function updateSliceCount(numSlices: number): Promise { + const view = projectStore.view; + if (!view) return; + + jobStore.begin('slice-count'); + try { + const result = await api.setSliceCount(view.id, numSlices); + projectStore.applyView(result); + } catch (err) { + logStore.pushClient(errorMessage(err)); + } finally { + jobStore.end(); + await refreshLogs(view.id); + } +} + +/** Restores a legacy `appstate.json` (Configuration tab's Load State). */ +export async function restoreProject(file: File): Promise { + jobStore.begin('restore'); + try { + const view = await api.restoreProject(file); + logStore.reset(); + projectStore.applyView(view); + await refreshLogs(view.id); + } catch (err) { + logStore.pushClient(errorMessage(err)); + } finally { + jobStore.end(); + } +} diff --git a/package.json b/package.json index e291f51..f707490 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "tailwindcss -i ./tailwind.css -o ./parallax_maker/assets/css/tailwind_compiled.css", "watch": "tailwindcss -i ./tailwind.css -o ./parallax_maker/assets/css/tailwind_compiled.css --watch", "test": "echo \"Error: no test specified\" && exit 1", + "pretest:e2e": "npm run build:frontend", "test:e2e": "playwright test --config=e2e/playwright.config.ts", "test:e2e:headed": "playwright test --config=e2e/playwright.config.ts --headed", "check:e2e": "tsc --noEmit -p e2e/tsconfig.json",