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
6 changes: 4 additions & 2 deletions docs/svelte-migration/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ All JSON is camelCase. Errors: `{"error": {"code": str, "message": str}}`.
| `WorkflowNotReady` / service validation errors | 409 | `not_ready` |
| Project busy (another mutation or job running) | 409 | `busy` |
| Stale `baseRevision` | 409 | `stale_revision` |
| `DELETE /jobs/{id}` on a job that can't be cancelled right now | 409 | `not_cancellable` |
| Provider/model failure | 502 | `provider_error` |
| Unexpected | 500 | `internal` (details logged server-side only) |

Expand Down Expand Up @@ -138,7 +139,8 @@ Never serialize `AppState`, PIL/NumPy objects, model objects or credentials.
| `PUT /api/v1/projects/{id}/slice-count` | `{numSlices}` | `ProjectView` |
| `PUT /api/v1/projects/{id}/thresholds` | `{values, baseRevision}` | `ProjectView` |
| `POST /api/v1/projects/{id}/slices` | `{}` | `202 {job}` |
| `GET /api/v1/jobs/{jobId}` | – | `{id, kind, status: queued/running/succeeded/failed, progress: 0..1, error?, project?: ProjectView}` |
| `GET /api/v1/jobs/{jobId}` | – | `{id, kind, status: queued/running/succeeded/failed/cancelled, progress: 0..1, error?, detail?: string, cancellable: bool, project?: ProjectView}` (`project` is populated for every terminal status, including `cancelled`) |
| `DELETE /api/v1/jobs/{jobId}` | – | `200` with the same body as `GET` above; requests cancellation. Accepted (and returns the now-cancelled-or-cancelling job) for any `queued` job, or a `running` job whose kind opted into cancellation (currently only `inpainting` generation); `404 not_found` for an unknown id; `409 not_cancellable` for a `running` job that didn't opt in or a job that already reached a terminal status. A queued job's cancellation is only observed the next time `_begin_job`'s wrapper runs it, at which point it releases the project's busy slot without doing any work. A running cancellable job only notices the request the next time it reports progress (`Job.set_progress` raises `cancellation.OperationCancelled`, which `JobManager` turns into `cancelled` instead of `failed`) - `inpainting`'s per-diffusion-step callback is what makes that check frequent enough to feel responsive. `detail` is an optional short status line (e.g. depth generation reports "Loading the depth model (the first run downloads its weights)" while the model is loading) that is cleared on every terminal transition. |
| `GET /api/v1/projects/{id}/assets/{assetId}` | – | image bytes, `Cache-Control: no-cache`, strong ETag; `assetId="main"` serves the current display image (composed/encoded on request, bytes cached by revision), falling back to the input image when nothing has set a preview |
| `GET /api/v1/projects/{id}/logs?after={seq}` | – | `{entries: [{seq, level, message}], next}`; per-project ring buffer (replaces the Dash log pane) |
| `GET /api/v1/health` | – | `{ok: true, version}` |
Expand Down Expand Up @@ -236,7 +238,7 @@ mismatch is `409 not_ready`.
| `PUT /api/v1/projects/{id}/slices/{index}/prompts` | `{positivePrompt, negativePrompt}` | `200 ProjectView & {changed}` (sync); `update_prompts`; `changed: false` on `InpaintingUnchanged`. |
| `PUT /api/v1/projects/{id}/inpainting/settings` | `{model?, strength?, guidanceScale?, padding?, blur?, externalServer?, apiKey?}` | `200 ProjectView & {changed}` (sync); only fields present in the body are applied. `model` calls `update_model` (and drops any stored candidate set on an actual change, mirroring `remember_inpaint_model`); `externalServer`/`apiKey` are additionally written onto `AppState` (JSON-only save) like Dash's own settings panel. The rest become project-level defaults consumed by the next `generate` call. `apiKey` is write-only - never echoed back by any response. |
| `PUT /api/v1/projects/{id}/inpainting/workflow` | multipart `workflow` (ComfyUI JSON) | `200 ProjectView & {changed: true}` (sync); stored in memory and supplied to `generate_candidates` when `model == "comfyui"`. |
| `POST /api/v1/projects/{id}/slices/{index}/inpainting/generate` | `{mode: "paint" \| "fill" \| "enhance", positivePrompt, negativePrompt}` | `202 {job}` (kind `inpainting`); `generate_candidates` using the project's current settings (strength/guidanceScale/padding/blur/model/workflow). See "Candidates" below. |
| `POST /api/v1/projects/{id}/slices/{index}/inpainting/generate` | `{mode: "paint" \| "fill" \| "enhance", positivePrompt, negativePrompt}` | `202 {job}` (kind `inpainting`); `generate_candidates` using the project's current settings (strength/guidanceScale/padding/blur/model/workflow). See "Candidates" below. This is currently the only job kind whose job opts into `DELETE /jobs/{id}` cancellation (see the jobs table above); a cancelled generation leaves the previously stored candidate set and selection untouched, since the record is only replaced after `generate_candidates` returns successfully. |
| `PUT /api/v1/projects/{id}/inpainting/selection` | `{generationId, candidate: number \| null}` | `200 ProjectView & {changed}` (sync); `candidate: null` → `clear_selection`; otherwise `select_candidate` (selecting the same index again toggles it off). A `generationId` that doesn't match the project's current candidate set is `409 stale_revision`. |
| `POST /api/v1/projects/{id}/slices/{index}/inpainting/apply` | `{generationId}` | `200 ProjectView & {changed: true}` (sync); requires the current candidate set (`generationId`, slice index, and slice version must all still match); `apply_candidate` (new slice version, JSON-only save), re-composes the `main` asset, and drops the stored candidate set - mirrors Dash's `apply_inpainting` re-triggering `react_selected_slice_change`. |
| `POST /api/v1/projects/{id}/slices/{index}/inpainting/erase` | – | `200 ProjectView & {changed: true}` (sync); `erase` (new slice version, JSON-only save), re-composes the `main` asset. Unlike apply, any stored candidate set is left alone (Dash's `erase_inpainting` doesn't clear `CTR_INPAINTING_DISPLAY` either). |
Expand Down
13 changes: 6 additions & 7 deletions e2e/drivers/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,20 +269,19 @@ export class SvelteDriver implements UiDriver {

async clickImagePixel(x: number, y: number, modifiers: Modifier[] = []): Promise<void> {
await this.closeDialogs();
// Same position computation as DashDriver.clickImagePixel: the main
// image renders at `width: 100%; height: auto` (see
// InputImagePanel.svelte), so this scale is the exact inverse of the
// backend's `find_pixel_from_click` ratio, and real click coordinates'
// sub-pixel rounding truncates the resulting pixel the same way on both
// UIs (see lib/geometry.ts's `findPixelFromClick`).
// The image is fitted into the stage, so this scale is the inverse of
// lib/geometry.ts's `findPixelFromClick` ratio. Aim at the pixel's
// center, not its top-left corner: a corner click can round into the
// neighbouring pixel depending on the fitted scale (i.e. the window and
// column widths).
// Clicks select with the Segment tool, on the Input view.
await this.ensureTool('segment');
const image = this.mainImage();
const position = await image.evaluate(
(element: HTMLImageElement, point) => {
const rect = element.getBoundingClientRect();
const scale = Math.min(rect.width / element.naturalWidth, rect.height / element.naturalHeight);
return { x: point.x * scale, y: point.y * scale };
return { x: (point.x + 0.5) * scale, y: (point.y + 0.5) * scale };
},
{ x, y },
);
Expand Down
16 changes: 7 additions & 9 deletions e2e/parallax-maker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,19 @@ test('point segmentation honors positive and negative points through the UI', as
.toEqual({ enabled: true, points: [] });

await ui.clickImagePixel(90, 96);
// The current browser-to-image transform scales the rendered click and then
// truncates it, so these requested positions arrive one pixel lower on each axis.
// Clicks land on the requested pixel whatever the fitted scale.
await expect
.poll(async () => (await readE2EState(page, projectId)).points_selected)
.toEqual([{ point: [89, 95], negative: false }]);
.toEqual([{ point: [90, 96], negative: false }]);
expect((await readE2EState(page, projectId)).slice_mask).toEqual(positiveMask);
expect(await imageHash(image)).toBe(committedImageHash);

await ui.clickImagePixel(128, 96, ['Control']);
await expect
.poll(async () => (await readE2EState(page, projectId)).points_selected)
.toEqual([
{ point: [89, 95], negative: false },
{ point: [127, 95], negative: true },
{ point: [90, 96], negative: false },
{ point: [128, 96], negative: true },
]);
expect((await readE2EState(page, projectId)).slice_mask).toEqual(positiveMask);
expect(await imageHash(image)).toBe(committedImageHash);
Expand Down Expand Up @@ -190,12 +189,11 @@ test('a depth-band click records its pixel, depth, log, and mask', async ({ page
.poll(async () => (await readE2EState(page, projectId)).slice_pixel)
.not.toBeNull();
const state = await readE2EState(page, projectId);
// Lock in the same rendered-coordinate truncation exercised by real clicks.
expect(state.slice_pixel).toEqual([15, 15]);
expect(state.slice_pixel_depth).toBe(1);
expect(state.slice_pixel).toEqual([16, 16]);
expect(state.slice_pixel_depth).toBe(2);
expect(state.slice_mask.samples).toMatchObject({ '16,16': 255, '160,96': 0 });
await expect(ui.log()).toContainText(
'Click event at pixel coordinates (15, 15) at depth 1',
'Click event at pixel coordinates (16, 16) at depth 2',
);
});

Expand Down
6 changes: 1 addition & 5 deletions e2e/ux-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,7 @@ test('after zooming in and panning, a depth-mode click at a known source pixel l
// of whether the pan above actually took effect - proving the pixel
// mapping itself (lib/geometry.ts's `findPixelFromClick`) stays exact
// under zoom, independent of the pan gap just pinned above. The `<= 1`
// tolerance (not exact equality) is the same real sub-pixel truncation
// quirk `e2e/parallax-maker.spec.ts`'s own "depth-band click" scenario
// pins for a *plain, unzoomed* click (a requested (16, 16) truncates to
// (15, 15)) - a fractional zoom scale only makes that rounding harder to
// predict exactly by hand, not any less exact in what the app itself does.
// tolerance allows for the fractional zoom scale's sub-pixel rounding.
await ui.clickImagePixel(160, 120);
await expect
.poll(async () => (await readE2EState(page, projectId)).slice_pixel)
Expand Down
12 changes: 0 additions & 12 deletions frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,6 @@
--toolopts-h: 44px;
--layers-w: 300px;
--inspector-w: 328px;

/*
* Legacy names still used by components that predate the redesign. Each
* goes away when its last user is rebuilt (docs/redesign/HANDOFF.md §12).
*/
--color-accent: var(--color-selection);
--color-accent-text: var(--color-selection-text);
--color-success-text: var(--color-primary-text);
--color-danger-strong: var(--color-danger);
--color-disabled-text: var(--color-text-muted);
--color-surface-muted: var(--color-surface-raised);
--color-depth-number: color-mix(in srgb, var(--color-primary) 55%, transparent);
}

html,
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/lib/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiError, JobFailedError, getJob, health, pollJob } from './client';
import { ApiError, JobCancelledError, JobFailedError, cancelJob, getJob, health, pollJob } from './client';

function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
Expand Down Expand Up @@ -119,4 +119,37 @@ describe('pollJob', () => {
await getJob('job-3');
expect(fetchMock).toHaveBeenCalledWith('/api/v1/jobs/job-3', expect.objectContaining({ method: 'GET' }));
});

it('rejects with a JobCancelledError when the job is cancelled', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
jsonResponse(200, { id: 'job-4', kind: 'depth', status: 'cancelled', progress: 0.3 }),
);
vi.stubGlobal('fetch', fetchMock);

const cancelled = pollJob('job-4', { intervalMs: 0 });
await expect(cancelled).rejects.toBeInstanceOf(JobCancelledError);
await expect(cancelled).rejects.toMatchObject({
job: { id: 'job-4', status: 'cancelled' },
});
});
});

