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
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 @@ -34,6 +34,17 @@ export class ApiError extends Error {
}
}

/** 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;
}
}

/** A background job reached status `failed`; carries the terminal job record. */
export class JobFailedError extends Error {
readonly job: Job;
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