describe('cancelJob', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('issues a DELETE to /api/v1/jobs/{id}', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse(200, { id: 'job-5', kind: 'depth', status: 'cancelled', progress: 0.2 }));
vi.stubGlobal('fetch', fetchMock);

const job = await cancelJob('job-5');

expect(fetchMock).toHaveBeenCalledWith('/api/v1/jobs/job-5', expect.objectContaining({ method: 'DELETE' }));
expect(job.status).toBe('cancelled');
});
});
24 changes: 24 additions & 0 deletions frontend/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ export class ApiError extends Error {
}

/** A background job reached status `failed`; carries the terminal job record. */
/** A polled job that ended because it was cancelled (`DELETE /jobs/{id}`). */
export class JobCancelledError extends Error {
readonly job: Job;

constructor(job: Job) {
super('Cancelled');
this.name = 'JobCancelledError';
this.job = job;
}
}

export class JobFailedError extends Error {
readonly job: Job;
Comment thread
Copilot marked this conversation as resolved.
Outdated

Expand Down Expand Up @@ -191,6 +202,14 @@ export function getJob(jobId: string, signal?: AbortSignal): Promise<Job> {
return request<Job>(`/jobs/${encodeURIComponent(jobId)}`, { signal });
}

/**
* DELETE /api/v1/jobs/{id}: stops a queued job, or a running one that can
* stop safely (`Job.cancellable`); 409 otherwise.
*/
export function cancelJob(jobId: string, signal?: AbortSignal): Promise<Job> {
return request<Job>(`/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE', signal });
}

/** GET /api/v1/projects/{id}/logs?after={seq} */
export function getLogs(
id: string,
Expand Down Expand Up @@ -807,6 +826,11 @@ export function pollJob(jobId: string, options: PollJobOptions = {}): Promise<Jo
reject(new JobFailedError(job));
return;
}
if (job.status === 'cancelled') {
cleanup();
reject(new JobCancelledError(job));
return;
}
setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export type Model2 = string;
export type Padding1 = number;
export type Selectedcandidate = number | null;
export type Strength1 = number;
export type Cancellable = boolean;
export type Detail = string | null;
export type Error = string | null;
export type Id = string;
export type Kind1 = string;
Expand Down Expand Up @@ -102,7 +104,7 @@ export type Version1 = number;
export type Slices = SliceView[];
export type Thresholds = number[];
export type Usecheckerboard = boolean;
export type Status = "queued" | "running" | "succeeded" | "failed";
export type Status = "queued" | "running" | "succeeded" | "failed" | "cancelled";
export type Level = string;
export type Message1 = string;
export type Seq = number;
Expand Down Expand Up @@ -364,6 +366,8 @@ export interface JobRef {
* via the `definition` "JobView".
*/
export interface JobView {
cancellable?: Cancellable;
detail?: Detail;
error?: Error;
id: Id;
kind: Kind1;
Expand Down
20 changes: 19 additions & 1 deletion frontend/src/lib/api/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,23 @@
"JobView": {
"additionalProperties": false,
"properties": {
"cancellable": {
"default": false,
"title": "Cancellable",
"type": "boolean"
},
"detail": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"title": "Detail"
},
"error": {
"anyOf": [
{
Expand Down Expand Up @@ -624,7 +641,8 @@
"queued",
"running",
"succeeded",
"failed"
"failed",
"cancelled"
],
"title": "Status",
"type": "string"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/components/canvas/CanvasArea.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import ToolOptionsBar from './ToolOptionsBar.svelte';
import CanvasToolbar from './CanvasToolbar.svelte';
import SelectionBar from './SelectionBar.svelte';
import ToastStack from '../feedback/ToastStack.svelte';

let stage: ReturnType<typeof InputImagePanel> | undefined = $state();

Expand Down Expand Up @@ -57,6 +58,7 @@
<SelectionBar />
{/if}
{/if}
<ToastStack />
</div>
</section>

Expand Down
9 changes: 2 additions & 7 deletions frontend/src/lib/components/depth/ModeTab.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script lang="ts">
import { projectStore } from '../../state/project.svelte';
import { uiStore } from '../../state/ui.svelte';
import { jobStore } from '../../state/jobs.svelte';
import { isBusy } from '../../state/busy.svelte';
import * as workflow from '../../workflow';
import JobCard from '../feedback/JobCard.svelte';

// Same labels/values as components.py's DROPDOWN_DEPTH_MODEL.
const depthOptions: Array<{ value: string; label: string }> = [
Expand Down Expand Up @@ -39,9 +39,6 @@
if (isBusy() || !projectStore.view) return;
void workflow.startDepth(uiStore.depthModel);
}

const showProgress = $derived(jobStore.active === 'depth' || jobStore.active === 'upload');
const progressPercent = $derived(Math.round((showProgress ? jobStore.progress : 0) * 100));
</script>

<div class="mode-tab" data-testid="tab-mode">
Expand Down Expand Up @@ -77,9 +74,7 @@
</button>
</div>

<div class="progress-bar" data-testid="depth-progress">
<div class="progress-bar-fill" style={`width: ${showProgress ? progressPercent : 0}%`}></div>
</div>
<JobCard kinds={['upload', 'depth']} testId="depth-progress" />

</div>

Expand Down
19 changes: 3 additions & 16 deletions frontend/src/lib/components/dialogs/ExportDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
* texture upscaling, Create / Download glTF) and the animation.
*/
import { projectStore } from '../../state/project.svelte';
import { jobStore } from '../../state/jobs.svelte';
import { isBusy } from '../../state/busy.svelte';
import * as workflow from '../../workflow';
import { triggerDownload } from '../../download';
import { cameraDraftStore } from '../../state/cameraDraft.svelte';
import { uiStore } from '../../state/ui.svelte';
import CameraSlider from '../shared/CameraSlider.svelte';
import Dialog from '../shared/Dialog.svelte';
import JobCard from '../feedback/JobCard.svelte';
import Box from '@lucide/svelte/icons/box';
import Clapperboard from '@lucide/svelte/icons/clapperboard';
import Check from '@lucide/svelte/icons/check';
Expand All @@ -36,7 +36,6 @@
// Model3DViewer.svelte), same as Dash's own
// `gltf_create` updating the `#model-viewer` iframe - it does not, by
// itself, switch the active viewer tab either.
const creatingGltf = $derived(jobStore.active === 'export-gltf');

function createGltfScene(): void {
if (!view || isBusy()) return;
Expand All @@ -54,7 +53,6 @@
}

// -- Upscale Textures (WEB-27).
const upscaling = $derived(jobStore.active === 'upscale');

function upscaleTextures(): void {
if (!view || isBusy()) return;
Expand All @@ -67,7 +65,6 @@
// setting (components.py's SLIDER_NUM_FRAMES has no "remember" callback).
let numFrames = $state(100);
const seconds = $derived((numFrames / 30).toFixed(1));
const animating = $derived(jobStore.active === 'animation');

function exportAnimation(): void {
if (!view || isBusy() || numFrames <= 0) return;
Expand Down Expand Up @@ -149,12 +146,7 @@
{#if view?.exports.upscaled}<span class="faint">Textures upscaled</span>{/if}
</div>
<span class="spacer"></span>
<div class="progress-bar" data-testid="export-progress" class:idle={!(creatingGltf || upscaling)}>
<div
class="progress-bar-fill"
style={`width: ${(creatingGltf || upscaling) ? Math.round(jobStore.progress * 100) : 0}%`}
></div>
</div>
<JobCard kinds={['export-gltf', 'upscale']} testId="export-progress" />
<div class="row end">
{#if gltfReady}
<span class="ready grow"><Check size={16} strokeWidth={1.6} /> Scene ready</span>
Expand Down Expand Up @@ -204,9 +196,7 @@
about {seconds} s.
</p>
<span class="spacer"></span>
<div class="progress-bar" data-testid="animation-progress" class:idle={!animating}>
<div class="progress-bar-fill" style={`width: ${animating ? Math.round(jobStore.progress * 100) : 0}%`}></div>
</div>
<JobCard kinds={['animation']} testId="animation-progress" />
<div class="row end">
<button
type="button"
Expand Down Expand Up @@ -345,7 +335,4 @@
font-size: 12px;
}

.progress-bar.idle {
visibility: hidden;
}
</style>
Loading
Loading