diff --git a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx
index f04e43b0807..5e6667c5e63 100644
--- a/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx
+++ b/docs/src/content/docs/Users Guide/06.Gallery/gallery-reference.mdx
@@ -65,7 +65,7 @@ On a multi-user installation, only a board's owner or an administrator can delet
Right-click an item, or several selected items, for its menu.
-
+
For an image, the menu offers:
@@ -74,6 +74,7 @@ For an image, the menu offers:
* **Add to Current Canvas** — add the image to the open project's canvas as a new layer of the kind you choose.
* **New Project from Image** — start a new project whose canvas holds the image, sized to fit it.
* **Send to Upscale**, **Send to Video**, **Use as Reference Image**, **Use as Prompt Template** and **Select for Compare** — use the image elsewhere in InvokeAI.
+* **Generate PBR Maps** — queue normal, roughness and displacement maps of the image. They are saved to the board the Gallery is auto-adding to, or to the project's Inbox if you can't add to that board (Uncategorized when there is neither), and a notice reports when they are ready. The first run downloads the PBR models.
* **Change Board** and **Delete Image**.
A video's menu has the equivalent actions for video, including **Extend in Video**, **Use as Reference Video** and **Use as Conditioning Clip**. With several items selected, the menu offers the actions that apply to all of them, such as **Star All**, **Download Selection**, **Change Board** and **Delete Selection**; for several images it also leads with **Add to Current Canvas** and **New Project from Images**.
diff --git a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx
index 7f5ca0e97cb..ec8d93f46b3 100644
--- a/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx
+++ b/docs/src/content/docs/Users Guide/06.Gallery/image-storage-maintenance.mdx
@@ -9,15 +9,21 @@ This operation changes where image files and thumbnails are stored on disk. It d
## Access
-Image storage maintenance is available from the in-application Settings panel.
+Open **Settings → Server → Image storage**. The **Folder layout for new images** select changes `image_subfolder_strategy` immediately for images saved from then on. Below it, **Move existing images** shows how many images are stored outside the current layout, the state of the last move job, and the **Start Move** and **Recover** buttons; each asks for confirmation before it starts.
-Administrators can start image moves, force recovery, and inspect the current move status. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode cannot run or inspect image move status.
+- **Start Move** is available when images are stored outside the current layout and no interrupted job is waiting for recovery. The server still refuses it while any queue item is pending or in progress.
+- **Recover** is available only when a move was interrupted and still needs finishing.
+- Both buttons are unavailable while a job runs or a layout change is being saved. The status line or a note below the buttons says why a button is unavailable.
+
+While a job runs and the Image storage setting is open, the status refreshes every few seconds and the folder layout cannot be changed. Closing Settings stops the refresh; reopen it to see the current state.
+
+Administrators can start image moves, force recovery, and inspect the current move status. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode do not see the Server section and cannot run or inspect image move status.
## Maintenance Mode
-Image moves run as a maintenance operation. Before a move starts, InvokeAI checks that no queue work is active. While the move is running, InvokeAI prevents image reads, uploads, deletes, generation jobs, and gallery mutations from racing with the filesystem move.
+Image moves run as a maintenance operation. Before a move starts, InvokeAI checks that no queue work is active. While the move is running, or an interrupted move is waiting for recovery, InvokeAI prevents image reads, uploads, deletes, generation jobs, and gallery mutations from racing with the filesystem move.
-The UI shows the move or recovery state until the job is complete or requires manual attention. Gallery images and thumbnails may be unavailable while maintenance is active.
+The UI shows the move or recovery state until the job is complete or requires manual attention. Gallery images and thumbnails may be unavailable while maintenance is active; an open gallery keeps checking periodically while its tab is visible and reloads them on its own shortly after maintenance ends, so you do not need to refresh the page.
## Crash Recovery
@@ -25,7 +31,7 @@ The move process is crash-recoverable. InvokeAI records each move job in its dat
If InvokeAI stops during a move, recovery resumes incomplete jobs. If recovery finds an ambiguous filesystem state, such as both the old and new full-size image files existing or neither file existing, it halts that item for manual repair instead of blindly updating the database. Other items in the same job are still finalized, and the maintenance lock is released when the job is recorded as failed.
-Recovery finalizes jobs that were already recorded before the stop. Images that had not yet been planned into a move job are not moved by recovery itself. The Settings panel reports how many images still need moving; run **Start Move** again to continue.
+Recovery finalizes jobs that were already recorded before the stop. Images that had not yet been planned into a move job are not moved by recovery itself. The Image storage setting reports how many images still need moving; run **Start Move** again to continue. While a job is interrupted, **Start Move** stays unavailable and the status asks you to run **Recover** first.
Missing intermediate image files are treated as already cleaned up and do not halt the move. Missing non-intermediate image files, unreadable images, and truncated images are recorded as failed items. When the full-size file is still at its original path, recovery leaves it there so it can be repaired or removed safely. If a previous filesystem move already placed the full-size file at its destination, recovery keeps that destination and reconciles the database before recording the item as failed. After recovery releases the lock, back up the failed file and either replace it with a valid image or delete its image record from the gallery, then run **Start Move** again if images still need moving. Do not delete an active journal row or edit `images.image_subfolder` while maintenance is active.
diff --git a/docs/src/content/docs/configuration/01.invokeai-yaml.mdx b/docs/src/content/docs/configuration/01.invokeai-yaml.mdx
index 28f3514631a..2c1dfa11865 100644
--- a/docs/src/content/docs/configuration/01.invokeai-yaml.mdx
+++ b/docs/src/content/docs/configuration/01.invokeai-yaml.mdx
@@ -197,7 +197,7 @@ Background indexing of new images already waits until no generation is running,
#### Image Subfolder Strategy
-By default, generated images are stored in a single flat directory under `outputs/images/`. The `image_subfolder_strategy` setting lets you organize newly-created images into subfolders automatically. You can edit this setting in `invokeai.yaml` or, as an admin user, in the Settings panel.
+By default, generated images are stored in a single flat directory under `outputs/images/`. The `image_subfolder_strategy` setting lets you organize newly-created images into subfolders automatically. You can edit this setting in `invokeai.yaml` or, as an admin user, under **Settings → Server → Image storage**.
```yaml
image_subfolder_strategy: flat # default value
diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json
index 0126e029233..3b32282024d 100644
--- a/invokeai/frontend/webv2/performance/architecture-baseline.json
+++ b/invokeai/frontend/webv2/performance/architecture-baseline.json
@@ -135,6 +135,8 @@
"source:src/features/fonts/launchpad.tsx",
"source:src/features/fonts/react.tsx",
"source:src/features/fonts/runtime.ts",
+ "source:src/features/gallery/data/imageRecovery.ts",
+ "source:src/features/gallery/ui/useGalleryImageRecovery.ts",
"source:src/features/identity/capabilities.ts",
"source:src/features/identity/core/schemas.ts",
"source:src/features/identity/core/sessionPolicy.ts",
@@ -515,6 +517,7 @@
"source:src/features/gallery/core/settings.ts",
"source:src/features/gallery/core/videoPaths.ts",
"source:src/features/gallery/data/backend.ts",
+ "source:src/features/gallery/data/imageRecovery.ts",
"source:src/features/gallery/data/imageUrls.ts",
"source:src/features/gallery/data/queries.ts",
"source:src/features/gallery/data/queryCache.ts",
@@ -543,6 +546,7 @@
"source:src/features/gallery/ui/galleryStateView.ts",
"source:src/features/gallery/ui/picker/GalleryPickerPopover.tsx",
"source:src/features/gallery/ui/useGalleryData.ts",
+ "source:src/features/gallery/ui/useGalleryImageRecovery.ts",
"source:src/features/gallery/ui/useGalleryUploadAction.ts",
"source:src/features/gallery/ui/useGalleryUploadInput.ts",
"source:src/features/gallery/utility.ts",
diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json
index 0182034957a..12f4863aa06 100644
--- a/invokeai/frontend/webv2/performance/browser-baseline.json
+++ b/invokeai/frontend/webv2/performance/browser-baseline.json
@@ -521,6 +521,7 @@
"source:src/features/gallery/core/settings.ts",
"source:src/features/gallery/core/videoPaths.ts",
"source:src/features/gallery/data/backend.ts",
+ "source:src/features/gallery/data/imageRecovery.ts",
"source:src/features/gallery/data/imageUrls.ts",
"source:src/features/gallery/data/videoUrls.ts",
"source:src/features/gallery/launchpad.ts",
@@ -528,6 +529,7 @@
"source:src/features/gallery/ui/GalleryUiContext.tsx",
"source:src/features/gallery/ui/galleryStateView.ts",
"source:src/features/gallery/ui/launchpad/RecentOutputs.tsx",
+ "source:src/features/gallery/ui/useGalleryImageRecovery.ts",
"source:src/features/identity/capabilities.ts",
"source:src/features/identity/core/schemas.ts",
"source:src/features/identity/core/sessionPolicy.ts",
@@ -912,6 +914,7 @@
"source:src/features/gallery/core/settings.ts",
"source:src/features/gallery/core/videoPaths.ts",
"source:src/features/gallery/data/backend.ts",
+ "source:src/features/gallery/data/imageRecovery.ts",
"source:src/features/gallery/data/imageUrls.ts",
"source:src/features/gallery/data/queries.ts",
"source:src/features/gallery/data/queryCache.ts",
@@ -968,6 +971,7 @@
"source:src/features/gallery/ui/useGalleryData.ts",
"source:src/features/gallery/ui/useGalleryGridHotkeys.ts",
"source:src/features/gallery/ui/useGalleryGridSelection.ts",
+ "source:src/features/gallery/ui/useGalleryImageRecovery.ts",
"source:src/features/gallery/ui/useGalleryStarredStrip.ts",
"source:src/features/gallery/ui/useGalleryUploadAction.ts",
"source:src/features/gallery/ui/useGalleryUploadInput.ts",
@@ -1605,6 +1609,7 @@
"source:src/workbench/image-actions/imageRecall.ts",
"source:src/workbench/image-actions/index.ts",
"source:src/workbench/image-actions/mediaUsage.ts",
+ "source:src/workbench/image-actions/pbrMapsGraph.ts",
"source:src/workbench/image-actions/recallParametersBridge.ts",
"source:src/workbench/image-actions/useDeletionConfirmation.tsx",
"source:src/workbench/image-actions/useFindGalleryItem.ts",
@@ -2022,6 +2027,7 @@
"source:src/features/gallery/core/settings.ts",
"source:src/features/gallery/core/videoPaths.ts",
"source:src/features/gallery/data/backend.ts",
+ "source:src/features/gallery/data/imageRecovery.ts",
"source:src/features/gallery/data/imageUrls.ts",
"source:src/features/gallery/data/queries.ts",
"source:src/features/gallery/data/queryCache.ts",
@@ -2078,6 +2084,7 @@
"source:src/features/gallery/ui/useGalleryData.ts",
"source:src/features/gallery/ui/useGalleryGridHotkeys.ts",
"source:src/features/gallery/ui/useGalleryGridSelection.ts",
+ "source:src/features/gallery/ui/useGalleryImageRecovery.ts",
"source:src/features/gallery/ui/useGalleryStarredStrip.ts",
"source:src/features/gallery/ui/useGalleryUploadAction.ts",
"source:src/features/gallery/ui/useGalleryUploadInput.ts",
@@ -2733,6 +2740,7 @@
"source:src/workbench/image-actions/imageRecall.ts",
"source:src/workbench/image-actions/index.ts",
"source:src/workbench/image-actions/mediaUsage.ts",
+ "source:src/workbench/image-actions/pbrMapsGraph.ts",
"source:src/workbench/image-actions/recallParametersBridge.ts",
"source:src/workbench/image-actions/useDeletionConfirmation.tsx",
"source:src/workbench/image-actions/useFindGalleryItem.ts",
@@ -3316,6 +3324,7 @@
"source:src/features/gallery/core/settings.ts",
"source:src/features/gallery/core/videoPaths.ts",
"source:src/features/gallery/data/backend.ts",
+ "source:src/features/gallery/data/imageRecovery.ts",
"source:src/features/gallery/data/imageUrls.ts",
"source:src/features/gallery/data/queries.ts",
"source:src/features/gallery/data/queryCache.ts",
@@ -3372,6 +3381,7 @@
"source:src/features/gallery/ui/useGalleryData.ts",
"source:src/features/gallery/ui/useGalleryGridHotkeys.ts",
"source:src/features/gallery/ui/useGalleryGridSelection.ts",
+ "source:src/features/gallery/ui/useGalleryImageRecovery.ts",
"source:src/features/gallery/ui/useGalleryStarredStrip.ts",
"source:src/features/gallery/ui/useGalleryUploadAction.ts",
"source:src/features/gallery/ui/useGalleryUploadInput.ts",
@@ -4086,6 +4096,7 @@
"source:src/workbench/image-actions/imageRecall.ts",
"source:src/workbench/image-actions/index.ts",
"source:src/workbench/image-actions/mediaUsage.ts",
+ "source:src/workbench/image-actions/pbrMapsGraph.ts",
"source:src/workbench/image-actions/recallParametersBridge.ts",
"source:src/workbench/image-actions/useDeletionConfirmation.tsx",
"source:src/workbench/image-actions/useFindGalleryItem.ts",
diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json
index d2869ea7f7b..11d236ee342 100644
--- a/invokeai/frontend/webv2/public/locales/en.json
+++ b/invokeai/frontend/webv2/public/locales/en.json
@@ -2063,6 +2063,50 @@
"failed": "Database VACUUM failed.",
"requestFailed": "The database could not be vacuumed."
},
+ "imageStorage": {
+ "adminOnly": "Only an administrator can change where images are stored.",
+ "layoutLabel": "Folder layout for new images",
+ "layoutHelp": "Applies to images saved from now on. Existing images stay where they are until you run Start Move.",
+ "layoutLoadFailed": "The storage layout could not be loaded.",
+ "layoutSaveFailed": "The storage layout could not be saved.",
+ "layouts": {
+ "flat": "Flat (one folder)",
+ "date": "By date (YYYY/MM/DD)",
+ "type": "By image category",
+ "hash": "By name prefix (hashed)",
+ "unknown": "Unknown layout ({{layout}})"
+ },
+ "maintenanceHeading": "Move existing images",
+ "maintenanceDescription": "Moves every image and thumbnail into the current layout. Names, boards and metadata are unchanged. While the move runs, the gallery and the queue are paused.",
+ "statusLoadFailed": "The image move status could not be loaded.",
+ "statusIdle_zero": "All images are stored in the current layout.",
+ "statusIdle_one": "{{count}} image is stored outside the current layout.",
+ "statusIdle_other": "{{count}} images are stored outside the current layout.",
+ "statusMoving": "Moving images into the current layout…",
+ "statusRecovering": "Recovering an interrupted image move…",
+ "statusNeedsRecovery": "An image move was interrupted. Run Recover to finish it before starting another move.",
+ "latestJob": "Last move: {{state}}",
+ "jobStates": {
+ "planned": "planned",
+ "moving": "moving",
+ "moved": "moved",
+ "committed": "completed",
+ "error": "failed"
+ },
+ "startMove": "Start Move",
+ "startConfirmTitle": "Move all images into the current layout?",
+ "startConfirmBody": "InvokeAI pauses image reads, uploads, deletions and generation for everyone on this server until the move finishes. It cannot start while queue work is active.",
+ "startFailed": "The image move could not be started.",
+ "recover": "Recover",
+ "recoverConfirmTitle": "Recover the image move journal?",
+ "recoverConfirmBody": "Recovery finishes moves that were recorded before the server stopped. Items in an ambiguous state are left for manual repair and reported here.",
+ "recoverFailed": "Image move recovery could not be started.",
+ "unavailableStatus": "Start Move and Recover become available once the move status has loaded.",
+ "unavailableRunning": "Start Move and Recover become available again when the current job finishes.",
+ "unavailableSavingLayout": "Start Move and Recover become available once the new layout is saved.",
+ "recoverNotNeeded": "Recover is only needed after a move is interrupted.",
+ "nothingToDo": "Nothing to move, and no interrupted move to recover."
+ },
"galleryMaintenance": {
"heading": "Gallery maintenance",
"removeMissing": "Remove records for missing images",
@@ -2203,6 +2247,10 @@
"label": "Generation devices",
"description": "Server-wide accelerator selection. Changes require a server restart."
},
+ "imageStorage": {
+ "label": "Image storage",
+ "description": "Where this server stores image files on disk, and the maintenance job that moves existing images into that layout."
+ },
"maxQueueHistory": {
"label": "Queue history",
"description": "How many finished queue items the server keeps on startup. Changes require a server restart."
@@ -4846,6 +4894,18 @@
"missing": "This image has no workflow embedded.",
"missingVideo": "This video has no workflow embedded."
},
+ "pbrMaps": {
+ "canceled": "PBR maps canceled",
+ "canceledMessage": "The queue item generating maps for {{name}} was canceled.",
+ "completed": "PBR maps are ready",
+ "completedMessage": "Normal, roughness and displacement maps for {{name}} were added to the gallery.",
+ "failed": "Could not generate PBR maps",
+ "label": "Generate PBR Maps",
+ "queued": "Generating PBR maps",
+ "queuedMessage": "Normal, roughness and displacement maps for {{name}} are queued.",
+ "removed": "PBR maps removed from the queue",
+ "removedMessage": "The queue item generating maps for {{name}} was removed from the queue."
+ },
"delete": {
"failure_one": "Could not delete {{count}} item.",
"failure_other": "Could not delete {{count}} items.",
diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs
index 19288f5940b..04d6739b004 100644
--- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs
+++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.test.mjs
@@ -202,7 +202,7 @@ test('the HTTP reset contract selects profiles explicitly and restores the start
);
assert.equal(projects.length, 40);
assert.deepEqual(runtimeConfig, {
- config: { generation_devices: 'auto', max_queue_history: null },
+ config: { generation_devices: 'auto', image_subfolder_strategy: 'flat', max_queue_history: null },
set_fields: [],
});
@@ -248,6 +248,16 @@ const getJson = async (backend, path, init) => {
return body;
};
+/** The representative queue holds pending and in-progress items, which refuse Start Move as on the real server. */
+const clearQueue = (backend) => getJson(backend, '/api/v1/queue/default/clear', { method: 'PUT' });
+
+const enqueueBatch = (backend) =>
+ fetch(`${backend.origin}/api/v1/queue/default/enqueue_batch`, {
+ body: JSON.stringify({ batch: { graph: { edges: [], id: 'pbr', nodes: {} }, runs: 1 }, prepend: false }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ });
+
test('queue item ids take the backend limit: the head of the order, counted as returned, bounded', async () => {
await withRepresentativeBackend(async (backend) => {
for (const orderDir of ['DESC', 'ASC']) {
@@ -1211,6 +1221,243 @@ test('workflow thumbnails round-trip their bytes and refresh the listed URL on e
});
});
+test('image storage: the layout is patched alone and the move count follows it', async () => {
+ await withRepresentativeBackend(async (backend) => {
+ assert.deepEqual(await getJson(backend, '/api/v1/image_moves/status'), {
+ active_job_id: null,
+ is_running: false,
+ last_error: null,
+ latest_job: null,
+ needs_move_count: 0,
+ operation: null,
+ });
+
+ const patched = await getJson(backend, '/api/v1/app/runtime_config', {
+ body: JSON.stringify({ image_subfolder_strategy: 'date' }),
+ headers: { 'content-type': 'application/json' },
+ method: 'PATCH',
+ });
+
+ assert.deepEqual(patched, {
+ config: { generation_devices: 'auto', image_subfolder_strategy: 'date', max_queue_history: null },
+ set_fields: ['image_subfolder_strategy'],
+ });
+ assert.equal((await getJson(backend, '/api/v1/image_moves/status')).needs_move_count, 1_000);
+
+ const invalid = await fetch(`${backend.origin}/api/v1/app/runtime_config`, {
+ body: JSON.stringify({ image_subfolder_strategy: 'by_user' }),
+ headers: { 'content-type': 'application/json' },
+ method: 'PATCH',
+ });
+
+ assert.equal(invalid.status, 422);
+ });
+});
+
+test('image storage: a move runs across status polls, commits, and refuses to overlap', async () => {
+ await withRepresentativeBackend(async (backend) => {
+ await getJson(backend, '/api/v1/app/runtime_config', {
+ body: JSON.stringify({ image_subfolder_strategy: 'hash' }),
+ headers: { 'content-type': 'application/json' },
+ method: 'PATCH',
+ });
+ await clearQueue(backend);
+
+ const accepted = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' });
+
+ assert.equal(accepted.status, 202);
+ assert.deepEqual(await accepted.json(), {
+ active_job_id: 1,
+ is_running: true,
+ last_error: null,
+ latest_job: { error_message: null, id: 1, state: 'moving' },
+ needs_move_count: 1_000,
+ operation: 'move_all',
+ });
+
+ const overlapping = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' });
+
+ assert.equal(overlapping.status, 409);
+
+ const inProgress = await getJson(backend, '/api/v1/image_moves/status');
+
+ assert.equal(inProgress.is_running, true);
+ assert.equal(inProgress.operation, 'move_all');
+
+ // Like the real maintenance guard: image files and media mutations refuse while the move runs, listings do not.
+ const listed = await getJson(backend, '/api/v1/images/?limit=1&offset=0');
+ const imageName = listed.items[0].image_name;
+ const thumbnail = await fetch(`${backend.origin}/api/v1/images/i/${imageName}/thumbnail`);
+
+ assert.equal(thumbnail.status, 409);
+ assert.deepEqual(await thumbnail.json(), { detail: 'Image storage maintenance is active' });
+ assert.equal((await fetch(`${backend.origin}/api/v1/images/i/${imageName}`)).status, 200);
+ assert.equal(
+ (
+ await fetch(`${backend.origin}/api/v1/images/star`, {
+ body: JSON.stringify({ image_names: [imageName] }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ })
+ ).status,
+ 409
+ );
+ assert.equal((await fetch(`${backend.origin}/api/v1/board_images/`)).status, 409);
+ assert.equal((await fetch(`${backend.origin}/api/v1/boards/?all=true`)).status, 200);
+ assert.equal((await enqueueBatch(backend)).status, 409);
+
+ assert.deepEqual(await getJson(backend, '/api/v1/image_moves/status'), {
+ active_job_id: null,
+ is_running: false,
+ last_error: null,
+ latest_job: { error_message: null, id: 1, state: 'committed' },
+ needs_move_count: 0,
+ operation: null,
+ });
+ assert.equal((await fetch(`${backend.origin}/api/v1/images/i/${imageName}/thumbnail`)).status, 200);
+ });
+});
+
+test('image storage: an instant move commits inside the POST, and a failed job counts as finished', async () => {
+ await withRepresentativeBackend(async (backend) => {
+ await getJson(backend, '/api/v1/app/runtime_config', {
+ body: JSON.stringify({ image_subfolder_strategy: 'type' }),
+ headers: { 'content-type': 'application/json' },
+ method: 'PATCH',
+ });
+ await getJson(backend, '/__faults?imageMoves=instant', { method: 'POST' });
+ await clearQueue(backend);
+
+ const accepted = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' });
+
+ assert.equal(accepted.status, 202);
+ assert.deepEqual(await accepted.json(), {
+ active_job_id: null,
+ is_running: false,
+ last_error: null,
+ latest_job: { error_message: null, id: 1, state: 'committed' },
+ needs_move_count: 0,
+ operation: null,
+ });
+
+ await getJson(backend, '/__faults?imageMoves=failed', { method: 'POST' });
+
+ const failed = await getJson(backend, '/api/v1/image_moves/status');
+
+ assert.equal(failed.active_job_id, null);
+ assert.equal(failed.is_running, false);
+ assert.equal(failed.latest_job.state, 'error');
+ assert.equal(failed.latest_job.error_message, 'Disk full while moving images');
+ assert.equal(failed.last_error, 'Disk full while moving images');
+ // A finished job needs no recovery, so another move may start.
+ assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' })).status, 202);
+ });
+});
+
+test('image storage: Start Move waits for an idle queue, and recovery does not', async () => {
+ await withRepresentativeBackend(async (backend) => {
+ await getJson(backend, '/api/v1/app/runtime_config', {
+ body: JSON.stringify({ image_subfolder_strategy: 'date' }),
+ headers: { 'content-type': 'application/json' },
+ method: 'PATCH',
+ });
+ await clearQueue(backend);
+ assert.equal((await enqueueBatch(backend)).status, 201);
+
+ // A queued item that has not run yet is active work, as `has_active_queue_work` counts it.
+ const refused = await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' });
+
+ assert.equal(refused.status, 409);
+ assert.deepEqual(await refused.json(), { detail: 'Cannot start image move while queue work is active' });
+ assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/recover`, { method: 'POST' })).status, 202);
+ await getJson(backend, '/api/v1/image_moves/status');
+ assert.equal((await getJson(backend, '/api/v1/image_moves/status')).is_running, false);
+
+ await clearQueue(backend);
+
+ assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' })).status, 202);
+ });
+});
+
+test('image storage: faults seed an interrupted job for recovery and a non-admin caller', async () => {
+ await withRepresentativeBackend(async (backend) => {
+ await getJson(backend, '/__faults?imageMoves=needs-recovery', { method: 'POST' });
+
+ const interrupted = await getJson(backend, '/api/v1/image_moves/status');
+
+ assert.equal(interrupted.active_job_id, 1);
+ assert.equal(interrupted.is_running, false);
+ assert.equal(interrupted.last_error, 'Server stopped during an image move');
+ assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/start`, { method: 'POST' })).status, 409);
+
+ // A job awaiting recovery keeps maintenance active, so the guard still refuses image files and enqueueing.
+ const imageName = (await getJson(backend, '/api/v1/images/?limit=1&offset=0')).items[0].image_name;
+
+ assert.equal((await fetch(`${backend.origin}/api/v1/images/i/${imageName}/thumbnail`)).status, 409);
+ assert.equal((await enqueueBatch(backend)).status, 409);
+
+ const recovering = await fetch(`${backend.origin}/api/v1/image_moves/recover`, { method: 'POST' });
+
+ assert.equal(recovering.status, 202);
+ assert.equal((await recovering.json()).operation, 'recovery');
+ await getJson(backend, '/api/v1/image_moves/status');
+
+ const recovered = await getJson(backend, '/api/v1/image_moves/status');
+
+ assert.equal(recovered.active_job_id, null);
+ assert.equal(recovered.is_running, false);
+ assert.equal(recovered.last_error, null);
+ assert.equal(recovered.latest_job.state, 'committed');
+ assert.equal((await fetch(`${backend.origin}/api/v1/images/i/${imageName}/thumbnail`)).status, 200);
+ assert.equal((await enqueueBatch(backend)).status, 201);
+
+ await getJson(backend, '/__faults?intermediatesCaller=user', { method: 'POST' });
+
+ assert.equal((await fetch(`${backend.origin}/api/v1/image_moves/status`)).status, 403);
+ assert.equal(
+ (
+ await fetch(`${backend.origin}/api/v1/app/runtime_config`, {
+ body: JSON.stringify({ image_subfolder_strategy: 'date' }),
+ headers: { 'content-type': 'application/json' },
+ method: 'PATCH',
+ })
+ ).status,
+ 403
+ );
+
+ await getJson(backend, '/__reset', { method: 'POST' });
+
+ assert.equal((await getJson(backend, '/api/v1/image_moves/status')).active_job_id, null);
+ });
+});
+
+test('enqueue_batch accepts a utility graph as pending queue items with its origin', async () => {
+ await withRepresentativeBackend(async (backend) => {
+ const before = (await getJson(backend, '/api/v1/queue/default/item_ids')).total_count;
+ const response = await fetch(`${backend.origin}/api/v1/queue/default/enqueue_batch`, {
+ body: JSON.stringify({
+ batch: { graph: { edges: [], id: 'pbr', nodes: {} }, origin: 'utility:pbr-1', runs: 1 },
+ prepend: false,
+ }),
+ headers: { 'content-type': 'application/json' },
+ method: 'POST',
+ });
+
+ assert.equal(response.status, 201);
+
+ const enqueued = await response.json();
+
+ assert.equal(enqueued.enqueued, 1);
+ assert.equal(enqueued.item_ids.length, 1);
+
+ const item = await getJson(backend, `/api/v1/queue/default/i/${String(enqueued.item_ids[0])}`);
+
+ assert.equal(item.origin, 'utility:pbr-1');
+ assert.equal(item.status, 'pending');
+ assert.equal((await getJson(backend, '/api/v1/queue/default/item_ids')).total_count, before + 1);
+ });
+});
+
test('shared boards answer as the backend role table does', async () => {
const backend = await startMockBackend(0, { profile: 'representative' });
const call = (path, init) =>
@@ -1492,10 +1739,10 @@ test('control model seed sets cover both preprocessor id forms, and a PATCH chan
});
});
-test('runtime config PATCH keeps the other writable field and validates the queue history limit', async () => {
+test('runtime config PATCH preserves other writable fields and validates the queue history limit', async () => {
await withRepresentativeBackend(async (backend) => {
await getJson(backend, '/api/v1/app/runtime_config', {
- body: JSON.stringify({ generation_devices: ['cpu'] }),
+ body: JSON.stringify({ generation_devices: ['cpu'], image_subfolder_strategy: 'date' }),
method: 'PATCH',
});
const withHistory = await getJson(backend, '/api/v1/app/runtime_config', {
@@ -1503,7 +1750,11 @@ test('runtime config PATCH keeps the other writable field and validates the queu
method: 'PATCH',
});
- assert.deepEqual(withHistory.config, { generation_devices: ['cpu'], max_queue_history: 0 });
+ assert.deepEqual(withHistory.config, {
+ generation_devices: ['cpu'],
+ image_subfolder_strategy: 'date',
+ max_queue_history: 0,
+ });
const invalid = await fetch(`${backend.origin}/api/v1/app/runtime_config`, {
body: JSON.stringify({ max_queue_history: -1 }),
diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs
index 39f64511940..8ddbf05ae95 100644
--- a/invokeai/frontend/webv2/scripts/mock-backend.mjs
+++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs
@@ -79,12 +79,130 @@ const ARCHITECTURE_CAPABILITIES = JSON.parse(
* multiuser-admin, until POST /__reset; independent of workload profile. `user` and `multiuser-admin` also turn on
* multi-user auth, with a non-admin or an admin session: sign in with any email and password (or reload a page that
* already holds the token). `sharedBoards: true` adds boards other accounts own (`SHARED_BOARD_FIXTURES`), which the
- * caller sees through general access, a membership, or admin rights.
+ * caller sees through general access, a membership, or admin rights. `user` also makes the admin-only image storage
+ * routes answer 403. `imageMoves` seeds the image move journal: `idle` (the
+ * default), `needs-recovery` (an interrupted job that only Recover can finish), `failed` (a job that ended in error,
+ * which counts as finished) or `instant` (a move so small it commits before the POST answers, so `is_running` is
+ * already false). Like the real service, Start Move is refused while any queue item is pending or in progress (the
+ * representative profile seeds some; clear the queue first). While a move runs or a job awaits recovery, image files,
+ * media mutations and enqueueing answer 409 like the real maintenance guard; listings stay readable.
*/
const CAPABILITY_FAULTS = new Set(['ok', 'error', 'empty']);
const INTERMEDIATES_CALLERS = new Set(['admin', 'user', 'multiuser-admin']);
+const IMAGE_MOVE_FAULTS = new Set(['idle', 'needs-recovery', 'failed', 'instant']);
+const IMAGE_MOVE_MAINTENANCE_DETAIL = 'Image storage maintenance is active';
+/** The job states the real service counts as finished; any other journaled job still needs recovery. */
+const FINISHED_IMAGE_MOVE_JOB_STATES = new Set(['committed', 'error']);
+const IMAGE_STORAGE_LAYOUTS = new Set(['flat', 'date', 'type', 'hash']);
+/** Status reads a running move or recovery takes to finish, so polling clients observe it in progress. */
+const IMAGE_MOVE_TICKS = 2;
-const createFaults = () => ({ capabilities: 'ok', intermediatesCaller: 'admin' });
+const createFaults = () => ({ capabilities: 'ok', imageMoves: 'idle', intermediatesCaller: 'admin' });
+
+/**
+ * Every fixture image is stored flat, so the move count is the whole library whenever the configured layout
+ * differs from the one the last committed move left the files in.
+ */
+const createImageStorage = () => ({
+ layout: 'flat',
+ storedLayout: 'flat',
+ job: null,
+ lastError: null,
+ nextJobId: 1,
+ running: null,
+});
+
+const imageMoveStatus = (state) => {
+ const storage = state.imageStorage;
+
+ return {
+ active_job_id:
+ storage.job !== null && !FINISHED_IMAGE_MOVE_JOB_STATES.has(storage.job.state) ? storage.job.id : null,
+ is_running: storage.running !== null,
+ last_error: storage.lastError,
+ latest_job: storage.job === null ? null : { ...storage.job },
+ needs_move_count: storage.layout === storage.storedLayout ? 0 : state.images.size,
+ operation: storage.running?.operation ?? null,
+ };
+};
+
+const seedInterruptedImageMove = (state) => {
+ const storage = state.imageStorage;
+
+ storage.job = { error_message: null, id: storage.nextJobId, state: 'moving' };
+ storage.nextJobId += 1;
+ storage.lastError = 'Server stopped during an image move';
+};
+
+const seedFailedImageMove = (state) => {
+ const storage = state.imageStorage;
+
+ storage.job = { error_message: 'Disk full while moving images', id: storage.nextJobId, state: 'error' };
+ storage.nextJobId += 1;
+ storage.lastError = 'Disk full while moving images';
+};
+
+const commitImageMove = (state) => {
+ const storage = state.imageStorage;
+
+ if (storage.running.operation === 'move_all') {
+ storage.storedLayout = storage.layout;
+ }
+ if (storage.job !== null) {
+ storage.job = { ...storage.job, state: 'committed' };
+ }
+ storage.running = null;
+};
+
+/** An `instant` move commits before the POST answers, as a tiny library does on the real server. */
+const startImageMoveOperation = (state, operation, { instant = false } = {}) => {
+ const storage = state.imageStorage;
+
+ if (operation === 'move_all') {
+ storage.job = { error_message: null, id: storage.nextJobId, state: 'moving' };
+ storage.nextJobId += 1;
+ }
+ storage.lastError = null;
+ storage.running = { operation, ticks: IMAGE_MOVE_TICKS };
+ if (instant) {
+ commitImageMove(state);
+ }
+};
+
+/** Each status read advances a running operation; it commits on the last tick. */
+const tickImageMove = (state) => {
+ const storage = state.imageStorage;
+
+ if (storage.running === null) {
+ return;
+ }
+ storage.running.ticks -= 1;
+ if (storage.running.ticks === 0) {
+ commitImageMove(state);
+ }
+};
+
+/** Mirrors `is_maintenance_active`: a running operation, or a journaled job that has not finished. */
+const isImageMoveMaintenanceActive = (state) =>
+ state.imageStorage.running !== null || imageMoveStatus(state).active_job_id !== null;
+
+/** Mirrors `has_active_queue_work`, which gates Start Move: any queue item pending or in progress. */
+const hasActiveQueueWork = (state) =>
+ [...state.queueItems.values()].some((item) => item.status === 'pending' || item.status === 'in_progress');
+
+/**
+ * The real maintenance guard covers image uploads, deletions, edits, star changes, copies, downloads, image files
+ * and board-image membership, plus deleting a board with its images; listings and DTO reads stay open.
+ */
+const isImageMoveMaintenanceRoute = (method, path, url) => {
+ if (path.startsWith('/api/v1/board_images')) {
+ return true;
+ }
+ if (path.startsWith('/api/v1/images')) {
+ return method !== 'GET' || /^\/api\/v1\/images\/i\/[^/]+\/(full|thumbnail|workflow)$/.test(path);
+ }
+ return method === 'DELETE' && path.startsWith('/api/v1/boards/') && url.searchParams.get('include_images') === 'true';
+};
const isAdminCaller = (faults) => faults.intermediatesCaller !== 'user';
const isMultiuserCaller = (faults) => faults.intermediatesCaller !== 'admin';
@@ -346,6 +464,7 @@ const createState = (profile) => {
]),
externalProviders: createExternalProviderConfigs(fixture.models),
images: new Map(fixture.images.map((image) => [image.image_name, clone(image)])),
+ imageStorage: createImageStorage(),
models: new Map(fixture.models.map((model) => [model.key, clone(model)])),
/** What re-identify fills in per model key; only `POST /__models` seeds entries. */
modelProbes: new Map(),
@@ -1111,6 +1230,7 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
const body = await readJsonBody(request);
const requested = url.searchParams.get('capabilities') ?? body.capabilities;
const caller = url.searchParams.get('intermediatesCaller') ?? body.intermediatesCaller;
+ const imageMoves = url.searchParams.get('imageMoves') ?? body.imageMoves;
if (body.sharedBoards === true && !state.boards.has(SHARED_BOARD_FIXTURES[0].board_id)) {
seedSharedBoards(state);
@@ -1131,6 +1251,21 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
faults.intermediatesCaller = caller;
}
+
+ if (imageMoves !== undefined && imageMoves !== null) {
+ if (!IMAGE_MOVE_FAULTS.has(imageMoves)) {
+ return json(400, { detail: `Unknown image moves fault: ${String(imageMoves)}` });
+ }
+
+ faults.imageMoves = imageMoves;
+ state.imageStorage = createImageStorage();
+ if (imageMoves === 'needs-recovery') {
+ seedInterruptedImageMove(state);
+ }
+ if (imageMoves === 'failed') {
+ seedFailedImageMove(state);
+ }
+ }
}
return json(200, { ok: true, faults: { ...faults } });
@@ -1256,26 +1391,85 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
}
if (path === '/api/v1/app/runtime_config') {
- if (method === 'PATCH') {
- const changes = await readJsonBody(request);
+ const runtimeConfig = () => ({
+ config: { ...state.runtimeConfig, image_subfolder_strategy: state.imageStorage.layout },
+ set_fields: state.imageStorage.layout === 'flat' ? [] : ['image_subfolder_strategy'],
+ });
- if ('generation_devices' in changes) {
- state.runtimeConfig.generation_devices = changes.generation_devices;
+ if (method === 'GET') {
+ return json(200, runtimeConfig());
+ }
+ if (method === 'PATCH') {
+ if (!isAdminCaller(faults)) {
+ return json(403, { detail: 'Admin privileges required' });
}
- if ('max_queue_history' in changes) {
- const value = changes.max_queue_history;
+
+ const body = await readJsonBody(request);
+
+ if ('max_queue_history' in body) {
+ const value = body.max_queue_history;
if (value !== null && !(Number.isInteger(value) && value >= 0)) {
return json(422, { detail: 'max_queue_history must be a non-negative integer or null' });
}
- state.runtimeConfig.max_queue_history = value;
}
+ if ('image_subfolder_strategy' in body) {
+ if (!IMAGE_STORAGE_LAYOUTS.has(body.image_subfolder_strategy)) {
+ return json(422, { detail: 'image_subfolder_strategy must be one of flat, date, type, hash' });
+ }
+
+ state.imageStorage.layout = body.image_subfolder_strategy;
+ }
+ if ('generation_devices' in body) {
+ state.runtimeConfig.generation_devices = body.generation_devices;
+ }
+ if ('max_queue_history' in body) {
+ state.runtimeConfig.max_queue_history = body.max_queue_history;
+ }
+
+ return json(200, runtimeConfig());
+ }
+ }
+
+ if (path.startsWith('/api/v1/image_moves/')) {
+ if (!isAdminCaller(faults)) {
+ return json(403, { detail: 'Admin privileges required' });
+ }
+
+ const storage = state.imageStorage;
+
+ if (method === 'GET' && path === '/api/v1/image_moves/status') {
+ tickImageMove(state);
+ return json(200, imageMoveStatus(state));
+ }
+ if (method === 'POST' && path === '/api/v1/image_moves/start') {
+ if (storage.running !== null) {
+ return json(409, { detail: 'An image move job is already running' });
+ }
+ if (imageMoveStatus(state).active_job_id !== null) {
+ return json(409, { detail: 'An image move job is already active' });
+ }
+ if (hasActiveQueueWork(state)) {
+ return json(409, { detail: 'Cannot start image move while queue work is active' });
+ }
+
+ startImageMoveOperation(state, 'move_all', { instant: faults.imageMoves === 'instant' });
+ return json(202, imageMoveStatus(state));
}
- if (method === 'GET' || method === 'PATCH') {
- return json(200, { config: { ...state.runtimeConfig }, set_fields: [] });
+ if (method === 'POST' && path === '/api/v1/image_moves/recover') {
+ if (storage.running !== null) {
+ return json(409, { detail: 'An image move job is already running' });
+ }
+
+ startImageMoveOperation(state, 'recovery', { instant: faults.imageMoves === 'instant' });
+ return json(202, imageMoveStatus(state));
}
}
+ if (isImageMoveMaintenanceActive(state) && isImageMoveMaintenanceRoute(method, path, url)) {
+ return json(409, { detail: IMAGE_MOVE_MAINTENANCE_DETAIL });
+ }
+
if (path.startsWith('/api/v1/app/invocation_cache')) {
const cache = state.invocationCache;
@@ -1734,6 +1928,55 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => {
const queueMatch = /^\/api\/v1\/queue\/[^/]+\/(.+)$/.exec(path);
if (queueMatch) {
const action = queueMatch[1];
+
+ if (action === 'enqueue_batch' && method === 'POST') {
+ if (isImageMoveMaintenanceActive(state)) {
+ return json(409, { detail: IMAGE_MOVE_MAINTENANCE_DETAIL });
+ }
+
+ const { batch = {} } = await readJsonBody(request);
+ const runs = Number.isInteger(batch.runs) && batch.runs > 0 ? batch.runs : 1;
+ const now = timestamp(state);
+ const batchId = `mock-batch-${String(state.mutationClock)}`;
+ const itemIds = [];
+
+ // Items stay pending: the mock has no processor and no socket, so nothing ever completes them.
+ for (let run = 0; run < runs; run += 1) {
+ const itemId = Math.max(0, ...state.queueItems.keys()) + 1;
+
+ itemIds.push(itemId);
+ state.queueItems.set(itemId, {
+ batch_id: batchId,
+ completed_at: null,
+ created_at: now,
+ destination: batch.destination ?? null,
+ error_message: null,
+ error_traceback: null,
+ error_type: null,
+ field_values: null,
+ item_id: itemId,
+ origin: batch.origin ?? null,
+ retried_from_item_id: null,
+ session: { prepared_source_mapping: {}, results: {} },
+ session_id: `mock-session-${String(itemId)}`,
+ started_at: null,
+ status: 'pending',
+ updated_at: now,
+ user_display_name: 'Fixture User',
+ user_email: 'fixture@example.test',
+ user_id: MOCK_USER_ID,
+ });
+ }
+
+ return json(201, {
+ batch: { ...batch, batch_id: batchId },
+ enqueued: itemIds.length,
+ item_ids: itemIds,
+ priority: 0,
+ queue_id: 'default',
+ });
+ }
+
const scopedItems = queueItemsForScope(state, url);
if (action === 'status') {
diff --git a/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts b/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts
index 812832d1ce7..d19c4dc5450 100644
--- a/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts
+++ b/invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts
@@ -225,6 +225,7 @@ describe('feature public-interface registry', () => {
expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/workflow/queries')).toEqual([]);
expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/workflow/paletteSearch')).toEqual([]);
expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/gallery/paletteSearch')).toEqual([]);
+ expect(checkDependency('workbench/settings/ImageStorageSettings.tsx', '@features/gallery/storage')).toEqual([]);
expect(checkDependency('workbench/palette/paletteProviders.ts', '@features/queue/queries')).toEqual([]);
expect(checkDependency('workbench/palette/WorkbenchCommandPaletteDialog.tsx', '@features/queue/queries')).toEqual(
[]
diff --git a/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts b/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts
index e76f3d9bd87..4fae8e20539 100644
--- a/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts
+++ b/invokeai/frontend/webv2/src/architecture/featureInterfaces.ts
@@ -12,6 +12,7 @@ export const FEATURE_PUBLIC_INTERFACES: Readonly Promise>();
+const requestedUrls = () => fetchMock.mock.calls.map(([url]) => url);
+
+const answer = (status: number, contentType = 'image/webp') =>
+ new Response(status === 200 ? 'image' : null, { headers: { 'content-type': contentType }, status });
+const thumbnail = (index: number) => `/api/v1/images/i/image-${String(index)}.png/thumbnail`;
+
+describe('Gallery image recovery probe', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ fakeDocument = new FakeDocument();
+ serverStatus = 409;
+ fetchMock.mockReset().mockImplementation(() => Promise.resolve(answer(serverStatus)));
+ vi.stubGlobal('document', fakeDocument);
+ vi.stubGlobal('fetch', fetchMock);
+ // A fresh account lifetime per test: it also drops whatever probe the previous test left.
+ accountLifecycle.activate('image-recovery-user');
+ });
+
+ afterEach(() => {
+ accountLifecycle.invalidate();
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ it('asks about one failed image per interval for as long as a move runs, then reloads every failed image once', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ const listener = vi.fn();
+ const unsubscribe = subscribeGalleryImageRecovery(listener);
+
+ // A screen of tiles fails at once, some of them twice.
+ for (let index = 0; index < 40; index += 1) {
+ reportGalleryImageFailure(thumbnail(index), 0);
+ }
+ reportGalleryImageFailure(thumbnail(3), 0);
+
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(requestedUrls()).toEqual([thumbnail(0)]);
+
+ // 409: asked again 15 s later, then every 30 s, for as long as the move runs.
+ await vi.advanceTimersByTimeAsync(15 * SECOND - 1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ for (let probe = 3; probe <= 22; probe += 1) {
+ await vi.advanceTimersByTimeAsync(30 * SECOND - 1);
+ expect(fetchMock).toHaveBeenCalledTimes(probe - 1);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(fetchMock).toHaveBeenCalledTimes(probe);
+ }
+ expect(new Set(requestedUrls())).toEqual(new Set([thumbnail(0)]));
+ expect(getGalleryImageRecoveryRevision()).toBe(revision);
+ // The probe reads the status itself, uncached, under the account's lifetime.
+ expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ cache: 'no-store' });
+
+ // The move ends: the next probe sees the image served and every failed image reloads, once.
+ serverStatus = 200;
+ await vi.advanceTimersByTimeAsync(30 * SECOND);
+ expect(fetchMock).toHaveBeenCalledTimes(23);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 1);
+ expect(listener).toHaveBeenCalledOnce();
+
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+ expect(fetchMock).toHaveBeenCalledTimes(23);
+ expect(vi.getTimerCount()).toBe(0);
+ unsubscribe();
+ });
+
+ it('stops probing an image the server says is missing', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ serverStatus = 404;
+ reportGalleryImageFailure(thumbnail(1), 0);
+
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(requestedUrls()).toEqual([thumbnail(1)]);
+
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(vi.getTimerCount()).toBe(0);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision);
+ });
+
+ it('drops a missing image and goes on to the next failed one', async () => {
+ fetchMock.mockImplementation((url) => Promise.resolve(answer(url === thumbnail(1) ? 404 : 409)));
+ reportGalleryImageFailure(thumbnail(1), 0);
+ reportGalleryImageFailure(thumbnail(2), 0);
+
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(requestedUrls()).toEqual([thumbnail(1), thumbnail(2)]);
+
+ await vi.advanceTimersByTimeAsync(15 * SECOND);
+ expect(requestedUrls()).toEqual([thumbnail(1), thumbnail(2), thumbnail(2)]);
+ });
+
+ it('does not take a page served in place of the image for the image', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ fetchMock.mockImplementation(() => Promise.resolve(answer(200, 'text/html')));
+ reportGalleryImageFailure(thumbnail(1), 0);
+
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(getGalleryImageRecoveryRevision()).toBe(revision);
+ });
+
+ it('does not reload again an image that still fails while the server serves it', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ serverStatus = 200;
+ reportGalleryImageFailure(thumbnail(1), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 1);
+
+ // The reloaded image fails again (a broken file, say) although the server answers 200 for it.
+ reportGalleryImageFailure(thumbnail(1), revision + 1);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 1);
+
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('recovers a reloaded image again when a later move refuses it', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ serverStatus = 200;
+ reportGalleryImageFailure(thumbnail(1), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 1);
+
+ // A second move refuses the reloaded image; it recovers when that move ends too.
+ serverStatus = 409;
+ reportGalleryImageFailure(thumbnail(1), revision + 1);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ serverStatus = 200;
+ await vi.advanceTimersByTimeAsync(15 * SECOND);
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 2);
+ });
+
+ it('keeps waiting through a server restart mid-move, then reloads every failed image once', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ const listener = vi.fn();
+ const unsubscribe = subscribeGalleryImageRecovery(listener);
+ // The server goes down mid-move (connections refused), comes back unavailable, resumes the move, then serves.
+ const answers: (() => Promise)[] = [
+ () => Promise.reject(new TypeError('Failed to fetch')),
+ () => Promise.reject(new TypeError('Failed to fetch')),
+ () => Promise.reject(new TypeError('Failed to fetch')),
+ () => Promise.resolve(answer(503)),
+ () => Promise.resolve(answer(502)),
+ () => Promise.resolve(answer(409)),
+ () => Promise.resolve(answer(200)),
+ ];
+ fetchMock.mockImplementation(() => answers[fetchMock.mock.calls.length - 1]!());
+ for (let index = 0; index < 3; index += 1) {
+ reportGalleryImageFailure(thumbnail(index), 0);
+ }
+
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+
+ expect(fetchMock).toHaveBeenCalledTimes(answers.length);
+ // No answer drained the failed images: the one that kept being asked about recovered them all.
+ expect(new Set(requestedUrls())).toEqual(new Set([thumbnail(0)]));
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 1);
+ expect(listener).toHaveBeenCalledOnce();
+ expect(vi.getTimerCount()).toBe(0);
+ unsubscribe();
+ });
+
+ it('recovers an image that fails on its plain URL during a later short move', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ serverStatus = 200;
+ reportGalleryImageFailure(thumbnail(1), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 1);
+
+ // A short move runs; a tile mounted during it fails on the plain URL, and the move is over by the first probe.
+ reportGalleryImageFailure(thumbnail(1), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 2);
+ });
+
+ it('does not ask again after the account change aborts its request', async () => {
+ fetchMock.mockImplementation(
+ (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
+ })
+ );
+ reportGalleryImageFailure(thumbnail(1), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(fetchMock).toHaveBeenCalledOnce();
+
+ accountLifecycle.activate('image-recovery-other-user');
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it('pauses while the tab is hidden and asks as soon as it is shown', async () => {
+ fakeDocument.visibilityState = 'hidden';
+ reportGalleryImageFailure(thumbnail(1), 0);
+
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(vi.getTimerCount()).toBe(0);
+
+ fakeDocument.show();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(requestedUrls()).toEqual([thumbnail(1)]);
+ });
+
+ it('stops for good when the account changes, and forgets the old account’s images', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ reportGalleryImageFailure(thumbnail(1), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ const firstSignal = fetchMock.mock.calls[0]?.[1]?.signal;
+
+ accountLifecycle.activate('image-recovery-other-user');
+ expect(firstSignal?.aborted).toBe(true);
+ serverStatus = 200;
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(vi.getTimerCount()).toBe(0);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision);
+
+ // A failure in the new account starts its own probe, which never asks about the old account's image.
+ reportGalleryImageFailure(thumbnail(2), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+ expect(requestedUrls()).toEqual([thumbnail(1), thumbnail(2)]);
+ expect(getGalleryImageRecoveryRevision()).toBe(revision + 1);
+ });
+
+ it('stops a probe whose request is out when the account changes', async () => {
+ const revision = getGalleryImageRecoveryRevision();
+ let resolveAnswer!: (response: Response) => void;
+ fetchMock.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveAnswer = resolve;
+ })
+ );
+ reportGalleryImageFailure(thumbnail(1), 0);
+ await vi.advanceTimersByTimeAsync(2 * SECOND);
+
+ accountLifecycle.activate('image-recovery-other-user');
+ resolveAnswer(answer(200));
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+
+ expect(fetchMock).toHaveBeenCalledOnce();
+ expect(getGalleryImageRecoveryRevision()).toBe(revision);
+ });
+
+ it('ignores failures reported for an account lifetime that has ended', async () => {
+ const staleOwner = accountLifecycle.capture();
+ accountLifecycle.activate('image-recovery-other-user');
+
+ reportGalleryImageFailure(thumbnail(1), 0, staleOwner);
+ await vi.advanceTimersByTimeAsync(10 * 60 * SECOND);
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.ts
new file mode 100644
index 00000000000..3d6c98c45c6
--- /dev/null
+++ b/invokeai/frontend/webv2/src/features/gallery/data/imageRecovery.ts
@@ -0,0 +1,199 @@
+import type { AccountScope } from '@platform/state/accountLifecycle';
+
+import { captureAccountScope, isAccountScopeCurrent } from '@platform/state/accountLifecycle';
+
+/** Gathers the failures a screen of images reports together into one probe. */
+const GALLERY_IMAGE_PROBE_FIRST_DELAY_MS = 2_000;
+/**
+ * The waits between probes while the server is not serving images yet (409 during a move, or down while it
+ * restarts); the last repeats for as long as that lasts.
+ */
+const GALLERY_IMAGE_PROBE_BUSY_DELAYS_MS: readonly number[] = [15_000, 30_000];
+
+/** Probing for one account lifetime; dropped with it. */
+interface ImageRecoveryProbe {
+ /**
+ * Failed image URLs waiting for the server to serve images again, oldest first, each with the recovery revision
+ * its failed load carried (0 for the plain URL).
+ */
+ readonly failed: Map;
+ /** Waiting answers since the last success, which pick the next wait. */
+ busyAnswers: number;
+ readonly owner: AccountScope;
+ timer: ReturnType | null;
+ /** Set while a probe waits for the document to be shown. */
+ visibilityListener: (() => void) | null;
+ /** Set while a probe request is out. */
+ url: string | null;
+}
+
+let probe: ImageRecoveryProbe | null = null;
+let recoveryRevision = 0;
+const recoveryListeners = new Set<() => void>();
+
+/** Rises each time a probe sees the server serve a failed image again; images that failed before it reload. */
+export const getGalleryImageRecoveryRevision = (): number => recoveryRevision;
+
+export const subscribeGalleryImageRecovery = (listener: () => void): (() => void) => {
+ recoveryListeners.add(listener);
+ return () => recoveryListeners.delete(listener);
+};
+
+export const getRecoveredGalleryImageUrl = (url: string, revision: number): string => {
+ if (revision === 0) {
+ return url;
+ }
+
+ const recoveredUrl = new URL(url, window.location.href);
+ recoveredUrl.searchParams.set('gallery_image_recovery', String(revision));
+ return recoveredUrl.toString();
+};
+
+const clearWait = (current: ImageRecoveryProbe): void => {
+ if (current.timer !== null) {
+ clearTimeout(current.timer);
+ current.timer = null;
+ }
+ if (current.visibilityListener !== null) {
+ document.removeEventListener('visibilitychange', current.visibilityListener);
+ current.visibilityListener = null;
+ }
+};
+
+const dropProbe = (current: ImageRecoveryProbe): void => {
+ clearWait(current);
+ if (probe === current) {
+ probe = null;
+ }
+};
+
+const schedule = (current: ImageRecoveryProbe, delayMs: number): void => {
+ current.timer = setTimeout(() => {
+ current.timer = null;
+ // Paused while hidden: nobody sees the images, and the probe runs as soon as the tab is shown.
+ if (document.visibilityState === 'hidden') {
+ current.visibilityListener = () => {
+ if (document.visibilityState !== 'hidden') {
+ clearWait(current);
+ void runProbe(current);
+ }
+ };
+ document.addEventListener('visibilitychange', current.visibilityListener);
+ return;
+ }
+ void runProbe(current);
+ }, delayMs);
+};
+
+/** Statuses that mean the server cannot serve images yet: a move holds the files, or the server is restarting. */
+const WAITING_STATUSES = new Set([409, 502, 503, 504]);
+
+/**
+ * The probe's answer: `wait` while the server cannot serve images yet, `served` once it serves the image again,
+ * `other` for anything that will not change by waiting (missing, refused, or a page in place of the image).
+ */
+const readAnswer = async (url: string, signal: AbortSignal): Promise<'other' | 'served' | 'wait'> => {
+ let response: Response;
+
+ try {
+ response = await fetch(url, { cache: 'no-store', signal });
+ } catch {
+ // No answer at all: a server restarting mid-move refuses connections until it is back. An abort from the owner
+ // also lands here; its probe is already dropped and does not reschedule.
+ return 'wait';
+ }
+
+ // Only the answer matters; the image itself is read again by the elements that failed.
+ void response.body?.cancel().catch(() => undefined);
+ if (WAITING_STATUSES.has(response.status)) {
+ return 'wait';
+ }
+ // A page in place of the image (a proxy's fallback or sign-in page) is not the image served again.
+ return response.ok && response.headers.get('content-type')?.startsWith('image/') ? 'served' : 'other';
+};
+
+const runProbe = async (current: ImageRecoveryProbe): Promise => {
+ const [entry] = current.failed;
+
+ if (entry === undefined) {
+ return;
+ }
+
+ const [url, failedRevision] = entry;
+
+ current.url = url;
+ const answer = await readAnswer(url, current.owner.signal);
+ current.url = null;
+
+ if (probe !== current) {
+ return;
+ }
+
+ if (answer === 'wait') {
+ // A move or restart can last minutes and nothing announces its end to this tab. Whatever that image's earlier
+ // reloads did, this failure was the server's, so the next success reloads it.
+ const delays = GALLERY_IMAGE_PROBE_BUSY_DELAYS_MS;
+ current.busyAnswers += 1;
+ current.failed.set(url, 0);
+ schedule(current, delays[Math.min(current.busyAnswers, delays.length) - 1] ?? 0);
+ return;
+ }
+
+ // An image whose failed load already carried the current recovery revision failed after the server served it
+ // again: it is broken, not waiting, and another reload would not help.
+ const isFreshFailure = failedRevision === 0 || failedRevision < recoveryRevision;
+
+ if (answer === 'served' && isFreshFailure) {
+ current.failed.clear();
+ current.busyAnswers = 0;
+ recoveryRevision += 1;
+ recoveryListeners.forEach((listener) => listener());
+ return;
+ }
+
+ // Missing, refused for another reason, not an image, or served yet still failing after a reload: not waiting.
+ current.failed.delete(url);
+ if (current.failed.size > 0) {
+ schedule(current, GALLERY_IMAGE_PROBE_FIRST_DELAY_MS);
+ }
+};
+
+/**
+ * An image element failed to load `url`, carrying recovery `revision` (0 for the plain URL). An element cannot see
+ * why, and during an image move the server answers every image read with 409 for minutes. One probe per account
+ * lifetime asks for one failed URL at a time with a request that can see the status: while the server cannot serve
+ * images (409, 502-504, no connection) the probe keeps asking, paused while the tab is hidden; a success raises the
+ * recovery revision so every image that failed before it reloads once; any other answer drops that URL. An account
+ * change stops the probe and forgets its URLs.
+ */
+export const reportGalleryImageFailure = (
+ url: string,
+ revision: number,
+ owner: AccountScope = captureAccountScope()
+): void => {
+ if (!isAccountScopeCurrent(owner) || /^(?:blob|data):/i.test(url)) {
+ return;
+ }
+
+ // The previous lifetime's probe dropped itself when that lifetime ended.
+ if (probe?.owner !== owner) {
+ const created: ImageRecoveryProbe = {
+ busyAnswers: 0,
+ failed: new Map(),
+ owner,
+ timer: null,
+ url: null,
+ visibilityListener: null,
+ };
+ owner.signal.addEventListener('abort', () => dropProbe(created), { once: true });
+ probe = created;
+ }
+
+ // Elements sharing a URL may carry different revisions; a fresh failure among them must still recover.
+ const reported = probe.failed.get(url);
+ probe.failed.set(url, reported === undefined ? revision : Math.min(reported, revision));
+
+ if (probe.timer === null && probe.visibilityListener === null && probe.url === null) {
+ schedule(probe, GALLERY_IMAGE_PROBE_FIRST_DELAY_MS);
+ }
+};
diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts
new file mode 100644
index 00000000000..6471ba6fece
--- /dev/null
+++ b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.test.ts
@@ -0,0 +1,241 @@
+import { accountLifecycle } from '@platform/state/accountLifecycle';
+import { QueryClient, QueryObserver } from '@tanstack/react-query';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ apiFetchJson: vi.fn(),
+ invalidateGallery: vi.fn(() => Promise.resolve()),
+ refreshGalleryThumbnails: vi.fn(),
+}));
+
+vi.mock('@platform/transport/http', () => ({ apiFetchJson: mocks.apiFetchJson }));
+vi.mock('./queryCache', () => ({
+ invalidateGallery: mocks.invalidateGallery,
+ refreshGalleryThumbnails: mocks.refreshGalleryThumbnails,
+}));
+
+import {
+ imageMoveStatusQueryOptions,
+ imageStorageKeys,
+ imageStorageLayoutQueryOptions,
+ startImageMove,
+ startImageMoveRecovery,
+ updateImageStorageLayout,
+} from './imageStorage';
+
+const createDeferred = () => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+ return { promise, resolve };
+};
+
+const runningStatus = {
+ active_job_id: 3,
+ is_running: true,
+ last_error: null,
+ latest_job: { error_message: null, id: 3, state: 'moving' },
+ needs_move_count: 12,
+ operation: 'move_all',
+};
+
+const committedStatus = {
+ active_job_id: null,
+ is_running: false,
+ last_error: null,
+ latest_job: { error_message: null, id: 3, state: 'committed' },
+ needs_move_count: 0,
+ operation: null,
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ accountLifecycle.activate('admin');
+});
+
+describe('image storage layout', () => {
+ it('reads the layout from the nested runtime config and keeps unknown server values', async () => {
+ mocks.apiFetchJson.mockResolvedValueOnce({ config: { image_subfolder_strategy: 'date' }, set_fields: [] });
+ await expect(new QueryClient().fetchQuery(imageStorageLayoutQueryOptions())).resolves.toBe('date');
+ expect(mocks.apiFetchJson).toHaveBeenCalledWith('/api/v1/app/runtime_config', expect.anything());
+
+ mocks.apiFetchJson.mockResolvedValueOnce({ config: { image_subfolder_strategy: 'by_user' } });
+ await expect(new QueryClient().fetchQuery(imageStorageLayoutQueryOptions())).resolves.toBe('by_user');
+
+ mocks.apiFetchJson.mockResolvedValueOnce({ config: {} });
+ await expect(new QueryClient().fetchQuery(imageStorageLayoutQueryOptions())).resolves.toBe('flat');
+ });
+
+ it('patches only the layout field, caches the answer, and refreshes the move count', async () => {
+ const client = new QueryClient();
+ const owner = accountLifecycle.capture();
+ client.setQueryData(imageStorageKeys.moveStatus(owner), { needsMoveCount: 0 });
+ mocks.apiFetchJson.mockResolvedValueOnce({ config: { image_subfolder_strategy: 'hash' } });
+
+ await updateImageStorageLayout(client, 'hash', owner);
+
+ expect(mocks.apiFetchJson).toHaveBeenCalledExactlyOnceWith(
+ '/api/v1/app/runtime_config',
+ expect.objectContaining({ body: JSON.stringify({ image_subfolder_strategy: 'hash' }), method: 'PATCH' })
+ );
+ expect(client.getQueryData(imageStorageKeys.layout(owner))).toBe('hash');
+ expect(client.getQueryState(imageStorageKeys.moveStatus(owner))?.isInvalidated).toBe(true);
+ });
+});
+
+describe('image move status', () => {
+ it('maps the wire status to the read model', async () => {
+ mocks.apiFetchJson.mockResolvedValueOnce(runningStatus);
+
+ await expect(new QueryClient().fetchQuery(imageMoveStatusQueryOptions())).resolves.toEqual({
+ activeJobId: 3,
+ isRunning: true,
+ lastError: null,
+ latestJob: { errorMessage: null, id: 3, state: 'moving' },
+ needsMoveCount: 12,
+ operation: 'move_all',
+ });
+ });
+
+ it('polls while running and refreshes the gallery on the read that sees the job stop', async () => {
+ const client = new QueryClient();
+ const owner = accountLifecycle.capture();
+ const options = imageMoveStatusQueryOptions(owner);
+
+ mocks.apiFetchJson.mockResolvedValueOnce(runningStatus);
+ const running = await client.fetchQuery(options);
+ expect(running.isRunning).toBe(true);
+ expect(options.refetchInterval).toBeTypeOf('function');
+ const interval = options.refetchInterval as (query: { state: { data?: unknown } }) => number | false;
+ expect(interval({ state: { data: running } })).toBe(2_000);
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+
+ mocks.apiFetchJson.mockResolvedValueOnce(committedStatus);
+ const committed = await client.fetchQuery({ ...options, staleTime: 0 });
+ expect(committed.isRunning).toBe(false);
+ expect(interval({ state: { data: committed } })).toBe(false);
+ expect(mocks.invalidateGallery).toHaveBeenCalledExactlyOnceWith(client, owner);
+ // Thumbnails the move answered with 409 are asked for again as well.
+ expect(mocks.refreshGalleryThumbnails).toHaveBeenCalledExactlyOnceWith(owner);
+
+ // An idle read after an idle read is not a job ending.
+ mocks.apiFetchJson.mockResolvedValueOnce(committedStatus);
+ await client.fetchQuery({ ...options, staleTime: 0 });
+ expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1);
+ });
+
+ it('seeds the status query with the accepted job so polling starts without a second read', async () => {
+ const client = new QueryClient();
+ const owner = accountLifecycle.capture();
+ mocks.apiFetchJson.mockResolvedValueOnce(runningStatus);
+
+ await expect(startImageMove(client, owner)).resolves.toMatchObject({ isRunning: true, operation: 'move_all' });
+
+ expect(mocks.apiFetchJson).toHaveBeenCalledExactlyOnceWith(
+ '/api/v1/image_moves/start',
+ expect.objectContaining({ method: 'POST' })
+ );
+ expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toMatchObject({ activeJobId: 3, isRunning: true });
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+ });
+
+ it('keeps the accepted status when a stale status read lands after the POST', async () => {
+ const client = new QueryClient();
+ const owner = accountLifecycle.capture();
+ const options = imageMoveStatusQueryOptions(owner);
+ const staleRead = createDeferred();
+ mocks.apiFetchJson.mockImplementation((path: string, init?: RequestInit) =>
+ init?.method === 'POST' ? Promise.resolve(runningStatus) : staleRead.promise
+ );
+
+ // A poll started before the POST is still in flight when the POST is accepted.
+ const pendingRead = client.fetchQuery(options).catch((error: unknown) => error);
+ await expect(startImageMove(client, owner)).resolves.toMatchObject({ isRunning: true });
+ expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toMatchObject({ isRunning: true });
+
+ staleRead.resolve(committedStatus);
+ await pendingRead;
+ await Promise.resolve();
+
+ // The idle answer the server gave before the job existed must not stop the polling or free the controls.
+ expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toMatchObject({ activeJobId: 3, isRunning: true });
+ expect(client.getQueryState(imageStorageKeys.moveStatus(owner))?.fetchStatus).toBe('idle');
+ });
+
+ it('refreshes the gallery at once when the accepted job already finished', async () => {
+ const client = new QueryClient();
+ const owner = accountLifecycle.capture();
+ mocks.apiFetchJson.mockResolvedValueOnce(committedStatus);
+
+ await expect(startImageMoveRecovery(client, owner)).resolves.toMatchObject({ isRunning: false });
+
+ expect(mocks.apiFetchJson).toHaveBeenCalledExactlyOnceWith(
+ '/api/v1/image_moves/recover',
+ expect.objectContaining({ method: 'POST' })
+ );
+ expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toMatchObject({ isRunning: false });
+ expect(mocks.invalidateGallery).toHaveBeenCalledExactlyOnceWith(client, owner);
+ expect(mocks.refreshGalleryThumbnails).toHaveBeenCalledExactlyOnceWith(owner);
+ });
+
+ it('re-reads the status after a refusal and rejects only once the fresh status is cached', async () => {
+ const client = new QueryClient();
+ const owner = accountLifecycle.capture();
+ const statusRead = createDeferred();
+ const refusal = new Error('{"detail":"An image move job is already running"}');
+ mocks.apiFetchJson.mockResolvedValueOnce(committedStatus);
+ // A mounted editor observes the status, so the invalidation refetches it.
+ const observer = new QueryObserver(client, imageMoveStatusQueryOptions(owner));
+ const unsubscribe = observer.subscribe(() => undefined);
+ await vi.waitFor(() => expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toBeDefined());
+ mocks.apiFetchJson.mockRejectedValueOnce(refusal).mockReturnValueOnce(statusRead.promise);
+
+ let settled = false;
+ const started = startImageMove(client, owner).finally(() => {
+ settled = true;
+ });
+ const outcome = started.catch((error: unknown) => error);
+
+ await vi.waitFor(() => expect(mocks.apiFetchJson).toHaveBeenCalledTimes(3));
+ await Promise.resolve();
+ // Until the re-read lands the caller keeps its controls locked against the stale idle status.
+ expect(settled).toBe(false);
+
+ statusRead.resolve(runningStatus);
+
+ await expect(outcome).resolves.toBe(refusal);
+ expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toMatchObject({ activeJobId: 3, isRunning: true });
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+ unsubscribe();
+ });
+
+ it('keeps the refusal when the status re-read fails too', async () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const owner = accountLifecycle.capture();
+ const refusal = new Error('{"detail":"Cannot start image move while queue work is active"}');
+ mocks.apiFetchJson.mockResolvedValueOnce(committedStatus);
+ const observer = new QueryObserver(client, imageMoveStatusQueryOptions(owner));
+ const unsubscribe = observer.subscribe(() => undefined);
+ await vi.waitFor(() => expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toBeDefined());
+ mocks.apiFetchJson.mockRejectedValueOnce(refusal).mockRejectedValueOnce(new Error('status unavailable'));
+
+ await expect(startImageMove(client, owner)).rejects.toBe(refusal);
+
+ expect(mocks.apiFetchJson).toHaveBeenCalledTimes(3);
+ expect(client.getQueryState(imageStorageKeys.moveStatus(owner))?.status).toBe('error');
+ unsubscribe();
+ });
+
+ it('drops a response that lands after the account changed', async () => {
+ const client = new QueryClient();
+ const owner = accountLifecycle.capture();
+ mocks.apiFetchJson.mockImplementationOnce(() => {
+ accountLifecycle.activate('someone-else');
+ return Promise.resolve(runningStatus);
+ });
+
+ await expect(startImageMove(client, owner)).rejects.toThrow();
+ expect(client.getQueryData(imageStorageKeys.moveStatus(owner))).toBeUndefined();
+ });
+});
diff --git a/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts
new file mode 100644
index 00000000000..1b3659b5acc
--- /dev/null
+++ b/invokeai/frontend/webv2/src/features/gallery/data/imageStorage.ts
@@ -0,0 +1,193 @@
+import type { AccountScope } from '@platform/state/accountLifecycle';
+import type { QueryClient } from '@tanstack/react-query';
+
+import {
+ assertAccountScopeCurrent,
+ captureAccountScope,
+ isAccountScopeCurrent,
+} from '@platform/state/accountLifecycle';
+import { apiFetchJson } from '@platform/transport/http';
+import { queryOptions } from '@tanstack/react-query';
+
+import { invalidateGallery, refreshGalleryThumbnails } from './queryCache';
+
+/**
+ * Server image storage: the subfolder layout new images are written to, and the admin-only move/recovery job
+ * that relocates existing files into it. Both routes are admin-only, so callers gate reads on the capability.
+ */
+
+export const IMAGE_STORAGE_LAYOUTS = ['flat', 'date', 'type', 'hash'] as const;
+
+export type ImageStorageLayout = (typeof IMAGE_STORAGE_LAYOUTS)[number];
+
+export const isImageStorageLayout = (value: unknown): value is ImageStorageLayout =>
+ IMAGE_STORAGE_LAYOUTS.includes(value as ImageStorageLayout);
+
+type ImageMoveOperation = 'move_all' | 'recovery';
+
+export type ImageMoveJobState = 'planned' | 'moving' | 'moved' | 'committed' | 'error';
+
+export interface ImageMoveStatus {
+ /** Whether a move or recovery is running right now. */
+ isRunning: boolean;
+ operation: ImageMoveOperation | null;
+ /** A journaled job that has not committed; with `isRunning` false it needs recovery. */
+ activeJobId: number | null;
+ latestJob: { id: number; state: ImageMoveJobState; errorMessage: string | null } | null;
+ /** The last background worker error. */
+ lastError: string | null;
+ /** Images still stored under a layout other than the configured one. */
+ needsMoveCount: number;
+}
+
+/** `GET/PATCH /app/runtime_config` nest the settings under `config`; only the layout field is read here. */
+interface RuntimeConfigDTO {
+ config?: { image_subfolder_strategy?: unknown } | null;
+}
+
+interface ImageMoveStatusDTO {
+ is_running: boolean;
+ operation?: ImageMoveOperation | null;
+ active_job_id?: number | null;
+ latest_job?: { id: number; state: ImageMoveJobState; error_message?: string | null } | null;
+ last_error?: string | null;
+ needs_move_count: number;
+}
+
+/** An unrecognised server value is kept, not coerced: the editor shows it as unknown instead of lying. */
+const readImageStorageLayout = (dto: RuntimeConfigDTO | null): string =>
+ typeof dto?.config?.image_subfolder_strategy === 'string' ? dto.config.image_subfolder_strategy : 'flat';
+
+const toImageMoveStatus = (dto: ImageMoveStatusDTO): ImageMoveStatus => ({
+ activeJobId: dto.active_job_id ?? null,
+ isRunning: dto.is_running,
+ lastError: dto.last_error ?? null,
+ latestJob: dto.latest_job
+ ? { errorMessage: dto.latest_job.error_message ?? null, id: dto.latest_job.id, state: dto.latest_job.state }
+ : null,
+ needsMoveCount: dto.needs_move_count,
+ operation: dto.operation ?? null,
+});
+
+const getAccountKey = (owner: AccountScope) => ({ accountId: owner.accountId, epoch: owner.epoch });
+
+export const imageStorageKeys = {
+ all: ['gallery', 'image-storage'] as const,
+ layout: (owner: AccountScope) => [...imageStorageKeys.all, 'layout', getAccountKey(owner)] as const,
+ moveStatus: (owner: AccountScope) => [...imageStorageKeys.all, 'move-status', getAccountKey(owner)] as const,
+};
+
+const fenced = (owner: AccountScope, request: (signal: AbortSignal) => Promise, signal: AbortSignal) =>
+ request(AbortSignal.any([signal, owner.signal])).then((result) => {
+ assertAccountScopeCurrent(owner);
+ return result;
+ });
+
+const getImageStorageLayout = (signal?: AbortSignal): Promise =>
+ apiFetchJson('/api/v1/app/runtime_config', { signal }).then(readImageStorageLayout);
+
+export const imageStorageLayoutQueryOptions = (owner = captureAccountScope()) =>
+ queryOptions({
+ queryFn: ({ signal }) => fenced(owner, getImageStorageLayout, signal),
+ queryKey: imageStorageKeys.layout(owner),
+ staleTime: 30_000,
+ });
+
+/** Takes effect for new images at once; existing files move only through {@link startImageMove}. */
+export const updateImageStorageLayout = async (
+ client: QueryClient,
+ layout: ImageStorageLayout,
+ owner = captureAccountScope()
+): Promise => {
+ const response = await apiFetchJson('/api/v1/app/runtime_config', {
+ body: JSON.stringify({ image_subfolder_strategy: layout }),
+ method: 'PATCH',
+ signal: owner.signal,
+ });
+
+ assertAccountScopeCurrent(owner);
+ client.setQueryData(imageStorageKeys.layout(owner), readImageStorageLayout(response));
+ // The count of images outside the layout changes with it.
+ await client.invalidateQueries({ queryKey: imageStorageKeys.moveStatus(owner) });
+};
+
+const getImageMoveStatus = (signal?: AbortSignal): Promise =>
+ apiFetchJson('/api/v1/image_moves/status', { signal }).then(toImageMoveStatus);
+
+/**
+ * The move relocated the files and answered thumbnail reads with 409 while it ran, so a gallery that saw it needs
+ * its listings and its mounted thumbnails read again.
+ */
+const refreshGalleryAfterMove = (client: QueryClient, owner: AccountScope): void => {
+ void invalidateGallery(client, owner);
+ refreshGalleryThumbnails(owner);
+};
+
+/**
+ * Polls while a job runs. The read that first sees a job stop refreshes the gallery, since the move changed
+ * where its files live, whichever consumer happens to hold the query at the time.
+ */
+export const imageMoveStatusQueryOptions = (owner = captureAccountScope()) =>
+ queryOptions({
+ queryFn: async ({ client, signal }) => {
+ const previous = client.getQueryData(imageStorageKeys.moveStatus(owner));
+ const status = await fenced(owner, getImageMoveStatus, signal);
+
+ if (previous?.isRunning && !status.isRunning) {
+ refreshGalleryAfterMove(client, owner);
+ }
+
+ return status;
+ },
+ queryKey: imageStorageKeys.moveStatus(owner),
+ refetchInterval: (query) => (query.state.data?.isRunning ? 2_000 : false),
+ // A move pauses the whole server; keep following it from a background tab so its end is not missed.
+ refetchIntervalInBackground: true,
+ staleTime: 5_000,
+ });
+
+const postImageMove = async (
+ client: QueryClient,
+ route: 'start' | 'recover',
+ owner: AccountScope
+): Promise => {
+ const queryKey = imageStorageKeys.moveStatus(owner);
+ let status: ImageMoveStatus;
+
+ try {
+ status = await apiFetchJson(`/api/v1/image_moves/${route}`, {
+ method: 'POST',
+ signal: owner.signal,
+ }).then(toImageMoveStatus);
+ } catch (error) {
+ // A refusal usually means the cached status is stale (another admin's job, finished queue work): re-read it
+ // before the caller unlocks, so the editor's controls follow what the server says now rather than the stale
+ // status. A failed re-read leaves the status query in error (which keeps the controls locked) and never
+ // replaces the refusal: invalidation does not reject on refetch errors.
+ if (isAccountScopeCurrent(owner)) {
+ await client.invalidateQueries({ queryKey });
+ }
+ throw error;
+ }
+
+ assertAccountScopeCurrent(owner);
+ // A status read already in flight could answer "idle" after this POST and stop the polling mid-move.
+ await client.cancelQueries({ queryKey });
+ // The accepted status says the job is running, which starts the status query polling.
+ client.setQueryData(queryKey, status);
+
+ if (!status.isRunning) {
+ // A tiny move can finish before the 202 goes out; nothing will poll, so the gallery refresh happens here.
+ refreshGalleryAfterMove(client, owner);
+ }
+
+ return status;
+};
+
+/** Moves every image into the configured layout; the server refuses (409) while queue work or a job is active. */
+export const startImageMove = (client: QueryClient, owner = captureAccountScope()): Promise =>
+ postImageMove(client, 'start', owner);
+
+/** Resumes or finalizes a journaled job the server stopped in; refused (409) while a job runs. */
+export const startImageMoveRecovery = (client: QueryClient, owner = captureAccountScope()): Promise =>
+ postImageMove(client, 'recover', owner);
diff --git a/invokeai/frontend/webv2/src/features/gallery/react.test.ts b/invokeai/frontend/webv2/src/features/gallery/react.test.ts
index 4103bdcd51e..f3f84ac8fe6 100644
--- a/invokeai/frontend/webv2/src/features/gallery/react.test.ts
+++ b/invokeai/frontend/webv2/src/features/gallery/react.test.ts
@@ -12,11 +12,13 @@ const sources = import.meta.glob('./react.ts', { eager: true, import: 'default',
* module orders differ, so only the entry's import surface can be checked cheaply.
*/
describe('gallery React entry', () => {
- it('reaches only the eager UI port and core types', () => {
+ it('reaches only the eager UI port, the image recovery leaf hook and core types', () => {
const source = sources['./react.ts'];
const specifiers = [...(source ?? '').matchAll(/from '([^']+)'/g)].map(([, specifier]) => specifier);
expect(source).toBeDefined();
- expect(new Set(specifiers)).toEqual(new Set(['./ui/GalleryUiContext', './core/types']));
+ expect(new Set(specifiers)).toEqual(
+ new Set(['./ui/GalleryUiContext', './ui/useGalleryImageRecovery', './core/types'])
+ );
});
});
diff --git a/invokeai/frontend/webv2/src/features/gallery/react.ts b/invokeai/frontend/webv2/src/features/gallery/react.ts
index dd57c281ba7..cd8d08fec15 100644
--- a/invokeai/frontend/webv2/src/features/gallery/react.ts
+++ b/invokeai/frontend/webv2/src/features/gallery/react.ts
@@ -11,7 +11,9 @@ export {
useHostGalleryBoards,
useOptionalGalleryHostProjectContext,
} from './ui/GalleryUiContext';
+export { useGalleryImageRecovery } from './ui/useGalleryImageRecovery';
export type { GalleryProjectRef } from './core/types';
-// Only the eager UI port lives here. This entry is bundled with the eager gallery-state chunk; reaching a lazy
-// gallery module (uploads, the picker) from it makes that chunk and the picker chunk import each other, and the
-// picker's module-scope code then runs before the state chunk has initialised. Uploads are published by `picker`.
+// Only the eager UI port and leaf hooks live here. This entry is bundled with the eager gallery-state chunk;
+// reaching a lazy gallery module (uploads, the picker) from it makes that chunk and the picker chunk import each
+// other, and the picker's module-scope code then runs before the state chunk has initialised. Uploads are published
+// by `picker`.
diff --git a/invokeai/frontend/webv2/src/features/gallery/storage.ts b/invokeai/frontend/webv2/src/features/gallery/storage.ts
new file mode 100644
index 00000000000..cd16fd21b77
--- /dev/null
+++ b/invokeai/frontend/webv2/src/features/gallery/storage.ts
@@ -0,0 +1,14 @@
+/**
+ * Server image storage administration: the subfolder layout and the move/recovery job. Separate from `queries` so
+ * the editor's gallery reads never load this admin-only transport.
+ */
+export {
+ IMAGE_STORAGE_LAYOUTS,
+ imageMoveStatusQueryOptions,
+ imageStorageLayoutQueryOptions,
+ isImageStorageLayout,
+ startImageMove,
+ startImageMoveRecovery,
+ updateImageStorageLayout,
+} from './data/imageStorage';
+export type { ImageMoveStatus, ImageStorageLayout } from './data/imageStorage';
diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx
index 0b72938b623..d28bd732e32 100644
--- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryLoadFailures.browser.test.tsx
@@ -10,11 +10,11 @@ import { accountLifecycle } from '@platform/state/accountLifecycle';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { system } from '@theme/system';
import { createInstance } from 'i18next';
-import { act, useMemo, useState, type ReactNode } from 'react';
+import { act, StrictMode, useMemo, useState, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { userEvent } from 'vitest/browser';
+import { commands, userEvent } from 'vitest/browser';
import { GalleryWidgetView } from './GalleryWidgetView';
@@ -91,6 +91,12 @@ const image = (name: string, boardId: string, day = 1): GalleryItem => ({
});
const page = (items: GalleryItem[], total = items.length): Promise =>
Promise.resolve({ items, total });
+const thumbnailRouteCommands = commands as typeof commands & {
+ endThumbnailRouteFailures: () => Promise;
+ getThumbnailRequests: () => Promise<{ statuses: number[]; types: string[]; urls: string[] }>;
+ startThumbnailRoute: (pathnames: string[], options?: { failures?: number; status?: number }) => Promise;
+ stopThumbnailRoute: () => Promise;
+};
const fail = (): Promise => Promise.reject(new Error('Service Unavailable'));
/** A just-generated image on another board, the kind the old fallback showed in place of a failed listing. */
@@ -211,23 +217,24 @@ const PROGRESS_SESSION: QueueProgressSession = {
const renderGallery = async (
values: Record = {},
region: 'bottom' | 'center' = 'center',
- progressSessions = NO_SESSIONS
+ progressSessions = NO_SESSIONS,
+ { strict = false }: { strict?: boolean } = {}
) => {
- await act(() =>
- root?.render(
-
-
-
-
-
-
-
- )
+ const tree = (
+
+
+
+
+
+
+
);
+
+ await act(() => root?.render(strict ? {tree} : tree));
};
const listing = () => host?.querySelector('[role="list"][aria-label="Gallery items"]') ?? null;
@@ -362,6 +369,85 @@ describe('Gallery listing failures', () => {
await waitFor(() => expect(host?.contains(document.activeElement)).toBe(true));
});
+ it('reloads every thumbnail a long image move refused, once, probing one image per interval meanwhile', async () => {
+ // The real guard answers every image read with 409 while the files move; nothing tells this tab when it ends.
+ const moving = Array.from({ length: 6 }, (_, index) => ({
+ ...image(`moving-${String(index)}.png`, 'dogs', 10 - index),
+ thumbnailUrl: `/api/v1/images/i/moving-${String(index)}.png/thumbnail`,
+ }));
+ const paths = moving.map((item) => item.thumbnailUrl);
+ const pathOf = (url: string) => new URL(url, window.location.href).pathname;
+ const countByPath = (urls: string[]) =>
+ Object.fromEntries(paths.map((path) => [path, urls.filter((url) => pathOf(url) === path).length]));
+ const eachPath = (count: number) => Object.fromEntries(paths.map((path) => [path, count]));
+ const imageRequests = async () => {
+ const log = await thumbnailRouteCommands.getThumbnailRequests();
+ return log.urls.filter((_, index) => log.types[index] === 'image');
+ };
+ // The probe's reads are answered in the page, so they settle within the fake clock's steps; the tiles' own
+ // image reads go to the network route.
+ let isMoving = true;
+ const browserFetch = globalThis.fetch;
+ const probes = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) =>
+ paths.includes(pathOf(String(input)))
+ ? Promise.resolve(
+ isMoving
+ ? new Response('moving', { status: 409 })
+ : new Response('', {
+ headers: { 'content-type': 'image/svg+xml' },
+ })
+ )
+ : browserFetch(input, init)
+ );
+ const probedPaths = () => probes.mock.calls.map(([input]) => pathOf(String(input)));
+ await thumbnailRouteCommands.startThumbnailRoute(paths, { failures: 1_000_000, status: 409 });
+ transport.listItems.mockImplementation(() => page(moving));
+ // Real time keeps flowing for Query and the image loads; the test jumps the clock through the move.
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] });
+
+ try {
+ // StrictMode rehearses each tile's unmount; recovery must survive it as it does in the app.
+ await renderGallery({}, 'center', NO_SESSIONS, { strict: true });
+ await waitFor(() => expect(thumbnailNames()).toHaveLength(6));
+ const thumbnails = moving.map((item) => host!.querySelector(`img[alt="${item.name}"]`)!);
+ await expect.poll(() => thumbnails.every((thumbnail) => thumbnail.complete)).toBe(true);
+ expect(thumbnails.every((thumbnail) => thumbnail.naturalWidth === 0)).toBe(true);
+ expect(countByPath(await imageRequests())).toEqual(eachPath(1));
+
+ // Through six minutes of move the probe keeps asking about one image for the whole grid every 15 to 30 s,
+ // never one per tile.
+ for (let interval = 1; interval <= 12; interval += 1) {
+ const before = probes.mock.calls.length;
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+ expect(probes.mock.calls.length - before).toBeGreaterThanOrEqual(1);
+ expect(probes.mock.calls.length - before).toBeLessThanOrEqual(2);
+ }
+ expect(new Set(probedPaths()).size).toBe(1);
+ expect(await imageRequests()).toHaveLength(6);
+ expect(thumbnails.every((thumbnail) => thumbnail.naturalWidth === 0)).toBe(true);
+
+ // The move ends: the next probe sees an image served and every failed tile reloads by itself.
+ isMoving = false;
+ await thumbnailRouteCommands.endThumbnailRouteFailures();
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+ await expect
+ .poll(() =>
+ moving.map((item) => host!.querySelector(`img[alt="${item.name}"]`)!.naturalWidth)
+ )
+ .toEqual(moving.map(() => 64));
+
+ // Each tile asked twice in all: the refused read and one reload. Nothing asks again afterwards.
+ const probeCount = probes.mock.calls.length;
+ await act(() => vi.advanceTimersByTimeAsync(5 * 60_000));
+ expect(countByPath(await imageRequests())).toEqual(eachPath(2));
+ expect(probes.mock.calls.length).toBe(probeCount);
+ } finally {
+ vi.useRealTimers();
+ probes.mockRestore();
+ await thumbnailRouteCommands.stopThumbnailRoute();
+ }
+ }, 30_000);
+
it('shows an anchored window that failed as failed, never the unfiltered recents', async () => {
transport.listItems.mockImplementation(fail);
await renderGallery({ galleryPage: 2, paginationMode: 'paginated', recentImages: [OTHER_BOARD_RECENT] });
diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx
index 346494d7944..0c9e2325f07 100644
--- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx
+++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx
@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next';
import { getGalleryItemDragData, getGalleryItemDragId } from './galleryDnd';
import { GalleryTileFrame } from './GalleryTileFrame';
+import { useGalleryImageRecovery } from './useGalleryImageRecovery';
/** Desaturate armed and active touch drags, including the portalled preview. */
/** The grid's one thumbnail Tab stop: the tile keyboard focus returns to. */
@@ -119,13 +120,14 @@ const GalleryThumbnail = ({
getGalleryThumbnailRevision,
getGalleryThumbnailRevision
);
- const thumbnailUrl = useMemo(
+ const baseThumbnailUrl = item.thumbnailUrl || item.fullUrl;
+ // Only image thumbnails are regenerated by maintenance; a failed load of either kind recovers after an image move.
+ const maintainedThumbnailUrl = useMemo(
() =>
- item.kind === 'image'
- ? getRefreshedGalleryThumbnailUrl(item.thumbnailUrl || item.fullUrl, thumbnailRevision)
- : item.thumbnailUrl || item.fullUrl,
- [item.fullUrl, item.kind, item.thumbnailUrl, thumbnailRevision]
+ item.kind === 'image' ? getRefreshedGalleryThumbnailUrl(baseThumbnailUrl, thumbnailRevision) : baseThumbnailUrl,
+ [baseThumbnailUrl, item.kind, thumbnailRevision]
);
+ const { onError: handleImageError, src: thumbnailUrl } = useGalleryImageRecovery(maintainedThumbnailUrl);
const isCompared = compareRole !== null;
const duration = item.kind === 'video' ? formatGalleryVideoDuration(item.durationSeconds) : null;
@@ -337,6 +339,7 @@ const GalleryThumbnail = ({
draggable={false}
src={thumbnailUrl}
style={imageStyle}
+ onError={handleImageError}
/>
{/* The compare role owns this corner while comparing. */}
diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.browser.test.tsx
new file mode 100644
index 00000000000..bf3aef6abfa
--- /dev/null
+++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.browser.test.tsx
@@ -0,0 +1,83 @@
+import { accountLifecycle } from '@platform/state/accountLifecycle';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { commands } from 'vitest/browser';
+
+import { useGalleryImageRecovery } from './useGalleryImageRecovery';
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const imageRouteCommands = commands as typeof commands & {
+ endThumbnailRouteFailures: () => Promise;
+ startThumbnailRoute: (pathnames: string[], options?: { failures?: number; status?: number }) => Promise;
+ stopThumbnailRoute: () => Promise;
+};
+
+/** An index-keyed slot: the element stays, the image it shows changes. */
+const Slot = ({ url }: { url: string }) => {
+ const { onError, src } = useGalleryImageRecovery(url);
+
+ return
;
+};
+
+const FIRST = '/api/v1/images/i/first.png/thumbnail';
+const SECOND = '/api/v1/images/i/second.png/thumbnail';
+const LOADED = `data:image/svg+xml,${encodeURIComponent(
+ ''
+)}`;
+
+let host: HTMLDivElement;
+let root: Root;
+const slot = () => host.querySelector('img[alt="slot"]')!;
+
+beforeEach(() => {
+ accountLifecycle.activate('image-recovery-slot');
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] });
+});
+
+afterEach(async () => {
+ vi.useRealTimers();
+ await act(() => root.unmount());
+ host.remove();
+ await imageRouteCommands.stopThumbnailRoute();
+ accountLifecycle.invalidate();
+});
+
+describe('useGalleryImageRecovery', () => {
+ it('does not carry a recovered image’s reload into the next image the slot shows', async () => {
+ await imageRouteCommands.startThumbnailRoute([FIRST, SECOND], { failures: 1, status: 409 });
+ await act(() => root.render());
+ await expect.poll(() => slot().complete && slot().naturalWidth === 0).toBe(true);
+
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+ await expect.poll(() => slot().naturalWidth).toBe(64);
+ expect(slot().getAttribute('src')).toContain('gallery_image_recovery=');
+
+ // The slot now shows another image: it asks for that image's own URL, which the HTTP cache can answer.
+ await act(() => root.render());
+ expect(slot().getAttribute('src')).toBe(SECOND);
+ });
+
+ it('leaves an image that loaded alone when a recovery reloads the failed ones', async () => {
+ await imageRouteCommands.startThumbnailRoute([FIRST], { failures: 1, status: 409 });
+ await act(() =>
+ root.render(
+ <>
+
+
+ >
+ )
+ );
+ const [failing] = host.querySelectorAll('img');
+ await expect.poll(() => failing!.complete && failing!.naturalWidth === 0).toBe(true);
+
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+ await expect.poll(() => failing!.naturalWidth).toBe(64);
+ // The image that never failed keeps its plain URL through the recovery.
+ expect(host.querySelectorAll('img')[1]!.getAttribute('src')).toBe(LOADED);
+ });
+});
diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.ts b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.ts
new file mode 100644
index 00000000000..d892c5c8f03
--- /dev/null
+++ b/invokeai/frontend/webv2/src/features/gallery/ui/useGalleryImageRecovery.ts
@@ -0,0 +1,45 @@
+import {
+ getGalleryImageRecoveryRevision,
+ getRecoveredGalleryImageUrl,
+ reportGalleryImageFailure,
+ subscribeGalleryImageRecovery,
+} from '@features/gallery/data/imageRecovery';
+import { useCallback, useMemo, useState, useSyncExternalStore } from 'react';
+
+interface ImageRecoveryState {
+ /** The recovery revision current when the image failed; null once a later one reloaded it. */
+ failedAt: number | null;
+ /** The recovery revision in the image's URL. */
+ revision: number;
+ url: string;
+}
+
+/**
+ * The `src` for an image element showing the gallery image at `url`, and its `onError`. A failed image reloads once
+ * when the shared probe sees the server serve images again (an image move answers 409 until it ends). The state
+ * belongs to `url` alone: an element handed another image, as index-keyed slots are, starts from its plain URL.
+ */
+export const useGalleryImageRecovery = (url: string): { onError: () => void; src: string } => {
+ const recoveryRevision = useSyncExternalStore(
+ subscribeGalleryImageRecovery,
+ getGalleryImageRecoveryRevision,
+ getGalleryImageRecoveryRevision
+ );
+ const [state, setState] = useState(null);
+ const own = state?.url === url ? state : null;
+ let revision = own?.revision ?? 0;
+
+ if (own && own.failedAt !== null && recoveryRevision > own.failedAt) {
+ // Pin the reload to the revision that recovered it, so later recoveries of other images leave this one alone.
+ revision = recoveryRevision;
+ setState({ failedAt: null, revision, url });
+ }
+
+ const src = useMemo(() => getRecoveredGalleryImageUrl(url, revision), [revision, url]);
+ const onError = useCallback(() => {
+ setState({ failedAt: getGalleryImageRecoveryRevision(), revision, url });
+ reportGalleryImageFailure(url, revision);
+ }, [revision, url]);
+
+ return { onError, src };
+};
diff --git a/invokeai/frontend/webv2/src/features/queue/README.md b/invokeai/frontend/webv2/src/features/queue/README.md
index 851788d67e6..736a1ad857a 100644
--- a/invokeai/frontend/webv2/src/features/queue/README.md
+++ b/invokeai/frontend/webv2/src/features/queue/README.md
@@ -7,3 +7,5 @@ Queue's private `QueueBackendPort` is adapter seam; public callers use domain re
Queue exposes board-independent active backend sessions, including preparation and result settlement. Gallery progress section includes every unfinished batch slot, queued work too; Preview follows executing and settling sessions. Both use gallery-destination projections of active project's queue; eligibility is presentation policy, separate from discovery. Workbench owns transient overview/pinned follow selection for its mounted lifetime, clears pins on project changes or when following stops, and never persists pins or preview frames. Live sessions take no part in board counts, saved-image selection, or navigation. Saved outputs keep their actual backend board destinations; automatic result selection does not change browsed board or its paging window.
Retry re-runs failed or canceled backend items under new ids; the run owning them reopens around the reruns so its captured destination receives their results. A rerun no open run adopts reaches the Gallery, or the current Canvas of a tab with its project open. Canvas reruns are staged once across every tab and browser of the account through a delivery claim the server keeps per queue item (`POST queue/{id}/i/{item_id}/delivery`): a claimant claims, stages, then confirms; a claim neither confirmed nor released lapses after two minutes. A reopened run claims its reruns as `run:` once its tab holds the run, renews them while it lasts, and gives back what it did not stage when the tab lets the run go, so a reload restores the same claimant and a run never stages a rerun another tab staged first. A tab the browser freezes stops renewing; another tab then stages the rerun at its own current bbox. A tab staging a rerun no run adopted claims as itself, after waiting for any tab of the browser that still tracks the run, and asks again while another claimant holds the rerun. A server without the endpoint lets every tab stage.
+
+`submitUtilityGraph` (`@features/queue/utility`) returns once the backend accepts the graph; its `settled` watch ends on the first of: a terminal status event for its origin; a re-read of its accepted items that finds a terminal status or answers 404 (`removed`), triggered by `queue_cleared`, a reconnect after a disconnect, `queue_items_canceled` naming an accepted id, or the periodic reconcile read, which re-arms while the item stays open; or an abort of its signal (`aborted`), which stops watching and leaves the backend item alone.
diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts
index 0b26dde20fc..315838cd225 100644
--- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts
+++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.test.ts
@@ -439,6 +439,25 @@ describe('enqueueWorkflow', () => {
});
});
+describe('enqueueUtility', () => {
+ beforeEach(() => {
+ mocks.apiFetchJson.mockReset();
+ mocks.apiFetchJson.mockResolvedValue({ enqueued: 1, item_ids: [4] });
+ });
+
+ it('appends by default and prepends only when the request asks for the front of the queue', async () => {
+ const { enqueueUtility } = await import('./submissionApi');
+ const request = { graph: { edges: [], id: 'pbr', nodes: {} }, origin: 'webv2:util:1' };
+
+ await expect(enqueueUtility(request)).resolves.toEqual({ enqueued: 1, itemIds: [4] });
+ expect(getSubmittedBody()).toMatchObject({ batch: { origin: 'webv2:util:1', runs: 1 }, prepend: false });
+
+ mocks.apiFetchJson.mockClear();
+ await enqueueUtility({ ...request, prepend: true });
+ expect(getSubmittedBody()).toMatchObject({ prepend: true });
+ });
+});
+
describe('getResultImages', () => {
beforeEach(() => {
accountLifecycle.activate('test-account');
diff --git a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts
index 0d286bcfadb..bba8d8a2707 100644
--- a/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts
+++ b/invokeai/frontend/webv2/src/features/queue/data/submissionApi.ts
@@ -140,9 +140,14 @@ export const enqueueWorkflow = async (request: QueueEnqueueWorkflowRequest): Pro
export const enqueueUtility = async (request: {
graph: QueueEnqueueWorkflowRequest['graph'];
origin: string;
+ /** Put the item ahead of queued generations. */
+ prepend?: boolean;
}): Promise<{ enqueued: number; itemIds: number[] }> => {
const result = await apiFetchJson<{ enqueued?: number; item_ids?: number[] }>('/api/v1/queue/default/enqueue_batch', {
- body: JSON.stringify({ batch: { graph: request.graph, origin: request.origin, runs: 1 }, prepend: false }),
+ body: JSON.stringify({
+ batch: { graph: request.graph, origin: request.origin, runs: 1 },
+ prepend: request.prepend === true,
+ }),
method: 'POST',
});
diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts
index f5fe5851ea4..c27d1ac09f8 100644
--- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts
+++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.test.ts
@@ -1,5 +1,7 @@
import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@features/queue/contracts';
+import type { QueueItemsCanceledEvent } from '@features/queue/data/events';
import type { SocketHub } from '@platform/transport/socketHub';
+import type { BackendConnectionStatus } from '@platform/transport/types';
import {
buildQueueItemOrigin,
@@ -10,16 +12,24 @@ import {
import { ApiError } from '@platform/transport/http';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import type { UtilityEnqueue } from './utilityGraph';
+import type { UtilityEnqueue, UtilityItemStatusReader } from './utilityGraph';
-import { DEFAULT_UTILITY_QUEUE_TIMEOUT_MS, runUtilityGraph, UtilityQueueError } from './utilityGraph';
+import {
+ DEFAULT_UTILITY_QUEUE_TIMEOUT_MS,
+ DEFAULT_UTILITY_RECONCILE_INTERVAL_MS,
+ runUtilityGraph,
+ submitUtilityGraph,
+ UtilityQueueError,
+} from './utilityGraph';
/** A fake socket hub that records handlers per event and can emit to them, mirroring `hub.on`. */
const createFakeHub = () => {
const handlers = new Map void>>();
let detachCount = 0;
- const hub: Pick = {
+ const connectionHandlers = new Set<(status: BackendConnectionStatus) => void>();
+
+ const hub: Pick = {
on: (event, handler) => {
const set = handlers.get(event) ?? new Set();
set.add(handler as (payload: unknown) => void);
@@ -29,6 +39,11 @@ const createFakeHub = () => {
set.delete(handler as (payload: unknown) => void);
};
},
+ onConnectionChange: (handler) => {
+ connectionHandlers.add(handler);
+ handler('connected');
+ return () => connectionHandlers.delete(handler);
+ },
};
const emit = (event: string, payload: unknown): void => {
@@ -38,6 +53,12 @@ const createFakeHub = () => {
};
return {
+ connection: (status: BackendConnectionStatus): void => {
+ for (const handler of connectionHandlers) {
+ handler(status);
+ }
+ },
+ connectionHandlerCount: (): number => connectionHandlers.size,
emit,
get detachCount() {
return detachCount;
@@ -116,6 +137,20 @@ const statusEvent = (
...overrides,
});
+const bulkCanceledEvent = (itemIds: number[]): QueueItemsCanceledEvent => ({
+ canceled_item_ids: itemIds,
+ canceled_item_ids_by_user: { 'user-1': itemIds },
+ queue_id: 'default',
+ timestamp: 1,
+ user_ids: ['user-1'],
+});
+
+/** Yields a real macrotask, so everything the previous tasks queued (an unhandled rejection report included) runs. */
+const nextMacrotask = () =>
+ new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+
const okEnqueue: UtilityEnqueue = () => Promise.resolve({ enqueued: 1, itemIds: [1] });
const createDeferred = (): { promise: Promise; resolve: (value: T) => void } => {
@@ -177,10 +212,11 @@ describe('runUtilityGraph — await + settle', () => {
fake.emit('queue_item_status_changed', statusEvent('completed'));
await expect(promise).resolves.toEqual({ height: 48, imageName: 'filtered.png', origin: ORIGIN, width: 64 });
- // Both listeners detached on settle.
+ // Every listener detached on settle: output, status, queue clear, and bulk cancel.
expect(fake.handlerCount('invocation_complete')).toBe(0);
expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
- expect(fake.detachCount).toBe(2);
+ expect(fake.detachCount).toBe(4);
+ expect(fake.connectionHandlerCount()).toBe(0);
});
it('ignores completions from other output nodes when outputNodeId is set', async () => {
@@ -331,7 +367,7 @@ describe('runUtilityGraph — await + settle', () => {
expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
reconciliation.resolve({ height: 48, imageName: 'late.png', width: 64 });
await Promise.resolve();
- expect(fake.detachCount).toBe(2);
+ expect(fake.detachCount).toBe(4);
});
it('allows delayed socket output to win after a transient reconciliation failure', async () => {
@@ -1091,6 +1127,71 @@ describe('runUtilityGraph — enqueue failures', () => {
hub: fake.hub,
});
await expect(promise).rejects.toMatchObject({ message: 'network down', reason: 'enqueue' });
+
+ // A backend refusal shows its detail, never the raw JSON body.
+ const cause = new ApiError('{"detail":"queue paused"}', 409);
+ await expect(
+ runUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.reject(cause),
+ graph: { edges: [], id: 'g', nodes: {} },
+ hub: createFakeHub().hub,
+ })
+ ).rejects.toMatchObject({ cause, message: 'queue paused', reason: 'enqueue' });
+ });
+});
+
+describe('runUtilityGraph — missed settlement', () => {
+ it('re-reads the item after a queue clear, bulk cancel, or reconnect instead of waiting for the timeout', async () => {
+ const graph = { edges: [], id: 'g', nodes: {} };
+ const removed = createFakeHub();
+ const readRemoved = vi.fn(() =>
+ Promise.reject(new ApiError('{"detail":"Queue item not found"}', 404))
+ );
+ const removedRun = runUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [3] }),
+ graph,
+ hub: removed.hub,
+ readItemStatus: readRemoved,
+ timeoutMs: 0,
+ });
+ await vi.waitFor(() => expect(removed.handlerCount('queue_cleared')).toBe(1));
+ await Promise.resolve();
+ removed.emit('queue_cleared', { queue_id: 'default', timestamp: 1, user_id: null });
+ await expect(removedRun).rejects.toMatchObject({ reason: 'removed' });
+ expect(readRemoved).toHaveBeenCalledExactlyOnceWith(3, undefined);
+ expect(removed.handlerCount('queue_items_canceled')).toBe(0);
+
+ const canceled = createFakeHub();
+ const canceledRun = runUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [3] }),
+ graph,
+ hub: canceled.hub,
+ readItemStatus: () => Promise.resolve({ status: 'canceled' }),
+ timeoutMs: 0,
+ });
+ await nextMacrotask();
+ canceled.emit('queue_items_canceled', bulkCanceledEvent([3]));
+ await expect(canceledRun).rejects.toMatchObject({ reason: 'canceled' });
+
+ // A reconnect that finds the item completed reconciles its output as a socket completion would.
+ const reconnected = createFakeHub();
+ const reconnectedRun = runUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [3] }),
+ graph,
+ hub: reconnected.hub,
+ readItemStatus: () => Promise.resolve({ status: 'completed' }),
+ reconcileCompletedOutput: () => Promise.resolve({ height: 8, imageName: 'out.png', width: 8 }),
+ timeoutMs: 0,
+ });
+ await nextMacrotask();
+ reconnected.connection('disconnected');
+ reconnected.connection('connected');
+ await expect(reconnectedRun).resolves.toEqual({ height: 8, imageName: 'out.png', origin: ORIGIN, width: 8 });
+ expect(reconnected.connectionHandlerCount()).toBe(0);
});
});
@@ -1147,3 +1248,431 @@ describe('UtilityQueueError', () => {
expect(error.reason).toBe('timeout');
});
});
+
+describe('submitUtilityGraph — fire, then watch settlement', () => {
+ const graph = { edges: [], id: 'pbr', nodes: {} };
+
+ it('resolves once the backend accepts the graph and settles when the item completes', async () => {
+ const fake = createFakeHub();
+ const enqueue = vi.fn(() => Promise.resolve({ enqueued: 1, itemIds: [7] }));
+
+ const submission = await submitUtilityGraph({ createId: () => UTIL_ID, enqueue, graph, hub: fake.hub });
+
+ expect(enqueue).toHaveBeenCalledExactlyOnceWith({ graph, origin: ORIGIN });
+ expect(submission).toMatchObject({ itemIds: [7], origin: ORIGIN });
+ // Only status changes are watched; no output image is expected.
+ expect(fake.handlerCount('invocation_complete')).toBe(0);
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(1);
+
+ fake.emit('queue_item_status_changed', statusEvent('completed', { origin: 'other-origin' }));
+ fake.emit('queue_item_status_changed', statusEvent('completed', { item_id: 7 }));
+
+ await expect(submission.settled).resolves.toBeUndefined();
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ });
+
+ it('settles a completion that arrives before the enqueue response', async () => {
+ const fake = createFakeHub();
+ const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>();
+ const pending = submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => deferred.promise,
+ graph,
+ hub: fake.hub,
+ });
+
+ fake.emit('queue_item_status_changed', statusEvent('completed'));
+ deferred.resolve({ enqueued: 1, itemIds: [1] });
+
+ await expect((await pending).settled).resolves.toBeUndefined();
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ });
+
+ it('rejects settlement with the backend failure message, or as canceled', async () => {
+ const failed = createFakeHub();
+ const failedSubmission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: okEnqueue,
+ graph,
+ hub: failed.hub,
+ });
+ failed.emit('queue_item_status_changed', statusEvent('failed', { error_message: 'No model' }));
+ await expect(failedSubmission.settled).rejects.toMatchObject({ message: 'No model', reason: 'failed' });
+
+ const canceled = createFakeHub();
+ const canceledSubmission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: okEnqueue,
+ graph,
+ hub: canceled.hub,
+ });
+ canceled.emit('queue_item_status_changed', statusEvent('canceled'));
+ await expect(canceledSubmission.settled).rejects.toMatchObject({ reason: 'canceled' });
+ expect(canceled.handlerCount('queue_item_status_changed')).toBe(0);
+ });
+
+ it('rejects the submission, and detaches, when the enqueue fails or answers inconsistently', async () => {
+ const failing = createFakeHub();
+ const cause = new ApiError('{"detail":"queue paused"}', 409);
+ await expect(
+ submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.reject(cause),
+ graph,
+ hub: failing.hub,
+ })
+ ).rejects.toMatchObject({ cause, message: 'queue paused', reason: 'enqueue' });
+ expect(failing.handlerCount('queue_item_status_changed')).toBe(0);
+ expect(failing.handlerCount('queue_cleared')).toBe(0);
+
+ const inconsistent = createFakeHub();
+ const cancel = vi.fn(() => Promise.resolve());
+ await expect(
+ submitUtilityGraph({
+ cancel,
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 0, itemIds: [] }),
+ graph,
+ hub: inconsistent.hub,
+ })
+ ).rejects.toMatchObject({ reason: 'enqueue' });
+ expect(inconsistent.handlerCount('queue_item_status_changed')).toBe(0);
+ expect(cancel).not.toHaveBeenCalled();
+
+ // Items the backend did accept alongside an inconsistent count are canceled rather than left running unwatched.
+ await expect(
+ submitUtilityGraph({
+ cancel,
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 2, itemIds: [5] }),
+ graph,
+ hub: inconsistent.hub,
+ })
+ ).rejects.toMatchObject({ reason: 'enqueue' });
+ await vi.waitFor(() => expect(cancel).toHaveBeenCalledExactlyOnceWith([5]));
+ });
+
+ it('passes prepend through to the enqueue so the item runs ahead of queued generations', async () => {
+ const fake = createFakeHub();
+ const enqueue = vi.fn(() => Promise.resolve({ enqueued: 1, itemIds: [1] }));
+
+ await submitUtilityGraph({ createId: () => UTIL_ID, enqueue, graph, hub: fake.hub, prepend: true });
+
+ expect(enqueue).toHaveBeenCalledExactlyOnceWith({ graph, origin: ORIGIN, prepend: true });
+ });
+
+ it('aborting ends the watch: the listener detaches, the timer clears, and the item is not canceled', async () => {
+ vi.useFakeTimers();
+ const fake = createFakeHub();
+ const controller = new AbortController();
+ const readItemStatus = vi.fn();
+ const submission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: okEnqueue,
+ graph,
+ hub: fake.hub,
+ readItemStatus,
+ signal: controller.signal,
+ });
+ const outcome = submission.settled.then(
+ () => 'resolved',
+ (error: UtilityQueueError) => error.reason
+ );
+
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(1);
+ controller.abort();
+
+ await expect(outcome).resolves.toBe('aborted');
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ expect(fake.handlerCount('queue_cleared')).toBe(0);
+ expect(vi.getTimerCount()).toBe(0);
+ expect(readItemStatus).not.toHaveBeenCalled();
+
+ // An already-aborted signal refuses the submission before anything is enqueued.
+ const enqueue = vi.fn(okEnqueue);
+ await expect(
+ submitUtilityGraph({ createId: () => UTIL_ID, enqueue, graph, hub: fake.hub, signal: AbortSignal.abort() })
+ ).rejects.toMatchObject({ reason: 'aborted' });
+ expect(enqueue).not.toHaveBeenCalled();
+ });
+
+ it('removes its abort listener once the watch settles, so each submission leaves nothing on the signal', async () => {
+ const fake = createFakeHub();
+ const controller = new AbortController();
+ const added = vi.spyOn(controller.signal, 'addEventListener');
+ const removed = vi.spyOn(controller.signal, 'removeEventListener');
+ const submission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: okEnqueue,
+ graph,
+ hub: fake.hub,
+ signal: controller.signal,
+ });
+
+ expect(added).toHaveBeenCalledExactlyOnceWith('abort', expect.any(Function));
+ expect(removed).not.toHaveBeenCalled();
+
+ fake.emit('queue_item_status_changed', statusEvent('completed'));
+ await expect(submission.settled).resolves.toBeUndefined();
+
+ const handler = added.mock.calls[0]?.[1];
+ expect(removed).toHaveBeenCalledExactlyOnceWith('abort', handler);
+ });
+
+ // `settled` can reject before the caller holds it. Vitest fails the run on an unhandled rejection, and a real
+ // macrotask between the rejection and the caller's handler gives one the chance to be reported.
+
+ it('settles quietly when the account changes while the enqueue is still pending', async () => {
+ const fake = createFakeHub();
+ const controller = new AbortController();
+ const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>();
+ const pending = submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => deferred.promise,
+ graph,
+ hub: fake.hub,
+ signal: controller.signal,
+ });
+
+ controller.abort();
+ await nextMacrotask();
+ vi.useFakeTimers();
+ deferred.resolve({ enqueued: 1, itemIds: [1] });
+
+ const submission = await pending;
+ await expect(submission.settled).rejects.toMatchObject({ reason: 'aborted' });
+ expect(submission.itemIds).toEqual([1]);
+ // The settled watch arms no reconciliation timer for the late acceptance.
+ expect(vi.getTimerCount()).toBe(0);
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ });
+
+ it('settles quietly when the item fails before the enqueue response arrives', async () => {
+ const fake = createFakeHub();
+ const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>();
+ const pending = submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => deferred.promise,
+ graph,
+ hub: fake.hub,
+ });
+
+ fake.emit('queue_item_status_changed', statusEvent('failed', { error_message: 'No model' }));
+ await nextMacrotask();
+ deferred.resolve({ enqueued: 1, itemIds: [1] });
+
+ const submission = await pending;
+ await expect(submission.settled).rejects.toMatchObject({ message: 'No model', reason: 'failed' });
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ });
+
+ it('re-reads the item after a reconnect so a missed completion still settles', async () => {
+ const fake = createFakeHub();
+ const readItemStatus = vi.fn(() => Promise.resolve({ status: 'in_progress' }));
+ const submission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [9] }),
+ graph,
+ hub: fake.hub,
+ readItemStatus,
+ });
+ const outcome = submission.settled.then(
+ () => 'resolved',
+ (error: UtilityQueueError) => error.reason
+ );
+
+ // The initial status replay is not a reconnect.
+ fake.connection('connected');
+ expect(readItemStatus).not.toHaveBeenCalled();
+
+ fake.connection('disconnected');
+ fake.connection('connected');
+ await vi.waitFor(() => expect(readItemStatus).toHaveBeenCalledExactlyOnceWith(9, undefined));
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(1);
+
+ readItemStatus.mockResolvedValueOnce({ status: 'completed' });
+ fake.connection('disconnected');
+ fake.connection('connected');
+
+ await expect(outcome).resolves.toBe('resolved');
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ expect(fake.connectionHandlerCount()).toBe(0);
+ });
+
+ it('re-reads the item after a queue clear and settles as removed when the item is gone', async () => {
+ const fake = createFakeHub();
+ const readItemStatus = vi.fn(() => Promise.resolve({ status: 'pending' }));
+ const submission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [9] }),
+ graph,
+ hub: fake.hub,
+ readItemStatus,
+ });
+ const outcome = submission.settled.then(
+ () => 'resolved',
+ (error: UtilityQueueError) => error.reason
+ );
+ const clearedEvent = { queue_id: 'default', timestamp: 1, user_id: null };
+
+ // A clear that only removed other items leaves this one watched.
+ fake.emit('queue_cleared', clearedEvent);
+ await vi.waitFor(() => expect(readItemStatus).toHaveBeenCalledExactlyOnceWith(9, undefined));
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(1);
+
+ // A pending item a clear deleted answers 404: there will never be a terminal event for it.
+ readItemStatus.mockRejectedValueOnce(new ApiError('{"detail":"Queue item not found"}', 404));
+ fake.emit('queue_cleared', clearedEvent);
+
+ await expect(outcome).resolves.toBe('removed');
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ expect(fake.handlerCount('queue_cleared')).toBe(0);
+ });
+
+ it('re-reads the item when a bulk cancel or delete names it, and ignores bulk events for other items', async () => {
+ const fake = createFakeHub();
+ const readItemStatus = vi.fn(() => Promise.resolve({ status: 'canceled' }));
+ const submission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [9] }),
+ graph,
+ hub: fake.hub,
+ readItemStatus,
+ });
+ const outcome = submission.settled.then(
+ () => 'resolved',
+ (error: UtilityQueueError) => error.reason
+ );
+
+ // Other owners' items, and the id-less companion other users receive, leave this item watched.
+ fake.emit('queue_items_canceled', bulkCanceledEvent([4, 5]));
+ fake.emit('queue_items_canceled', bulkCanceledEvent([]));
+ await nextMacrotask();
+ expect(readItemStatus).not.toHaveBeenCalled();
+ expect(fake.handlerCount('queue_items_canceled')).toBe(1);
+
+ // Bulk operations cancel waiting items in one statement and emit no per-item status change.
+ fake.emit('queue_items_canceled', bulkCanceledEvent([4, 9]));
+
+ await expect(outcome).resolves.toBe('canceled');
+ expect(readItemStatus).toHaveBeenCalledExactlyOnceWith(9, undefined);
+ expect(fake.handlerCount('queue_items_canceled')).toBe(0);
+
+ // A bulk delete removes the row: the read answers 404.
+ const deleted = createFakeHub();
+ const deletedSubmission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => Promise.resolve({ enqueued: 1, itemIds: [9] }),
+ graph,
+ hub: deleted.hub,
+ readItemStatus: () => Promise.reject(new ApiError('{"detail":"Queue item not found"}', 404)),
+ });
+ deleted.emit('queue_items_canceled', bulkCanceledEvent([9]));
+ await expect(deletedSubmission.settled).rejects.toMatchObject({ reason: 'removed' });
+ });
+
+ it('defers a bulk cancel that arrives before the enqueue response until the item id is known', async () => {
+ const fake = createFakeHub();
+ const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>();
+ const readItemStatus = vi.fn(() => Promise.resolve({ status: 'canceled' }));
+ const pending = submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => deferred.promise,
+ graph,
+ hub: fake.hub,
+ readItemStatus,
+ });
+
+ // The ids are unknown until the enqueue answers, so any bulk event may have named this item.
+ fake.emit('queue_items_canceled', bulkCanceledEvent([12]));
+ await Promise.resolve();
+ expect(readItemStatus).not.toHaveBeenCalled();
+
+ deferred.resolve({ enqueued: 1, itemIds: [12] });
+ const submission = await pending;
+
+ await expect(submission.settled).rejects.toMatchObject({ reason: 'canceled' });
+ expect(readItemStatus).toHaveBeenCalledExactlyOnceWith(12, undefined);
+ });
+
+ it('defers a clear that arrives before the enqueue response until the item id is known', async () => {
+ const fake = createFakeHub();
+ const deferred = createDeferred<{ enqueued: number; itemIds: number[] }>();
+ const readItemStatus = vi.fn(() =>
+ Promise.reject(new ApiError('{"detail":"Queue item not found"}', 404))
+ );
+ const pending = submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: () => deferred.promise,
+ graph,
+ hub: fake.hub,
+ readItemStatus,
+ });
+
+ fake.emit('queue_cleared', { queue_id: 'default', timestamp: 1, user_id: null });
+ await Promise.resolve();
+ expect(readItemStatus).not.toHaveBeenCalled();
+
+ deferred.resolve({ enqueued: 1, itemIds: [12] });
+ const submission = await pending;
+
+ await expect(submission.settled).rejects.toMatchObject({ reason: 'removed' });
+ expect(readItemStatus).toHaveBeenCalledExactlyOnceWith(12, undefined);
+ });
+
+ it('reads the item at the reconcile interval: a finished item settles, an open one re-arms the read', async () => {
+ vi.useFakeTimers();
+ for (const [status, expected] of [
+ ['completed', 'resolved'],
+ ['failed', 'failed'],
+ ] as const) {
+ const fake = createFakeHub();
+ const submission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: okEnqueue,
+ graph,
+ hub: fake.hub,
+ readItemStatus: () => Promise.resolve({ errorMessage: 'boom', status }),
+ });
+ const outcome = submission.settled.then(
+ () => 'resolved',
+ (error: UtilityQueueError) => error.reason
+ );
+
+ vi.advanceTimersByTime(DEFAULT_UTILITY_RECONCILE_INTERVAL_MS - 1);
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(1);
+ vi.advanceTimersByTime(1);
+
+ await expect(outcome).resolves.toBe(expected);
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ }
+
+ // A first run may still be downloading models at the interval: the watch reads again later instead of giving up.
+ const fake = createFakeHub();
+ const readItemStatus = vi.fn(() => Promise.resolve({ status: 'in_progress' }));
+ const submission = await submitUtilityGraph({
+ createId: () => UTIL_ID,
+ enqueue: okEnqueue,
+ graph,
+ hub: fake.hub,
+ readItemStatus,
+ });
+ const outcome = submission.settled.then(
+ () => 'resolved',
+ (error: UtilityQueueError) => error.reason
+ );
+
+ await vi.advanceTimersByTimeAsync(DEFAULT_UTILITY_RECONCILE_INTERVAL_MS);
+ expect(readItemStatus).toHaveBeenCalledTimes(1);
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(1);
+ expect(vi.getTimerCount()).toBe(1);
+
+ readItemStatus.mockResolvedValueOnce({ status: 'completed' });
+ await vi.advanceTimersByTimeAsync(DEFAULT_UTILITY_RECONCILE_INTERVAL_MS);
+
+ await expect(outcome).resolves.toBe('resolved');
+ expect(readItemStatus).toHaveBeenCalledTimes(2);
+ expect(fake.handlerCount('queue_item_status_changed')).toBe(0);
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts
index 0f2d26ee0a5..94fdab2080c 100644
--- a/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts
+++ b/invokeai/frontend/webv2/src/features/queue/runtime/utilityGraph.ts
@@ -4,14 +4,18 @@
*/
import type { QueueBackendGraph } from '@features/queue/core/types';
-import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@features/queue/data/events';
+import type {
+ InvocationCompleteEvent,
+ QueueItemsCanceledEvent,
+ QueueItemStatusChangedEvent,
+} from '@features/queue/data/events';
import type { SocketHub } from '@platform/transport/socketHub';
import { buildUtilityQueueItemOrigin } from '@features/queue/data/events';
import { cancelQueueItems, getQueueItem } from '@features/queue/data/serverApi';
import { enqueueUtility } from '@features/queue/data/submissionApi';
import { createUuid } from '@platform/browser/randomUuid';
-import { ApiError } from '@platform/transport/http';
+import { ApiError, getApiErrorMessage } from '@platform/transport/http';
/** The default time a utility graph may run before it is abandoned. */
export const DEFAULT_UTILITY_QUEUE_TIMEOUT_MS = 120_000;
@@ -19,9 +23,18 @@ export const DEFAULT_UTILITY_QUEUE_TIMEOUT_MS = 120_000;
/** Bounded retries for transient completed-item reconciliation failures. */
export const DEFAULT_UTILITY_RECONCILE_RETRY_POLICY = { delayMs: 100, maxAttempts: 3 } as const;
-/** Thrown when a utility graph fails, is canceled, times out, or is aborted. */
+/** Thrown when a utility graph fails, is canceled, times out, is removed from the queue, or is aborted. */
export class UtilityQueueError extends Error {
- readonly reason: 'failed' | 'canceled' | 'timeout' | 'aborted' | 'no-output' | 'enqueue' | 'reconcile' | 'setup';
+ readonly reason:
+ | 'failed'
+ | 'canceled'
+ | 'timeout'
+ | 'aborted'
+ | 'removed'
+ | 'no-output'
+ | 'enqueue'
+ | 'reconcile'
+ | 'setup';
constructor(reason: UtilityQueueError['reason'], message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause });
@@ -34,6 +47,7 @@ export class UtilityQueueError extends Error {
export type UtilityEnqueue = (request: {
graph: QueueBackendGraph;
origin: string;
+ prepend?: boolean;
}) => Promise<{ itemIds: number[]; enqueued: number }>;
/** The cancellation seam: best-effort cancels accepted backend item ids. */
@@ -102,12 +116,17 @@ export interface RunUtilityGraphOptions {
* When omitted, the first image-bearing completion for our origin is used.
*/
outputNodeId?: string;
- /** The socket hub (only `on` is used). Raw listeners survive socket recreation. */
- hub: Pick;
+ /**
+ * The socket hub. Raw listeners survive socket recreation. A queue clear, a bulk cancel or delete, and, when
+ * observable, a reconnect re-read the item in case it settled or was deleted without a status event.
+ */
+ hub: UtilityHub;
/** Enqueue seam (defaults to the real utility enqueue API). */
enqueue?: UtilityEnqueue;
/** Cancellation seam (defaults to the real queue cancellation API). */
cancel?: UtilityCancel;
+ /** Item status seam for reconciliation (defaults to the queue item API). */
+ readItemStatus?: UtilityItemStatusReader;
/** Abandon after this many ms (default {@link DEFAULT_UTILITY_QUEUE_TIMEOUT_MS}). `0` disables. */
timeoutMs?: number;
/** Cancels the await and best-effort cancels accepted backend items. */
@@ -189,6 +208,332 @@ export const reconcileUtilityCompletedOutput: UtilityCompletedOutputReconciler =
return null;
};
+/**
+ * How long a submitted utility graph goes unheard from before its item is read again; the first run may download
+ * models. An item still open at the read re-arms it, so the watch never gives up: it ends only on a terminal status,
+ * the item's removal, or an abort.
+ */
+export const DEFAULT_UTILITY_RECONCILE_INTERVAL_MS = 30 * 60_000;
+
+/**
+ * The outcome a terminal queue status means for a utility item: `null` for completion, the error for failure or
+ * cancellation, and `undefined` while the item is still open.
+ */
+const toTerminalUtilityOutcome = (
+ status: QueueItemStatusChangedEvent['status'],
+ errorMessage: unknown,
+ errorType: unknown
+): UtilityQueueError | null | undefined => {
+ if (status === 'completed') {
+ return null;
+ }
+ if (status === 'failed') {
+ const message = typeof errorMessage === 'string' ? errorMessage.trim() : '';
+ const type = typeof errorType === 'string' ? errorType.trim() : '';
+ return new UtilityQueueError('failed', message || type || 'The utility graph failed.');
+ }
+ if (status === 'canceled') {
+ return new UtilityQueueError('canceled', 'The utility graph was canceled.');
+ }
+ return undefined;
+};
+
+/** Reads one accepted item's status when a socket event may have been missed; rejects with a 404 once it is gone. */
+export type UtilityItemStatusReader = (
+ itemId: number,
+ signal?: AbortSignal
+) => Promise<{ status: QueueItemStatusChangedEvent['status']; errorMessage?: unknown; errorType?: unknown }>;
+
+const readUtilityItemStatus: UtilityItemStatusReader = async (itemId, signal) => {
+ const item = await getQueueItem(itemId, signal);
+ return { errorMessage: item.error_message, errorType: item.error_type, status: item.status };
+};
+
+/** The socket hub a utility watch needs; a reconnect is only observable when it can report connection changes. */
+type UtilityHub = Pick & Partial>;
+
+interface UtilityItemWatch {
+ /** Records the accepted item ids and runs any read requested before they were known. */
+ accept: (itemIds: number[]) => void;
+ /** Detaches every listener; no outcome is reported afterwards. */
+ detach: () => void;
+ /** Reads each accepted item, reporting a terminal status or, on a 404, its removal. */
+ read: () => Promise;
+}
+
+/**
+ * Watches one utility origin and reports each terminal outcome it learns of until detached: `null` for completion,
+ * the error for failure, cancellation, or removal; callers settle once. Status events report directly. Events that
+ * can settle or delete an item without a status event for it re-read the accepted items instead: a queue clear, a
+ * bulk cancel or delete naming one of them (`queue_items_canceled` carries only the owner's ids), and a reconnect
+ * after a disconnect.
+ */
+const watchUtilityItems = ({
+ hub,
+ onOutcome,
+ origin,
+ readItemStatus,
+ signal,
+}: {
+ hub: UtilityHub;
+ onOutcome: (error: UtilityQueueError | null) => void;
+ origin: string;
+ readItemStatus: UtilityItemStatusReader;
+ signal?: AbortSignal;
+}): UtilityItemWatch => {
+ let detached = false;
+ let itemIds: number[] = [];
+ let readRequestedBeforeAccept = false;
+ const detachers: Array<() => void> = [];
+
+ const detach = (): void => {
+ detached = true;
+ for (const detachListener of detachers.splice(0)) {
+ try {
+ detachListener();
+ } catch {
+ // Settlement must not depend on the listeners detaching cleanly.
+ }
+ }
+ };
+
+ const read = async (): Promise => {
+ for (const itemId of itemIds) {
+ if (detached) {
+ return;
+ }
+ try {
+ const item = await readItemStatus(itemId, signal);
+ const outcome = detached ? undefined : toTerminalUtilityOutcome(item.status, item.errorMessage, item.errorType);
+ if (outcome !== undefined) {
+ onOutcome(outcome);
+ return;
+ }
+ } catch (cause) {
+ if (!detached && cause instanceof ApiError && cause.status === 404) {
+ // A queue clear or bulk delete removes a pending item without a terminal event.
+ onOutcome(new UtilityQueueError('removed', 'The utility graph item was removed from the queue.', cause));
+ return;
+ }
+ // Unreadable now; the socket or a later read can still settle it.
+ }
+ }
+ };
+
+ const requestRead = (): void => {
+ if (itemIds.length === 0) {
+ // The event raced the enqueue; read the item once its id is known.
+ readRequestedBeforeAccept = true;
+ return;
+ }
+ void read();
+ };
+
+ try {
+ detachers.push(
+ hub.on('queue_item_status_changed', (event: QueueItemStatusChangedEvent) => {
+ if (event.origin !== origin) {
+ return;
+ }
+ const outcome = toTerminalUtilityOutcome(event.status, event.error_message, event.error_type);
+ if (outcome !== undefined) {
+ onOutcome(outcome);
+ }
+ })
+ );
+ detachers.push(hub.on('queue_cleared', requestRead));
+ detachers.push(
+ hub.on('queue_items_canceled', (event: QueueItemsCanceledEvent) => {
+ if (itemIds.length === 0 || event.canceled_item_ids.some((itemId) => itemIds.includes(itemId))) {
+ requestRead();
+ }
+ })
+ );
+ if (hub.onConnectionChange) {
+ let wasDisconnected = false;
+ detachers.push(
+ hub.onConnectionChange((status) => {
+ if (status === 'disconnected') {
+ wasDisconnected = true;
+ } else if (status === 'connected' && wasDisconnected) {
+ wasDisconnected = false;
+ requestRead();
+ }
+ })
+ );
+ }
+ } catch (cause) {
+ detach();
+ throw cause;
+ }
+
+ return {
+ accept: (acceptedItemIds) => {
+ itemIds = acceptedItemIds;
+ if (readRequestedBeforeAccept) {
+ readRequestedBeforeAccept = false;
+ void read();
+ }
+ },
+ detach,
+ read,
+ };
+};
+
+/** Dependencies for {@link submitUtilityGraph}. */
+export interface SubmitUtilityGraphOptions {
+ /** The graph to enqueue; its outputs stay wherever the graph sends them (a board, intermediates). */
+ graph: QueueBackendGraph;
+ /**
+ * The socket hub. A queue clear, a bulk cancel or delete, and, when observable, a reconnect re-read the item in
+ * case its settlement was missed or it was deleted without a terminal event.
+ */
+ hub: UtilityHub;
+ /** Put the item ahead of queued generations. */
+ prepend?: boolean;
+ /** Enqueue seam (defaults to the real utility enqueue API). */
+ enqueue?: UtilityEnqueue;
+ /** Cancellation seam for items an inconsistent enqueue response still accepted (defaults to the queue API). */
+ cancel?: UtilityCancel;
+ /** Item status seam for reconciliation (defaults to the queue item API). */
+ readItemStatus?: UtilityItemStatusReader;
+ /** Injectable id source (defaults to `createUuid`). */
+ createId?: () => string;
+ /**
+ * Ends the watch (listener and timer) with an `aborted` settlement, for example when the account changes. The
+ * backend item is never canceled: the caller only asked to stop watching.
+ */
+ signal?: AbortSignal;
+}
+
+/** An accepted utility submission whose results are not returned, only awaited. */
+export interface UtilityGraphSubmission {
+ /** The accepted backend item ids. */
+ itemIds: number[];
+ /** The origin used, for diagnostics/tests. */
+ origin: string;
+ /**
+ * Resolves once the backend completes the item; rejects with {@link UtilityQueueError} when it fails, is
+ * canceled, is removed from the queue (`removed`), or the watch is aborted.
+ */
+ settled: Promise;
+}
+
+/**
+ * Enqueue a utility graph whose results land where the graph sends them (such as a gallery board) rather than
+ * being returned. Resolves when the backend accepts it; `settled` tracks the item afterwards. Rejects as
+ * {@link UtilityQueueError} when the enqueue fails or its response is inconsistent.
+ */
+export const submitUtilityGraph = async (options: SubmitUtilityGraphOptions): Promise => {
+ const { graph, hub, prepend, signal } = options;
+ const enqueue = options.enqueue ?? enqueueUtility;
+ const cancel = options.cancel ?? cancelQueueItems;
+ const readItemStatus = options.readItemStatus ?? readUtilityItemStatus;
+ let origin: string;
+ try {
+ origin = buildUtilityQueueItemOrigin((options.createId ?? createUuid)());
+ } catch (cause) {
+ const detail = cause instanceof Error ? cause.message : String(cause);
+ throw new UtilityQueueError('setup', `Failed to initialize utility graph submission: ${detail}`, cause);
+ }
+
+ let settleResolve!: () => void;
+ let settleReject!: (error: UtilityQueueError) => void;
+ const settled = new Promise((resolve, reject) => {
+ settleResolve = resolve;
+ settleReject = reject;
+ });
+ // The watch can settle (abort, a fast terminal event) before the caller holds `settled`, and an enqueue failure
+ // never hands it over at all; callers still observe the rejection through their own handlers.
+ settled.catch(() => {});
+ let isSettled = false;
+ let timer: ReturnType | null = null;
+ const detachers: Array<() => void> = [];
+
+ const finish = (error: UtilityQueueError | null): void => {
+ if (isSettled) {
+ return;
+ }
+ isSettled = true;
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ for (const detach of detachers) {
+ try {
+ detach();
+ } catch {
+ // Settlement must not depend on the listeners detaching cleanly.
+ }
+ }
+ detachers.length = 0;
+ if (error) {
+ settleReject(error);
+ } else {
+ settleResolve();
+ }
+ };
+
+ const onAbort = (): void => finish(new UtilityQueueError('aborted', 'The utility graph watch was aborted.'));
+
+ const fail = (error: UtilityQueueError): never => {
+ finish(error);
+ throw error;
+ };
+
+ // Attach before enqueue so a very fast completion is not missed.
+ let watch!: UtilityItemWatch;
+ try {
+ watch = watchUtilityItems({ hub, onOutcome: finish, origin, readItemStatus, signal });
+ detachers.push(watch.detach);
+ if (signal) {
+ if (signal.aborted) {
+ fail(new UtilityQueueError('aborted', 'The utility graph watch was aborted.'));
+ }
+ signal.addEventListener('abort', onAbort);
+ detachers.push(() => signal.removeEventListener('abort', onAbort));
+ }
+ } catch (cause) {
+ if (cause instanceof UtilityQueueError) {
+ throw cause;
+ }
+ const detail = cause instanceof Error ? cause.message : String(cause);
+ fail(new UtilityQueueError('setup', `Failed to attach utility graph listeners: ${detail}`, cause));
+ }
+
+ /** An item still open at the periodic read re-arms it. */
+ const armReconcileTimer = (): void => {
+ if (isSettled) {
+ return;
+ }
+ timer = setTimeout(() => {
+ timer = null;
+ void watch.read().then(armReconcileTimer);
+ }, DEFAULT_UTILITY_RECONCILE_INTERVAL_MS);
+ };
+
+ let result: Awaited>;
+ try {
+ result = await enqueue({ graph, origin, prepend });
+ } catch (cause) {
+ fail(new UtilityQueueError('enqueue', getApiErrorMessage(cause, 'Failed to enqueue utility graph.'), cause));
+ }
+
+ const inspected = inspectUtilityEnqueueResult(result!);
+ if (!inspected.result) {
+ if (inspected.itemIds.length > 0) {
+ // Whatever the backend did accept must not run unwatched; cancellation is best-effort.
+ void cancel(inspected.itemIds).catch(() => {});
+ }
+ fail(new UtilityQueueError('enqueue', 'The backend returned an inconsistent utility enqueue response.'));
+ }
+ const { itemIds } = inspected.result!;
+ watch.accept(itemIds);
+ armReconcileTimer();
+
+ return { itemIds, origin, settled };
+};
+
/**
* Return utility output metadata without project routing; reject failures, cancellation, timeout, abort, and
* enqueue errors as {@link UtilityQueueError}.
@@ -197,6 +542,7 @@ export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise void) | null = null;
const detachers: Array<() => void> = [];
+ let watch: UtilityItemWatch | null = null;
let timer: ReturnType | null = null;
const cancelAcceptedItems = (): void => {
@@ -422,23 +769,21 @@ export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise {
- if (event.origin !== origin) {
+ watch = watchUtilityItems({
+ hub,
+ onOutcome: (error) => {
+ if (error) {
+ settleReject(error);
return;
}
- if (event.status === 'completed') {
- completionReceived = true;
- reconcileCompletion();
- } else if (event.status === 'failed') {
- const errorMessage = typeof event.error_message === 'string' ? event.error_message.trim() : '';
- const errorType = typeof event.error_type === 'string' ? event.error_type.trim() : '';
- settleReject(new UtilityQueueError('failed', errorMessage || errorType || 'The utility graph failed.'));
- } else if (event.status === 'canceled') {
- settleReject(new UtilityQueueError('canceled', 'The utility graph was canceled.'));
- }
- })
- );
+ completionReceived = true;
+ reconcileCompletion();
+ },
+ origin,
+ readItemStatus,
+ signal,
+ });
+ detachers.push(watch.detach);
} catch (cause) {
const detail = cause instanceof Error ? cause.message : String(cause);
settleReject(new UtilityQueueError('setup', `Failed to attach utility graph listeners: ${detail}`, cause));
@@ -486,6 +831,7 @@ export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise {
settleReject(
- new UtilityQueueError(
- 'enqueue',
- error instanceof Error ? error.message : 'Failed to enqueue utility graph.',
- error
- )
+ new UtilityQueueError('enqueue', getApiErrorMessage(error, 'Failed to enqueue utility graph.'), error)
);
});
});
diff --git a/invokeai/frontend/webv2/src/features/queue/utility.ts b/invokeai/frontend/webv2/src/features/queue/utility.ts
index d39f110043a..dfa43292508 100644
--- a/invokeai/frontend/webv2/src/features/queue/utility.ts
+++ b/invokeai/frontend/webv2/src/features/queue/utility.ts
@@ -1,7 +1,9 @@
/** One-shot Queue execution interface for utility graphs such as Canvas filters. */
export {
runUtilityGraph,
+ submitUtilityGraph,
UtilityQueueError,
type RunUtilityGraphOptions,
type UtilityGraphResult,
+ type UtilityGraphSubmission,
} from './runtime/utilityGraph';
diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx
index a467d6a3cd8..031351e2bdb 100644
--- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.browser.test.tsx
@@ -23,6 +23,7 @@ import { EMPTY_IMAGE_RECALL_CAPABILITIES } from './imageRecall';
const NO_BOARDS: [] = [];
const LOAD_WORKFLOW = 'widgets.gallery.itemActions.loadWorkflow.label';
+const GENERATE_PBR_MAPS = 'widgets.gallery.itemActions.pbrMaps.label';
const findGalleryItem = vi.fn();
@@ -106,6 +107,7 @@ const createActions = (deleteItems: ImageActions['deleteItems']): ImageActions =
downloadItems: vi.fn(() => Promise.resolve()),
downloadImage: vi.fn(() => Promise.resolve()),
downloadImages: vi.fn(() => Promise.resolve()),
+ generatePbrMaps: vi.fn(() => Promise.resolve()),
getImageRecallCapabilities: vi.fn(() => Promise.resolve(EMPTY_IMAGE_RECALL_CAPABILITIES)),
loadImageWorkflow: vi.fn(() => Promise.resolve()),
moveItemsToBoard: vi.fn(() => Promise.resolve()),
@@ -342,6 +344,25 @@ describe('ImageContextMenu load workflow', () => {
});
});
+describe('ImageContextMenu PBR maps', () => {
+ it('hands a single image to the PBR maps action', async () => {
+ const actions = createActions(vi.fn());
+ await renderMenu(actions, [image('texture.png')]);
+
+ await interact(() => getMenuItem(GENERATE_PBR_MAPS).click());
+
+ expect(actions.generatePbrMaps).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ imageName: 'texture.png' })
+ );
+ });
+
+ it('is absent from bulk menus, which queue nothing per image', async () => {
+ await renderMenu(createActions(vi.fn()), [image('a.png'), image('b.png')]);
+
+ expect(document.body.textContent).not.toContain(GENERATE_PBR_MAPS);
+ });
+});
+
describe('ImageContextMenu starred state', () => {
const starIconFill = (label: string): string =>
getComputedStyle(document.querySelector(`[aria-label="${label}"] svg`)!).fill;
diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx
index c6f1ab1bbaa..fa566b12b16 100644
--- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx
@@ -28,6 +28,7 @@ import {
ClapperboardIcon,
AsteriskIcon,
AudioLinesIcon,
+ BoxIcon,
ChevronRightIcon,
CopyIcon,
CrosshairIcon,
@@ -772,6 +773,7 @@ const SingleImageMenuItems = ({
const handleUseAsReferenceImage = useUseAsReferenceImageHandler(actions, image);
const handleUseAsControl = useCallback(() => void actions.sendImageAsControl(image), [actions, image]);
const handleLoadWorkflow = useCallback(() => void actions.loadImageWorkflow(image), [actions, image]);
+ const handleGeneratePbrMaps = useCallback(() => void actions.generatePbrMaps(image), [actions, image]);
const { generation, widgets } = useWorkbenchCommands();
const openWidget = useOpenWorkbenchWidget();
const handleSendToUpscale = useCallback(() => {
@@ -890,6 +892,12 @@ const SingleImageMenuItems = ({
value="select-for-compare"
onClick={handleSelectForCompare}
/>
+
diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.test.ts
new file mode 100644
index 00000000000..d29bf93652d
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildPbrMapsGraph, PBR_MAPS_NODE_ID } from './pbrMapsGraph';
+
+describe('buildPbrMapsGraph', () => {
+ it('emits one durable pbr_maps node over the image, destined for the given board', () => {
+ const graph = buildPbrMapsGraph({ boardId: 'board-7', imageName: 'texture.png' });
+
+ expect(graph.edges).toEqual([]);
+ expect(Object.keys(graph.nodes)).toEqual([PBR_MAPS_NODE_ID]);
+ expect(graph.nodes[PBR_MAPS_NODE_ID]).toEqual({
+ board: { board_id: 'board-7' },
+ border_mode: 'none',
+ id: PBR_MAPS_NODE_ID,
+ image: { image_name: 'texture.png' },
+ is_intermediate: false,
+ tile_size: 512,
+ type: 'pbr_maps',
+ use_cache: false,
+ });
+ });
+
+ it('leaves the maps Uncategorized when no board is auto-adding', () => {
+ for (const boardId of [null, 'none']) {
+ expect(buildPbrMapsGraph({ boardId, imageName: 'texture.png' }).nodes[PBR_MAPS_NODE_ID]).not.toHaveProperty(
+ 'board'
+ );
+ }
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.ts b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.ts
new file mode 100644
index 00000000000..578068fe779
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/image-actions/pbrMapsGraph.ts
@@ -0,0 +1,30 @@
+import type { QueueBackendGraph } from '@features/queue/contracts';
+
+export const PBR_MAPS_NODE_ID = 'pbr_maps';
+
+/**
+ * One `pbr_maps` node over a gallery image: whole-image tiles with no seam handling, saved to the board as the
+ * normal, roughness and displacement maps. `boardId` null or `'none'` leaves them Uncategorized.
+ */
+export const buildPbrMapsGraph = ({
+ boardId,
+ imageName,
+}: {
+ boardId: string | null;
+ imageName: string;
+}): QueueBackendGraph => ({
+ edges: [],
+ id: 'pbr-maps-graph',
+ nodes: {
+ [PBR_MAPS_NODE_ID]: {
+ id: PBR_MAPS_NODE_ID,
+ type: 'pbr_maps',
+ ...(boardId !== null && boardId !== 'none' ? { board: { board_id: boardId } } : {}),
+ border_mode: 'none',
+ image: { image_name: imageName },
+ is_intermediate: false,
+ tile_size: 512,
+ use_cache: false,
+ },
+ },
+});
diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx
index 355a4628918..814b8424de1 100644
--- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx
@@ -1,4 +1,5 @@
import type { GalleryImage, GalleryItem, GalleryItemKey, GalleryItemRef } from '@features/gallery';
+import type * as QueueUtilityModule from '@features/queue/utility';
import type { InvocationTemplatesSnapshot } from '@features/workflow/contracts';
import type * as WorkflowReact from '@features/workflow/react';
import type { CreateCanvasFromImagesResult } from '@workbench/canvas-operations/api';
@@ -12,7 +13,9 @@ import {
seedArchitectureCapabilities,
} from '@features/generation/core/architectureCapabilities.testing';
import { parseOpenApiToTemplates } from '@features/workflow/react';
+import { clearLogs, getLogSnapshot } from '@platform/logging/logger';
import { accountLifecycle } from '@platform/state/accountLifecycle';
+import { ApiError, getApiErrorMessage } from '@platform/transport/http';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, createRef, type Ref, useImperativeHandle } from 'react';
import { createRoot, type Root } from 'react-dom/client';
@@ -24,6 +27,7 @@ import { useImageActions } from './useImageActions';
const mocks = vi.hoisted(() => ({
addToBoard: vi.fn(),
+ canAddToCachedGalleryBoard: vi.fn((..._args: unknown[]) => true),
createCanvasFromImages: vi.fn((..._args: unknown[]): Promise =>
Promise.resolve({ projectId: null, status: 'empty' })
),
@@ -31,6 +35,7 @@ const mocks = vi.hoisted(() => ({
deleteImages: vi.fn(),
downloadArchive: vi.fn(),
downloadBlob: vi.fn(),
+ ensureGalleryBoardDestinations: vi.fn((): Promise | null => null),
galleryRemoveItems: vi.fn(),
galleryPatchItems: vi.fn(),
galleryWidgetsPatchValues: vi.fn(),
@@ -52,11 +57,13 @@ const mocks = vi.hoisted(() => ({
Promise.resolve({ graph: null, workflow: null })
),
requestWorkflowDocumentLoad: vi.fn(),
+ submitUtilityGraph: vi.fn((..._args: unknown[]): Promise => Promise.reject(new Error('not submitted'))),
videoMetadata: vi.fn((..._args: unknown[]) => Promise.resolve(null)),
videoWorkflow: vi.fn((..._args: unknown[]): Promise<{ graph: string | null; workflow: string | null }> =>
Promise.resolve({ graph: null, workflow: null })
),
invalidateGallery: vi.fn(),
+ invalidateGalleryBoardDestinations: vi.fn(),
invalidateGalleryItems: vi.fn(),
itemDelete: vi.fn(),
itemMoveToBoard: vi.fn(),
@@ -112,6 +119,12 @@ vi.mock('@features/gallery', () => ({
toGalleryItemRef: ({ kind, name }: { kind: 'image' | 'video'; name: string }) => ({ kind, name }),
}));
+vi.mock('@features/queue/utility', async (importOriginal) => ({
+ ...(await importOriginal()),
+ submitUtilityGraph: (...args: unknown[]) => mocks.submitUtilityGraph(...args),
+}));
+vi.mock('@platform/transport/socketHub', () => ({ socketHub: { on: () => () => {} } }));
+
vi.mock('@features/workflow/react', async (importOriginal) => ({
...(await importOriginal()),
getInvocationTemplatesSnapshot: () => mocks.getInvocationTemplatesSnapshot(),
@@ -126,9 +139,12 @@ vi.mock('@features/workflow/preview', async () => {
});
vi.mock('@features/gallery/queries', () => ({
+ canAddToCachedGalleryBoard: (...args: unknown[]) => mocks.canAddToCachedGalleryBoard(...args),
+ ensureGalleryBoardDestinations: () => mocks.ensureGalleryBoardDestinations(),
getGalleryItemBoardIdsFromCaches: (...args: unknown[]) => mocks.getItemBoardIds(...args),
getGalleryItemStarredFromCaches: (...args: unknown[]) => mocks.getItemStarred(...args),
invalidateGallery: (...args: unknown[]) => mocks.invalidateGallery(...args),
+ invalidateGalleryBoardDestinations: (...args: unknown[]) => mocks.invalidateGalleryBoardDestinations(...args),
invalidateGalleryItems: (...args: unknown[]) => mocks.invalidateGalleryItems(...args),
patchGalleryItemCaches: (...args: unknown[]) => mocks.patchGalleryItemCaches(...args),
}));
@@ -798,6 +814,261 @@ describe('load workflow from image', () => {
});
});
+describe('generate PBR maps', () => {
+ const image: GalleryImage = {
+ boardId: 'none',
+ height: 512,
+ imageCategory: 'general',
+ imageName: 'texture.png',
+ imageUrl: '/full/texture.png',
+ queuedAt: '2026-07-30T00:00:00.000Z',
+ sourceQueueItemId: 'backend-gallery',
+ starred: false,
+ thumbnailUrl: '/thumb/texture.png',
+ width: 512,
+ };
+ const createSettlement = () => {
+ let resolve!: () => void;
+ let reject!: (error: unknown) => void;
+ const settled = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { reject, resolve, settled };
+ };
+
+ it('queues one pbr_maps graph into the project auto-add board, then refreshes the gallery when it completes', async () => {
+ const settlement = createSettlement();
+ mocks.getSnapshot.mockImplementation(() => {
+ const project = makeMockProject('project-1', {
+ autoAddBoardId: 'board-materials',
+ selectedBoardId: 'board-other',
+ });
+
+ return { activeProject: project, projects: [project] };
+ });
+ mocks.submitUtilityGraph.mockResolvedValueOnce({ itemIds: [41], origin: 'utility:1', settled: settlement.settled });
+
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+
+ expect(mocks.submitUtilityGraph).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({
+ prepend: true,
+ signal: expect.any(AbortSignal),
+ graph: expect.objectContaining({
+ nodes: {
+ pbr_maps: expect.objectContaining({
+ board: { board_id: 'board-materials' },
+ image: { image_name: 'texture.png' },
+ is_intermediate: false,
+ type: 'pbr_maps',
+ }),
+ },
+ }),
+ })
+ );
+ expect(mocks.notificationsAdd).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({ kind: 'success', title: 'widgets.gallery.itemActions.pbrMaps.queued' })
+ );
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+
+ await act(async () => {
+ settlement.resolve();
+ await settlement.settled;
+ });
+
+ expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1);
+ expect(mocks.notificationsAdd).toHaveBeenLastCalledWith(
+ expect.objectContaining({ kind: 'success', title: 'widgets.gallery.itemActions.pbrMaps.completed' })
+ );
+ });
+
+ it('leaves the maps Uncategorized while the gallery follows no board', async () => {
+ mocks.getSnapshot.mockImplementation(() => {
+ const project = makeMockProject('project-1', { autoAddBoardId: 'none' });
+
+ return { activeProject: project, projects: [project] };
+ });
+ mocks.submitUtilityGraph.mockResolvedValueOnce({
+ itemIds: [42],
+ origin: 'utility:2',
+ settled: new Promise(() => {}),
+ });
+
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+
+ const [request] = mocks.submitUtilityGraph.mock.calls[0] as [{ graph: { nodes: Record } }];
+ expect(request.graph.nodes.pbr_maps).not.toHaveProperty('board');
+ });
+
+ it('waits for board permissions and leaves maps in the inbox when the destination is refused', async () => {
+ mocks.getSnapshot.mockImplementation(() => {
+ const project = makeMockProject('project-1', { autoAddBoardId: 'view-only' });
+
+ return { activeProject: project, projects: [project] };
+ });
+ const listing = createSettlement();
+ mocks.ensureGalleryBoardDestinations.mockReturnValueOnce(listing.settled);
+ mocks.canAddToCachedGalleryBoard.mockReturnValueOnce(false);
+ mocks.submitUtilityGraph.mockResolvedValueOnce({
+ itemIds: [48],
+ origin: 'utility:8',
+ settled: new Promise(() => {}),
+ });
+
+ await act(async () => {
+ const submission = actionsRef.current?.generatePbrMaps(image);
+
+ expect(mocks.submitUtilityGraph).not.toHaveBeenCalled();
+ listing.resolve();
+ await submission;
+ });
+
+ const [request] = mocks.submitUtilityGraph.mock.calls[0] as [{ graph: { nodes: Record } }];
+ expect(request.graph.nodes.pbr_maps).not.toHaveProperty('board');
+ expect(mocks.canAddToCachedGalleryBoard).toHaveBeenCalledWith('view-only', expect.any(QueryClient));
+ });
+
+ it('does not submit if the account changes while board permissions load', async () => {
+ const listing = createSettlement();
+ mocks.ensureGalleryBoardDestinations.mockReturnValueOnce(listing.settled);
+
+ await act(async () => {
+ const submission = actionsRef.current?.generatePbrMaps(image);
+
+ accountLifecycle.activate('user-b');
+ listing.resolve();
+ await submission;
+ });
+
+ expect(mocks.submitUtilityGraph).not.toHaveBeenCalled();
+ expect(mocks.notificationsAdd).not.toHaveBeenCalled();
+ });
+
+ it('reports an enqueue failure and a failed run as logged PBR error notices', async () => {
+ const { UtilityQueueError } = await import('@features/queue/utility');
+ clearLogs();
+ // The runtime wraps the backend's JSON refusal as an ApiError cause and derives the readable message the way
+ // `submitUtilityGraph` does; the toast must show that detail, never the JSON.
+ const cause = new ApiError(JSON.stringify({ detail: 'queue paused' }), 409);
+ mocks.submitUtilityGraph.mockRejectedValueOnce(
+ new UtilityQueueError('enqueue', getApiErrorMessage(cause, 'Failed to enqueue utility graph.'), cause)
+ );
+
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+
+ expect(mocks.notificationsAdd).toHaveBeenCalledExactlyOnceWith({
+ kind: 'error',
+ message: 'queue paused',
+ title: 'widgets.gallery.itemActions.pbrMaps.failed',
+ });
+
+ const settlement = createSettlement();
+ mocks.submitUtilityGraph.mockResolvedValueOnce({ itemIds: [43], origin: 'utility:3', settled: settlement.settled });
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+ await act(async () => {
+ settlement.reject(new UtilityQueueError('failed', 'No PBR model'));
+ await settlement.settled.catch(() => {});
+ });
+
+ // A run that fails after it was queued names the action, as an enqueue failure does.
+ expect(mocks.notificationsAdd).toHaveBeenLastCalledWith({
+ kind: 'error',
+ message: 'No PBR model',
+ title: 'widgets.gallery.itemActions.pbrMaps.failed',
+ });
+ expect(mocks.reportError).not.toHaveBeenCalled();
+ expect(
+ getLogSnapshot()
+ .entries.filter((entry) => entry.source.area === 'image-actions')
+ .map(({ level, message, namespace }) => ({ level, message, namespace }))
+ ).toEqual([
+ { level: 'error', message: 'No PBR model', namespace: 'gallery' },
+ { level: 'error', message: 'queue paused', namespace: 'gallery' },
+ ]);
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+ });
+
+ it('turns a canceled or removed item into an info notice, and says nothing once the watch is aborted', async () => {
+ const { UtilityQueueError } = await import('@features/queue/utility');
+ const canceled = createSettlement();
+ mocks.submitUtilityGraph.mockResolvedValueOnce({ itemIds: [44], origin: 'utility:4', settled: canceled.settled });
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+ await act(async () => {
+ canceled.reject(new UtilityQueueError('canceled', 'The utility graph was canceled.'));
+ await canceled.settled.catch(() => {});
+ });
+
+ expect(mocks.notificationsAdd).toHaveBeenLastCalledWith(
+ expect.objectContaining({ kind: 'info', title: 'widgets.gallery.itemActions.pbrMaps.canceled' })
+ );
+ expect(mocks.reportError).not.toHaveBeenCalled();
+
+ // A queue clear deletes a pending item without a terminal event; the watch reports it as removed.
+ const removed = createSettlement();
+ mocks.submitUtilityGraph.mockResolvedValueOnce({ itemIds: [47], origin: 'utility:7', settled: removed.settled });
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+ await act(async () => {
+ removed.reject(new UtilityQueueError('removed', 'The utility graph item was removed from the queue.'));
+ await removed.settled.catch(() => {});
+ });
+
+ expect(mocks.notificationsAdd).toHaveBeenLastCalledWith({
+ kind: 'info',
+ message: 'widgets.gallery.itemActions.pbrMaps.removedMessage',
+ title: 'widgets.gallery.itemActions.pbrMaps.removed',
+ });
+ // The maps may already be in the gallery if the item completed before it was pruned.
+ expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1);
+ expect(mocks.reportError).not.toHaveBeenCalled();
+
+ const aborted = createSettlement();
+ mocks.notificationsAdd.mockClear();
+ mocks.submitUtilityGraph.mockResolvedValueOnce({ itemIds: [45], origin: 'utility:5', settled: aborted.settled });
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+ await act(async () => {
+ aborted.reject(new UtilityQueueError('aborted', 'The utility graph watch was aborted.'));
+ await aborted.settled.catch(() => {});
+ });
+
+ expect(mocks.notificationsAdd).toHaveBeenCalledTimes(1);
+ expect(mocks.reportError).not.toHaveBeenCalled();
+ });
+
+ it('suppresses every notice when the account changes while the graph is being accepted', async () => {
+ const settlement = createSettlement();
+ mocks.submitUtilityGraph.mockImplementationOnce(() => {
+ accountLifecycle.activate('user-b');
+ return Promise.resolve({ itemIds: [46], origin: 'utility:6', settled: settlement.settled });
+ });
+
+ await act(async () => {
+ await actionsRef.current?.generatePbrMaps(image);
+ });
+ await act(async () => {
+ settlement.resolve();
+ await settlement.settled;
+ });
+
+ expect(mocks.notificationsAdd).not.toHaveBeenCalled();
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+ });
+});
+
describe('video workflow loading', () => {
const video = {
boardId: 'none',
diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts
index 29cde6223d3..e12547558f3 100644
--- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts
+++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts
@@ -22,22 +22,28 @@ import {
type GalleryVideoItem,
} from '@features/gallery';
import {
+ getGalleryAutoAddBoardId,
getGalleryBoardLabel,
getGalleryDeletionSuccessor,
getPersistedSelectedGalleryItemKeys,
} from '@features/gallery/contracts';
import {
+ canAddToCachedGalleryBoard,
+ ensureGalleryBoardDestinations,
getGalleryItemBoardIdsFromCaches,
getGalleryItemStarredFromCaches,
invalidateGallery,
+ invalidateGalleryBoardDestinations,
patchGalleryItemCaches,
} from '@features/gallery/queries';
import { setPendingPromptTemplateDraft } from '@features/generation/react';
import { getArchitectureCapabilitiesSnapshot, subscribeArchitectureCapabilities } from '@features/generation/runtime';
import { getMaxReferenceImages, isVaeModelConfig, isSupportedGenerateModel } from '@features/generation/settings';
import { ensureModelsLoaded, useModelsSelector } from '@features/models';
+import { submitUtilityGraph, type UtilityGraphSubmission, UtilityQueueError } from '@features/queue/utility';
import { copyImage } from '@platform/browser/clipboard';
import { downloadBlob } from '@platform/browser/downloadBlob';
+import { createLogger } from '@platform/logging/logger';
import { useMountEffect } from '@platform/react/useMountEffect';
import {
assertAccountScopeCurrent,
@@ -46,6 +52,7 @@ import {
} from '@platform/state/accountLifecycle';
import { useExternalStoreSelector } from '@platform/state/selectors';
import { getApiErrorMessage } from '@platform/transport/http';
+import { socketHub } from '@platform/transport/socketHub';
import { useQueryClient } from '@tanstack/react-query';
import {
createCanvasFromImages,
@@ -93,6 +100,7 @@ import {
type ImageRecallKind,
} from './imageRecall';
import { collectMediaUsage } from './mediaUsage';
+import { buildPbrMapsGraph } from './pbrMapsGraph';
import {
EMPTY_VIDEO_RECALL_CAPABILITIES,
getVideoRecallCapabilities as deriveVideoRecallCapabilitiesFromMetadata,
@@ -122,6 +130,8 @@ export interface ImageActions extends GalleryItemActions {
) => ImageRecallCapabilities;
downloadImage: (image: GalleryImage) => Promise;
downloadImages: (imageNames: string[]) => Promise;
+ /** Queues normal, roughness and displacement maps of the image into the project's auto-add board. */
+ generatePbrMaps: (image: GalleryImage) => Promise;
getImageRecallCapabilities: (image: GalleryImage, signal?: AbortSignal) => Promise;
/** Opens the image's embedded workflow or generation graph in the workflow editor. */
loadImageWorkflow: (image: GalleryImage) => Promise;
@@ -904,6 +914,95 @@ export const useImageActions = ({
}
},
downloadImages: (imageNames) => downloadItems(imageNames.map((name) => ({ kind: 'image', name }))),
+ generatePbrMaps: async (image) => {
+ const owner = captureAccountScope();
+ const galleryValues = getLatestWidgetValues('gallery');
+ let submission: UtilityGraphSubmission;
+ const reportFailure = (error: unknown) => {
+ const message = toErrorMessage(error);
+
+ createLogger({ area: 'image-actions', namespace: 'gallery', projectId }, { owner }).error({ error, message });
+ notifications.add({ kind: 'error', message, title: t('widgets.gallery.itemActions.pbrMaps.failed') });
+ };
+
+ try {
+ const pendingBoards = ensureGalleryBoardDestinations(queryClient);
+
+ if (pendingBoards) {
+ await pendingBoards;
+ }
+ assertAccountScopeCurrent(owner);
+ const boardId = getGalleryAutoAddBoardId(galleryValues, (id) => canAddToCachedGalleryBoard(id, queryClient));
+
+ // Ahead of queued generations, as the legacy action was; the account signal ends the watch, not the item.
+ submission = await submitUtilityGraph({
+ graph: buildPbrMapsGraph({ boardId, imageName: image.imageName }),
+ hub: socketHub,
+ prepend: true,
+ signal: owner.signal,
+ });
+ } catch (error: unknown) {
+ if (isAccountScopeCurrent(owner)) {
+ void invalidateGalleryBoardDestinations(queryClient, owner);
+ reportFailure(error);
+ }
+ return;
+ }
+
+ // Handle settlement before anything else can throw, so an aborted watch never rejects unobserved. The
+ // utility item is not project work, so the queue runtime does not refresh the gallery for it.
+ void submission.settled.then(
+ () => {
+ if (!isAccountScopeCurrent(owner)) {
+ return;
+ }
+
+ void invalidateGallery(queryClient, owner);
+ recordSuccess(
+ t('widgets.gallery.itemActions.pbrMaps.completed'),
+ t('widgets.gallery.itemActions.pbrMaps.completedMessage', { name: image.imageName })
+ );
+ },
+ (error: unknown) => {
+ if (!isAccountScopeCurrent(owner)) {
+ return;
+ }
+ if (error instanceof UtilityQueueError && error.reason === 'canceled') {
+ notifications.add({
+ kind: 'info',
+ message: t('widgets.gallery.itemActions.pbrMaps.canceledMessage', { name: image.imageName }),
+ title: t('widgets.gallery.itemActions.pbrMaps.canceled'),
+ });
+ return;
+ }
+ // The item is gone without a terminal event: cleared while pending, or pruned after completing while
+ // the socket was down. Either way the gallery may hold its maps.
+ if (error instanceof UtilityQueueError && error.reason === 'removed') {
+ void invalidateGallery(queryClient, owner);
+ notifications.add({
+ kind: 'info',
+ message: t('widgets.gallery.itemActions.pbrMaps.removedMessage', { name: image.imageName }),
+ title: t('widgets.gallery.itemActions.pbrMaps.removed'),
+ });
+ return;
+ }
+ // An abort only ends the watch; the backend item keeps running.
+ if (!(error instanceof UtilityQueueError && error.reason === 'aborted')) {
+ void invalidateGalleryBoardDestinations(queryClient, owner);
+ reportFailure(error);
+ }
+ }
+ );
+
+ if (!isAccountScopeCurrent(owner)) {
+ return;
+ }
+
+ recordSuccess(
+ t('widgets.gallery.itemActions.pbrMaps.queued'),
+ t('widgets.gallery.itemActions.pbrMaps.queuedMessage', { name: image.imageName })
+ );
+ },
getImageRecallCapabilities: async (image, signal) => {
const owner = captureAccountScope();
const requestSignal = signal ? AbortSignal.any([signal, owner.signal]) : owner.signal;
diff --git a/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx b/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx
index a4e27c59f0c..75dcc95b891 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx
+++ b/invokeai/frontend/webv2/src/workbench/settings/CustomSettingsEditors.tsx
@@ -22,6 +22,7 @@ import { clearWorkspaceData, rememberWorkspaceClearFailure } from './clearWorksp
import { GenerationDevicesSettings } from './GenerationDevicesSettings';
import { HotkeysSettingsSection } from './HotkeysSettingsSection';
import { ImageMapVocabularySettings } from './ImageMapVocabularySettings';
+import { ImageStorageSettings } from './ImageStorageSettings';
import { MaxQueueHistorySettings } from './MaxQueueHistorySettings';
import {
clearWorkbenchSettings,
@@ -317,6 +318,9 @@ const CustomSettingField = ({ field, onReveal }: SettingFieldProps) => {
case 'imageMapVocabulary':
editor = ;
break;
+ case 'imageStorage':
+ editor = ;
+ break;
default:
return null;
}
diff --git a/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx
index f81b2ba58d5..e0ba94f10e0 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/workbench/settings/GalleryMaintenance.browser.test.tsx
@@ -283,6 +283,7 @@ describe('gallery maintenance in Data & workspace settings', () => {
afterEach(async () => {
await act(() => root.unmount());
await thumbnailRouteCommands.stopThumbnailRoute();
+ vi.restoreAllMocks();
host.remove();
queryClient.clear();
accountLifecycle.invalidate();
@@ -523,6 +524,14 @@ describe('gallery maintenance in Data & workspace settings', () => {
const owner = accountLifecycle.capture();
const action = operations[2];
await thumbnailRouteCommands.startThumbnailRoute(thumbnailItem.thumbnailUrl);
+ // The gallery's recovery probe asks about the failed thumbnail with fetch; answer it here as missing so it adds no
+ // request to the ones this test counts, and reloads nothing.
+ const browserFetch = globalThis.fetch;
+ vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) =>
+ String(input).includes(thumbnailItem.thumbnailUrl)
+ ? Promise.resolve(new Response(null, { status: 404 }))
+ : browserFetch(input, init)
+ );
mocks.apiFetchJson
.mockResolvedValueOnce(preview(action.operation, action.fingerprint))
.mockResolvedValueOnce(
diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx
new file mode 100644
index 00000000000..87688b64238
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.browser.test.tsx
@@ -0,0 +1,572 @@
+import type * as GalleryQueryCacheModule from '@features/gallery/data/queryCache';
+import type * as HttpModule from '@platform/transport/http';
+
+import { ChakraProvider } from '@chakra-ui/react';
+import { accountLifecycle } from '@platform/state/accountLifecycle';
+import { ApiError } from '@platform/transport/http';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { system } from '@theme/system';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { userEvent } from 'vitest/browser';
+
+import { ImageStorageSettings } from './ImageStorageSettings';
+
+const mocks = vi.hoisted(() => ({
+ apiFetchJson: vi.fn<(path: string, init?: RequestInit) => Promise>(),
+ canManageAppConfig: true,
+ invalidateGallery: vi.fn((..._args: unknown[]) => Promise.resolve()),
+}));
+
+vi.mock('@features/identity', () => ({ useCapabilities: () => ({ canManageAppConfig: mocks.canManageAppConfig }) }));
+vi.mock('@platform/transport/http', async (importOriginal) => ({
+ ...(await importOriginal()),
+ apiFetchJson: (path: string, init?: RequestInit) => mocks.apiFetchJson(path, init),
+}));
+vi.mock('@features/gallery/data/queryCache', async (importOriginal) => ({
+ ...(await importOriginal()),
+ invalidateGallery: (...args: unknown[]) => mocks.invalidateGallery(...args),
+}));
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, values?: Record) => [key, ...Object.values(values ?? {})].join(' '),
+ }),
+}));
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const STATUS_ROUTE = '/api/v1/image_moves/status';
+const CONFIG_ROUTE = '/api/v1/app/runtime_config';
+
+const idleStatus = (needsMoveCount: number) => ({
+ active_job_id: null,
+ is_running: false,
+ last_error: null,
+ latest_job: needsMoveCount === 0 ? { error_message: null, id: 2, state: 'committed' } : null,
+ needs_move_count: needsMoveCount,
+ operation: null,
+});
+const runningStatus = (operation: 'move_all' | 'recovery') => ({
+ active_job_id: 3,
+ is_running: true,
+ last_error: null,
+ latest_job: { error_message: null, id: 3, state: 'moving' },
+ needs_move_count: 12,
+ operation,
+});
+const interruptedStatus = {
+ active_job_id: 3,
+ is_running: false,
+ last_error: 'Server stopped during an image move',
+ latest_job: { error_message: null, id: 3, state: 'moving' },
+ needs_move_count: 12,
+ operation: null,
+};
+
+/** Routes answer from a mutable table so a test can change what the next poll sees. */
+const routes = new Map unknown>();
+const installRoutes = (table: Record unknown>) => {
+ routes.clear();
+ for (const [key, answer] of Object.entries(table)) {
+ routes.set(key, answer);
+ }
+ mocks.apiFetchJson.mockImplementation((path, init) => {
+ const answer = routes.get(`${init?.method ?? 'GET'} ${path}`);
+
+ if (!answer) {
+ return Promise.reject(new Error(`No route for ${init?.method ?? 'GET'} ${path}`));
+ }
+ const value = answer();
+
+ return value instanceof Error ? Promise.reject(value) : Promise.resolve(value);
+ });
+};
+const calls = (method: string, path: string) =>
+ mocks.apiFetchJson.mock.calls.filter(
+ ([calledPath, init]) => calledPath === path && (init?.method ?? 'GET') === method
+ );
+
+let host: HTMLDivElement;
+let root: Root;
+let queryClient: QueryClient;
+
+const render = async () => {
+ await act(() =>
+ root.render(
+
+
+
+
+
+ )
+ );
+};
+const text = () => host.textContent ?? '';
+const button = (label: string) =>
+ [...host.querySelectorAll('button')].find((candidate) => candidate.textContent?.trim() === label);
+const dialog = () => document.querySelector('[role="alertdialog"]');
+const confirmIn = (label: string) =>
+ [...(dialog()?.querySelectorAll('button') ?? [])].find((candidate) =>
+ candidate.textContent?.includes(label)
+ );
+const maintenanceGroup = () => host.querySelector('[role="group"][aria-labelledby]');
+const trigger = () => host.querySelector('[data-scope="select"][data-part="trigger"]');
+const refetchStatus = () => act(() => queryClient.refetchQueries({ queryKey: ['gallery', 'image-storage'] }));
+const createDeferred = () => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((res) => {
+ resolve = res;
+ });
+ return { promise, resolve };
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.canManageAppConfig = true;
+ accountLifecycle.activate('admin');
+ queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ host = document.createElement('div');
+ document.body.append(host);
+ root = createRoot(host);
+});
+
+afterEach(async () => {
+ await act(() => root.unmount());
+ host.remove();
+ queryClient.clear();
+});
+
+describe('image storage settings', () => {
+ it('shows a read-only notice to non-admins without reading admin-only routes', async () => {
+ mocks.canManageAppConfig = false;
+ await render();
+
+ expect(text()).toContain('settings.imageStorage.adminOnly');
+ expect(mocks.apiFetchJson).not.toHaveBeenCalled();
+ });
+
+ it('reads the layout and move status, and patches only the layout when it changes', async () => {
+ let layout = 'flat';
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: layout }, set_fields: [] }),
+ [`PATCH ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: layout }, set_fields: [] }),
+ [`GET ${STATUS_ROUTE}`]: () => idleStatus(layout === 'flat' ? 0 : 12),
+ });
+ await render();
+
+ await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.flat');
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0');
+ expect(text()).toContain('settings.imageStorage.latestJob settings.imageStorage.jobStates.committed');
+
+ await act(() => userEvent.click(trigger()!));
+ await expect.poll(() => document.querySelectorAll('[role="option"]').length).toBe(4);
+ const dateOption = [...document.querySelectorAll('[role="option"]')].find((option) =>
+ option.textContent?.includes('settings.imageStorage.layouts.date')
+ );
+ layout = 'date';
+ await act(() => userEvent.click(dateOption!));
+
+ await expect.poll(() => calls('PATCH', CONFIG_ROUTE).length).toBe(1);
+ expect(calls('PATCH', CONFIG_ROUTE)[0]?.[1]?.body).toBe(JSON.stringify({ image_subfolder_strategy: 'date' }));
+ await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.date');
+ // The count of images outside the layout is re-read after the change.
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12');
+ });
+
+ it('keeps the server layout and shows the refusal when the layout change is rejected', async () => {
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'flat' } }),
+ [`PATCH ${CONFIG_ROUTE}`]: () => new ApiError(JSON.stringify({ detail: 'Admin privileges required' }), 403),
+ [`GET ${STATUS_ROUTE}`]: () => idleStatus(0),
+ });
+ await render();
+ await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.flat');
+
+ await act(() => userEvent.click(trigger()!));
+ await expect.poll(() => document.querySelectorAll('[role="option"]').length).toBe(4);
+ const hashOption = [...document.querySelectorAll('[role="option"]')].find((option) =>
+ option.textContent?.includes('settings.imageStorage.layouts.hash')
+ );
+ await act(() => userEvent.click(hashOption!));
+
+ await expect.poll(() => host.querySelector('[role="alert"]')?.textContent).toBe('Admin privileges required');
+ expect(trigger()?.textContent).toContain('settings.imageStorage.layouts.flat');
+ expect(trigger()?.disabled).toBe(false);
+ expect(calls('GET', STATUS_ROUTE)).toHaveLength(1);
+ });
+
+ it('shows a layout this build does not know as itself instead of reading as flat', async () => {
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'by_user' } }),
+ [`GET ${STATUS_ROUTE}`]: () => idleStatus(0),
+ });
+ await render();
+
+ await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.unknown by_user');
+ await act(() => userEvent.click(trigger()!));
+ await expect.poll(() => document.querySelectorAll('[role="option"]').length).toBe(5);
+ expect(calls('PATCH', CONFIG_ROUTE)).toHaveLength(0);
+ });
+
+ it('starts a move after confirmation, polls it, and refreshes the gallery once it stops', async () => {
+ let status: unknown = idleStatus(12);
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => status,
+ [`POST /api/v1/image_moves/start`]: () => {
+ status = runningStatus('move_all');
+ return status;
+ },
+ });
+ await render();
+ await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false);
+
+ await act(() => userEvent.click(button('settings.imageStorage.startMove')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ expect(dialog()?.textContent).toContain('settings.imageStorage.startConfirmTitle');
+ expect(calls('POST', '/api/v1/image_moves/start')).toHaveLength(0);
+
+ await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!));
+ await expect.poll(() => calls('POST', '/api/v1/image_moves/start').length).toBe(1);
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusMoving');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(true);
+ expect(trigger()?.disabled).toBe(true);
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+
+ status = idleStatus(0);
+ await refetchStatus();
+
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0');
+ expect(mocks.invalidateGallery).toHaveBeenCalledTimes(1);
+ // Every image is in the layout now, so there is nothing left to start.
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(text()).toContain('settings.imageStorage.nothingToDo');
+ expect(trigger()?.disabled).toBe(false);
+ });
+
+ it('offers recovery for an interrupted job and reports a refused start', async () => {
+ let status: unknown = interruptedStatus;
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => status,
+ [`POST /api/v1/image_moves/recover`]: () => {
+ status = runningStatus('recovery');
+ return status;
+ },
+ });
+ await render();
+
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusNeedsRecovery');
+ expect(text()).toContain('Server stopped during an image move');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(false);
+
+ await act(() => userEvent.click(button('settings.imageStorage.recover')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ await act(() => userEvent.click(confirmIn('settings.imageStorage.recover')!));
+
+ await expect.poll(() => calls('POST', '/api/v1/image_moves/recover').length).toBe(1);
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusRecovering');
+ });
+
+ it('shows the server refusal when a move cannot start, and leaves the controls usable', async () => {
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => idleStatus(12),
+ [`POST /api/v1/image_moves/start`]: () => new ApiError(JSON.stringify({ detail: 'Queue work is active' }), 409),
+ });
+ await render();
+ await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false);
+
+ await act(() => userEvent.click(button('settings.imageStorage.startMove')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!));
+
+ await expect.poll(() => host.querySelector('[role="alert"]')?.textContent).toBe('Queue work is active');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ // The refusal describes the button it came from; the disabled Recover keeps only its own note.
+ const recoverNote = [...host.querySelectorAll('p')].find((node) =>
+ node.textContent?.includes('settings.imageStorage.recoverNotNeeded')
+ );
+ expect(button('settings.imageStorage.startMove')?.getAttribute('aria-describedby')).toBe(
+ host.querySelector('[role="alert"]')?.id
+ );
+ expect(button('settings.imageStorage.recover')?.getAttribute('aria-describedby')).toBe(recoverNote?.id);
+ expect(mocks.invalidateGallery).not.toHaveBeenCalled();
+ });
+
+ it('re-reads the status after a refused start and locks the controls on the move it reveals', async () => {
+ const reread = createDeferred();
+ let status: unknown = idleStatus(12);
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => status,
+ [`POST /api/v1/image_moves/start`]: () => {
+ // Another admin started a move after this editor last read the status.
+ status = reread.promise;
+ return new ApiError(JSON.stringify({ detail: 'An image move job is already running' }), 409);
+ },
+ });
+ await render();
+ await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ expect(calls('GET', STATUS_ROUTE)).toHaveLength(1);
+
+ await act(() => userEvent.click(button('settings.imageStorage.startMove')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!));
+ await expect.poll(() => calls('GET', STATUS_ROUTE).length).toBe(2);
+
+ // The cache still says idle, but nothing unlocks until the re-read lands.
+ expect(dialog()).not.toBeNull();
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(trigger()?.disabled).toBe(true);
+ expect(host.querySelector('[role="alert"]')).toBeNull();
+
+ await act(() => {
+ reread.resolve(runningStatus('move_all'));
+ });
+
+ await expect
+ .poll(() => host.querySelector('[role="alert"]')?.textContent)
+ .toBe('An image move job is already running');
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusMoving');
+ expect(calls('GET', STATUS_ROUTE)).toHaveLength(2);
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(true);
+ expect(trigger()?.disabled).toBe(true);
+ // Its opener is disabled now; the closed dialog hands focus to the named section reporting the refusal.
+ await expect.poll(() => dialog()).toBeNull();
+ await expect.poll(() => document.activeElement).toBe(maintenanceGroup());
+ });
+
+ it('drops a refusal once the move it was refused against ends', async () => {
+ let status: unknown = idleStatus(12);
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => status,
+ [`POST /api/v1/image_moves/start`]: () => {
+ // Another admin started a move after this editor last read the status.
+ status = runningStatus('move_all');
+ return new ApiError(JSON.stringify({ detail: 'An image move job is already running' }), 409);
+ },
+ });
+ await render();
+ await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false);
+
+ await act(() => userEvent.click(button('settings.imageStorage.startMove')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!));
+
+ const alert = () => host.querySelector('[role="alert"]');
+ await expect.poll(() => alert()?.textContent).toBe('An image move job is already running');
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusMoving');
+ expect(button('settings.imageStorage.startMove')?.getAttribute('aria-describedby')?.split(' ')).toContain(
+ alert()?.id
+ );
+
+ // The move journals one job per batch: polls during it see the job ids advance, with no active job between
+ // batches, and keep the refusal it explains.
+ for (const [activeJobId, latestJobId] of [
+ [null, 3],
+ [4, 4],
+ [null, 4],
+ [5, 5],
+ ] as const) {
+ status = {
+ ...runningStatus('move_all'),
+ active_job_id: activeJobId,
+ latest_job: { error_message: null, id: latestJobId, state: activeJobId === null ? 'committed' : 'moving' },
+ };
+ await refetchStatus();
+ expect(alert()?.textContent).toBe('An image move job is already running');
+ }
+
+ status = { ...idleStatus(12), latest_job: { error_message: null, id: 5, state: 'committed' } };
+ await refetchStatus();
+
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12');
+ expect(alert()).toBeNull();
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ expect(button('settings.imageStorage.startMove')?.hasAttribute('aria-describedby')).toBe(false);
+ });
+
+ it('shows the start in flight and locks the layout until the server has answered', async () => {
+ const accepted = createDeferred();
+ let status: unknown = idleStatus(12);
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => status,
+ // The real route scans the whole library before it answers.
+ [`POST /api/v1/image_moves/start`]: () => accepted.promise,
+ });
+ await render();
+ await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ expect(trigger()?.disabled).toBe(false);
+
+ await act(() => userEvent.click(button('settings.imageStorage.startMove')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ await act(() => userEvent.click(confirmIn('settings.imageStorage.startMove')!));
+ await expect.poll(() => calls('POST', '/api/v1/image_moves/start').length).toBe(1);
+
+ // The dialog stays open with its confirm button busy; behind it the layout cannot be changed any more.
+ expect(dialog()).not.toBeNull();
+ await expect.poll(() => confirmIn('settings.imageStorage.startMove')?.hasAttribute('data-loading')).toBe(true);
+ expect(trigger()?.disabled).toBe(true);
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+
+ status = runningStatus('move_all');
+ await act(() => {
+ accepted.resolve(status);
+ });
+
+ await expect.poll(() => dialog()).toBeNull();
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusMoving');
+ expect(trigger()?.disabled).toBe(true);
+ await expect.poll(() => document.activeElement).toBe(maintenanceGroup());
+ });
+
+ it('keeps the layout locked until the move status has loaded', async () => {
+ const statusRead = createDeferred();
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'flat' } }),
+ [`GET ${STATUS_ROUTE}`]: () => statusRead.promise,
+ });
+ await render();
+
+ await expect.poll(() => trigger()?.textContent).toContain('settings.imageStorage.layouts.flat');
+ // The layout is known, but whether a move is running is not.
+ expect(trigger()?.disabled).toBe(true);
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+
+ expect(text()).toContain('settings.imageStorage.unavailableStatus');
+
+ await act(() => {
+ statusRead.resolve(idleStatus(12));
+ });
+
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12');
+ expect(trigger()?.disabled).toBe(false);
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ });
+
+ it('shows a load failure for the status with a retry', async () => {
+ let fail = true;
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'flat' } }),
+ [`GET ${STATUS_ROUTE}`]: () =>
+ fail ? new ApiError('{"detail":"Image move service unavailable"}', 503) : idleStatus(12),
+ });
+ await render();
+
+ await expect.poll(() => host.querySelector('[role="alert"]')?.textContent).toBe('Image move service unavailable');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+
+ fail = false;
+ await act(() => userEvent.click(button('common.retry')!));
+
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ });
+ it('enables each job button only when the status gives it something to do, and says why otherwise', async () => {
+ let status: unknown = idleStatus(0);
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => status,
+ });
+ await render();
+
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 0');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(true);
+ expect(text()).toContain('settings.imageStorage.nothingToDo');
+
+ status = idleStatus(12);
+ await refetchStatus();
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusIdle 12');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(true);
+ expect(text()).toContain('settings.imageStorage.recoverNotNeeded');
+ const reason = [...host.querySelectorAll('p')].find((node) =>
+ node.textContent?.includes('settings.imageStorage.recoverNotNeeded')
+ );
+ expect(button('settings.imageStorage.recover')?.getAttribute('aria-describedby')).toBe(reason?.id);
+ // The note explains the disabled Recover only; the enabled Start Move is not described by it.
+ expect(button('settings.imageStorage.startMove')?.hasAttribute('aria-describedby')).toBe(false);
+
+ status = interruptedStatus;
+ await refetchStatus();
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusNeedsRecovery');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(false);
+
+ status = runningStatus('recovery');
+ await refetchStatus();
+ await expect.poll(() => text()).toContain('settings.imageStorage.statusRecovering');
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(true);
+ expect(text()).toContain('settings.imageStorage.unavailableRunning');
+ });
+
+ it('locks the job buttons while a layout change is being saved', async () => {
+ const saved = createDeferred();
+ let layout = 'date';
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: layout } }),
+ [`PATCH ${CONFIG_ROUTE}`]: () => saved.promise,
+ [`GET ${STATUS_ROUTE}`]: () => idleStatus(12),
+ });
+ await render();
+ await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false);
+
+ await act(() => userEvent.click(trigger()!));
+ await expect.poll(() => document.querySelectorAll('[role="option"]').length).toBe(4);
+ const hashOption = [...document.querySelectorAll('[role="option"]')].find((option) =>
+ option.textContent?.includes('settings.imageStorage.layouts.hash')
+ );
+ await act(() => userEvent.click(hashOption!));
+ await expect.poll(() => calls('PATCH', CONFIG_ROUTE).length).toBe(1);
+
+ // A move started now would read whichever layout the save leaves behind.
+ expect(button('settings.imageStorage.startMove')?.disabled).toBe(true);
+ expect(button('settings.imageStorage.recover')?.disabled).toBe(true);
+ expect(text()).toContain('settings.imageStorage.unavailableSavingLayout');
+
+ layout = 'hash';
+ await act(() => {
+ saved.resolve({ config: { image_subfolder_strategy: 'hash' } });
+ });
+
+ await expect.poll(() => button('settings.imageStorage.startMove')?.disabled).toBe(false);
+ expect(trigger()?.textContent).toContain('settings.imageStorage.layouts.hash');
+ });
+
+ it('returns focus to the opener when the confirmation is cancelled or dismissed', async () => {
+ installRoutes({
+ [`GET ${CONFIG_ROUTE}`]: () => ({ config: { image_subfolder_strategy: 'date' } }),
+ [`GET ${STATUS_ROUTE}`]: () => interruptedStatus,
+ });
+ await render();
+ await expect.poll(() => button('settings.imageStorage.recover')?.disabled).toBe(false);
+
+ await act(() => userEvent.click(button('settings.imageStorage.recover')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ await act(() => userEvent.click(confirmIn('Cancel')!));
+ await expect.poll(() => dialog()).toBeNull();
+ await expect.poll(() => document.activeElement).toBe(button('settings.imageStorage.recover'));
+
+ await act(() => userEvent.click(button('settings.imageStorage.recover')!));
+ await expect.poll(() => dialog()).not.toBeNull();
+ await act(() => userEvent.keyboard('{Escape}'));
+ await expect.poll(() => dialog()).toBeNull();
+ await expect.poll(() => document.activeElement).toBe(button('settings.imageStorage.recover'));
+ expect(calls('POST', '/api/v1/image_moves/recover')).toHaveLength(0);
+
+ // The section that receives focus after a confirmed action is named by its heading.
+ const group = maintenanceGroup();
+ expect(document.getElementById(group?.getAttribute('aria-labelledby') ?? '')?.textContent).toBe(
+ 'settings.imageStorage.maintenanceHeading'
+ );
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx
new file mode 100644
index 00000000000..ce7deaeed65
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/settings/ImageStorageSettings.tsx
@@ -0,0 +1,373 @@
+import type { ImageMoveStatus, ImageStorageLayout } from '@features/gallery/storage';
+
+import { createListCollection, HStack, Spinner, Stack, Text } from '@chakra-ui/react';
+import {
+ IMAGE_STORAGE_LAYOUTS,
+ imageMoveStatusQueryOptions,
+ imageStorageLayoutQueryOptions,
+ isImageStorageLayout,
+ startImageMove,
+ startImageMoveRecovery,
+ updateImageStorageLayout,
+} from '@features/gallery/storage';
+import { useCapabilities } from '@features/identity';
+import { useScopedAction } from '@platform/react/useScopedAction';
+import { getApiErrorMessage } from '@platform/transport/http';
+import { Button } from '@platform/ui/Button';
+import { ConfirmDialog } from '@platform/ui/ConfirmDialog';
+import { Field } from '@platform/ui/Field';
+import { Select } from '@platform/ui/Select';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { type MouseEvent, useCallback, useId, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+const tKey = (key: string): string => `settings.imageStorage.${key}`;
+const CONTROL_WIDTH = { base: 'full', md: '72' } as const;
+const SELECT_POSITIONING = { strategy: 'fixed', sameWidth: true, hideWhenDetached: true } as const;
+
+type ImageMoveAction = 'start' | 'recover';
+
+/**
+ * The job state a refusal was answered against. A running move journals one job per batch, so its job ids advance
+ * (with no active job between batches) while it runs: running is one state however far it has got. Otherwise the
+ * active and latest jobs tell the states apart. A refusal stops applying once the status moves past it (the move
+ * that refused the start ends, for example).
+ */
+const getJobKey = (status: ImageMoveStatus | undefined): string => {
+ if (!status) {
+ return 'unknown';
+ }
+
+ return status.isRunning ? 'running' : `idle:${String(status.activeJobId)}:${String(status.latestJob?.id ?? null)}`;
+};
+
+interface JobAvailability {
+ canRecover: boolean;
+ canStart: boolean;
+ /** Visible reason for the disabled buttons, unless the status line already explains it. */
+ reasonKey: string | null;
+}
+
+/**
+ * Each button is enabled only when the server would do something with it: Recover only for an interrupted job (any
+ * other recovery is a no-op behind the server-wide maintenance lock), Start Move only when images need moving.
+ */
+const getJobAvailability = ({
+ isActing,
+ isSavingLayout,
+ status,
+}: {
+ isActing: boolean;
+ isSavingLayout: boolean;
+ /** Null until a usable status has loaded. */
+ status: ImageMoveStatus | null;
+}): JobAvailability => {
+ if (status === null) {
+ return { canRecover: false, canStart: false, reasonKey: 'unavailableStatus' };
+ }
+ if (isActing) {
+ // The confirmation dialog shows the request in flight.
+ return { canRecover: false, canStart: false, reasonKey: null };
+ }
+ if (status.isRunning) {
+ return { canRecover: false, canStart: false, reasonKey: 'unavailableRunning' };
+ }
+ if (isSavingLayout) {
+ // The new layout changes what needs moving.
+ return { canRecover: false, canStart: false, reasonKey: 'unavailableSavingLayout' };
+ }
+ if (status.activeJobId !== null) {
+ // The status line tells the admin to run Recover before another move.
+ return { canRecover: true, canStart: false, reasonKey: null };
+ }
+ if (status.needsMoveCount > 0) {
+ return { canRecover: false, canStart: true, reasonKey: 'recoverNotNeeded' };
+ }
+
+ return { canRecover: false, canStart: false, reasonKey: 'nothingToDo' };
+};
+
+/** Server image storage: the folder layout for new images and the move/recovery job for existing ones. */
+export const ImageStorageSettings = () => {
+ const { t } = useTranslation();
+ const { canManageAppConfig } = useCapabilities();
+
+ if (!canManageAppConfig) {
+ return (
+
+ {t(tKey('adminOnly'))}
+
+ );
+ }
+
+ return ;
+};
+
+const ImageStorageEditor = () => {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ // Both routes are admin-only; the editor only mounts for accounts that can read them.
+ const layoutQuery = useQuery(imageStorageLayoutQueryOptions());
+ // Polling lives with this editor: the gallery refresh at the end of a move needs it open until the job stops.
+ const statusQuery = useQuery(imageMoveStatusQueryOptions());
+ const actionErrorId = useId();
+ const availabilityId = useId();
+ const maintenanceHeadingId = useId();
+ const maintenanceRef = useRef(null);
+ // The button that opened the confirmation, and whether it was confirmed rather than dismissed.
+ const openerRef = useRef(null);
+ const isConfirmedRef = useRef(false);
+ // Cancel returns focus to the opener. A confirmed action reports its outcome in the maintenance group, and an
+ // accepted job disables the opener, so focus lands on that group rather than the document body.
+ const getFinalFocus = useCallback(() => {
+ const opener = isConfirmedRef.current ? null : openerRef.current;
+
+ return opener && !opener.disabled ? opener : maintenanceRef.current;
+ }, []);
+ const { isBusy: isSavingLayout, run: runLayoutSave } = useScopedAction();
+ const { isBusy: isActing, run: runAction } = useScopedAction();
+ const [layoutError, setLayoutError] = useState(null);
+ const [actionError, setActionError] = useState<{ action: ImageMoveAction; jobKey: string; message: string } | null>(
+ null
+ );
+ const [pendingAction, setPendingAction] = useState(null);
+
+ const layout = layoutQuery.data ?? 'flat';
+ const layoutCollection = useMemo(() => {
+ const items = IMAGE_STORAGE_LAYOUTS.map((value) => ({ label: t(tKey(`layouts.${value}`)), value }));
+
+ // A layout this build does not know stays selectable as itself rather than silently reading as flat.
+ if (!isImageStorageLayout(layout)) {
+ items.push({ label: t(tKey('layouts.unknown'), { layout }), value: layout as ImageStorageLayout });
+ }
+
+ return createListCollection({ items });
+ }, [layout, t]);
+ const layoutValue = useMemo(() => [layout], [layout]);
+
+ const handleLayoutChange = useCallback(
+ ({ value }: { value: string[] }) => {
+ const next = value[0];
+
+ if (!isImageStorageLayout(next) || next === layout) {
+ return;
+ }
+
+ setLayoutError(null);
+ void runLayoutSave(
+ (owner) => updateImageStorageLayout(queryClient, next, owner),
+ (_message, error) => setLayoutError(getApiErrorMessage(error, t(tKey('layoutSaveFailed'))))
+ );
+ },
+ [layout, queryClient, runLayoutSave, t]
+ );
+
+ const requestAction = useCallback((action: ImageMoveAction, opener: HTMLButtonElement) => {
+ openerRef.current = opener;
+ isConfirmedRef.current = false;
+ setPendingAction(action);
+ }, []);
+ const requestStart = useCallback(
+ (event: MouseEvent) => requestAction('start', event.currentTarget),
+ [requestAction]
+ );
+ const requestRecover = useCallback(
+ (event: MouseEvent) => requestAction('recover', event.currentTarget),
+ [requestAction]
+ );
+ const closeConfirmation = useCallback(() => setPendingAction(null), []);
+ // Returns the request so the dialog shows it in flight; the dialog closes when it settles, either way.
+ const confirmAction = useCallback(async () => {
+ const action = pendingAction;
+
+ if (action === null) {
+ return;
+ }
+
+ isConfirmedRef.current = true;
+ setActionError(null);
+ await runAction(
+ async (owner) => {
+ await (action === 'start' ? startImageMove(queryClient, owner) : startImageMoveRecovery(queryClient, owner));
+ },
+ (_message, error) =>
+ setActionError({
+ action,
+ // The refusal re-read the status before it settled, so the cache holds the job it was refused against.
+ jobKey: getJobKey(queryClient.getQueryData(imageMoveStatusQueryOptions().queryKey)),
+ message: getApiErrorMessage(error, t(tKey(action === 'start' ? 'startFailed' : 'recoverFailed'))),
+ })
+ );
+ }, [pendingAction, queryClient, runAction, t]);
+ const retryStatus = useCallback(() => void statusQuery.refetch(), [statusQuery]);
+ const retryLayout = useCallback(() => void layoutQuery.refetch(), [layoutQuery]);
+
+ const status = statusQuery.data ?? null;
+ const shownActionError = actionError?.jobKey === getJobKey(statusQuery.data) ? actionError : null;
+ const usableStatus = statusQuery.isError ? null : status;
+ const availability = getJobAvailability({ isActing, isSavingLayout, status: usableStatus });
+ // The move reads the layout per batch, so changing it while a job runs (or may be running: status unknown, or a
+ // start still being accepted) would leave the images split between two layouts.
+ const isLayoutLocked =
+ layoutQuery.isPending || isSavingLayout || isActing || usableStatus === null || usableStatus.isRunning;
+ // Each button references only what explains it: its own action's failure, and the note while it is disabled.
+ const describedBy = (action: ImageMoveAction, isEnabled: boolean) =>
+ [
+ shownActionError?.action === action ? actionErrorId : null,
+ availability.reasonKey && !isEnabled ? availabilityId : null,
+ ]
+ .filter(Boolean)
+ .join(' ') || undefined;
+
+ return (
+
+
+ {layoutQuery.isError ? (
+
+
+ {getApiErrorMessage(layoutQuery.error, t(tKey('layoutLoadFailed')))}
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {t(tKey('maintenanceHeading'))}
+
+
+ {t(tKey('maintenanceDescription'))}
+
+
+
+
+
+
+ {availability.reasonKey ? (
+
+ {t(tKey(availability.reasonKey))}
+
+ ) : null}
+ {shownActionError ? (
+
+ {shownActionError.message}
+
+ ) : null}
+
+
+
+ );
+};
+
+const ImageMoveStatusLine = ({
+ isPending,
+ loadError,
+ onRetry,
+ status,
+}: {
+ isPending: boolean;
+ loadError: string | null;
+ onRetry: () => void;
+ status: ImageMoveStatus | null;
+}) => {
+ const { t } = useTranslation();
+
+ if (loadError) {
+ return (
+
+
+ {loadError}
+
+
+
+ );
+ }
+
+ if (isPending || status === null) {
+ return ;
+ }
+
+ const errorMessage = status.latestJob?.errorMessage || status.lastError;
+ let summary: string;
+
+ if (status.activeJobId !== null && !status.isRunning) {
+ summary = t(tKey('statusNeedsRecovery'));
+ } else if (status.isRunning) {
+ summary = t(tKey(status.operation === 'recovery' ? 'statusRecovering' : 'statusMoving'));
+ } else {
+ summary = t(tKey('statusIdle'), { count: status.needsMoveCount });
+ }
+
+ return (
+
+
+ {status.isRunning ? : null}
+
+ {summary}
+
+
+ {status.latestJob && !status.isRunning && status.activeJobId === null ? (
+
+ {t(tKey('latestJob'), { state: t(tKey(`jobStates.${status.latestJob.state}`)) })}
+
+ ) : null}
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts b/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts
index 76f96611aae..49ea7dc1355 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts
+++ b/invokeai/frontend/webv2/src/workbench/settings/applicationContributions.ts
@@ -153,6 +153,7 @@ export const developerSettings = section('developer', 'Developer', [
]);
export const serverSettings = section('server', 'Server', [
custom('generationDevices', 'Generation devices', 'server'),
+ { ...custom('imageStorage', 'Image storage', 'server'), keywords: 'images folder subfolder layout move recover' },
custom('maxQueueHistory', 'Queue history', 'server'),
]);
export const workspaceSettings = section('workspace', 'Data & workspace', [
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx
index 20234b5563f..aebad67e228 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewFrame.tsx
@@ -52,6 +52,8 @@ interface PreviewFrameProps {
frameWidth: number;
/** Keep the last denoise frame until source decoding completes, then notify onSourceLoaded. */
holdSource?: StreamingImageSource | null;
+ /** A settled image failed to load. */
+ onSourceError?: () => void;
onSourceLoaded?: (src: string) => void;
/** A small, usually cached rendition (the thumbnail) shown until a settled image's full pixels load. */
placeholderSrc?: string;
@@ -104,6 +106,7 @@ const PreviewImageFrame = ({
isLive,
loupeControlsRef,
onContextMenu,
+ onSourceError,
onSourceLoaded,
onZoomChange,
padding,
@@ -237,6 +240,13 @@ const PreviewImageFrame = ({
},
[holdSource, onSourceLoaded, placeholderSrc]
);
+ const handleSourceError = useCallback(
+ (event: SyntheticEvent) => {
+ handleSourceSettled(event);
+ onSourceError?.();
+ },
+ [handleSourceSettled, onSourceError]
+ );
// A hold arriving after the image already decoded (a cached image, or the
// element reused across a source swap) would never see a load event.
useEffect(() => {
@@ -291,7 +301,7 @@ const PreviewImageFrame = ({
src={source.src}
style={isHolding ? heldImageStyle : imageStyle}
width={frameWidth}
- onError={handleSourceSettled}
+ onError={handleSourceError}
onLoad={handleSourceSettled}
/>
{/* The hidden finished image keeps the frame's geometry; the held frame
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx
index e313f9d98ec..fe7942e067a 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewNavigation.browser.test.tsx
@@ -25,7 +25,7 @@ import { act, useCallback } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { userEvent } from 'vitest/browser';
+import { commands, userEvent } from 'vitest/browser';
import { LivePreviewFollowProvider, useLivePreviewFollow } from './livePreviewFollow';
import { usePreviewHeaderContext, type PreviewItemPosition } from './previewHeaderStore';
@@ -3761,3 +3761,61 @@ describe('preview deletion confirmation', () => {
await expect.poll(() => document.querySelector('[role="alertdialog"]')).toBeNull();
});
});
+
+describe('preview image recovery', () => {
+ const imageRouteCommands = commands as typeof commands & {
+ endThumbnailRouteFailures: () => Promise;
+ getThumbnailRequests: () => Promise<{ statuses: number[]; types: string[]; urls: string[] }>;
+ startThumbnailRoute: (pathnames: string[], options?: { failures?: number; status?: number }) => Promise;
+ stopThumbnailRoute: () => Promise;
+ };
+ const shownImage = () => host?.querySelector('img[alt="newest"]') ?? null;
+
+ it('reloads the full image an image move refused once the move ends', async () => {
+ // A fresh account lifetime: earlier tests' failed images must not leave a probe waiting on a real timer.
+ accountLifecycle.activate('preview-image-recovery');
+ const imagePath = '/images/newest/full';
+ // The recovery probe's reads are answered in the page, so they settle within the fake clock's steps; the
+ // image element's own reads go to the network route.
+ let isMoving = true;
+ const isProbe = (input: unknown) => new URL(String(input), window.location.href).pathname === imagePath;
+ const browserFetch = globalThis.fetch;
+ const probes = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) =>
+ isProbe(input)
+ ? Promise.resolve(
+ isMoving
+ ? new Response('moving', { status: 409 })
+ : new Response('', {
+ headers: { 'content-type': 'image/svg+xml' },
+ })
+ )
+ : browserFetch(input, init)
+ );
+ // The real guard answers full image reads with 409 too while the files move.
+ await imageRouteCommands.startThumbnailRoute([imagePath], { failures: 1_000_000, status: 409 });
+ vi.useFakeTimers({ shouldAdvanceTime: true, toFake: ['setTimeout', 'clearTimeout'] });
+
+ try {
+ await render();
+ await expect.poll(() => shownImage()?.complete === true && shownImage()?.naturalWidth === 0).toBe(true);
+ expect((await imageRouteCommands.getThumbnailRequests()).statuses).toEqual([409]);
+
+ // Two minutes into the move, the probe keeps asking and the image stays down.
+ await act(() => vi.advanceTimersByTimeAsync(2 * 60_000));
+ expect(probes.mock.calls.filter(([input]) => isProbe(input)).length).toBeGreaterThan(1);
+ expect(shownImage()?.naturalWidth).toBe(0);
+ expect((await imageRouteCommands.getThumbnailRequests()).statuses).toEqual([409]);
+
+ // The move ends: the next probe sees the image served and the preview reloads it.
+ isMoving = false;
+ await imageRouteCommands.endThumbnailRouteFailures();
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+ await expect.poll(() => shownImage()?.naturalWidth).toBe(64);
+ expect(shownImage()?.getAttribute('src')).toContain('gallery_image_recovery=');
+ } finally {
+ vi.useRealTimers();
+ probes.mockRestore();
+ await imageRouteCommands.stopThumbnailRoute();
+ }
+ }, 20_000);
+});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx
index 052dacd5b4b..65f58c89034 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx
@@ -31,7 +31,7 @@ import {
toGalleryItemRef,
} from '@features/gallery/contracts';
import { galleryBoardsOptions, getGalleryListingBoardsQuery } from '@features/gallery/queries';
-import { useHostGalleryBoards } from '@features/gallery/react';
+import { useGalleryImageRecovery, useHostGalleryBoards } from '@features/gallery/react';
import { createGenerateFormValuesSelector } from '@features/generation/react';
import {
consumeQueueItemSwapProgressImage,
@@ -719,12 +719,14 @@ const SelectedImagePreview = ({
shouldAntialiasProgressImage,
...props
}: SelectedMediaPreviewProps & { item: GalleryImageItem; shouldAntialiasProgressImage: boolean }) => {
+ // An image move refuses full image reads too; the image reloads by itself once the gallery sees the move end.
+ const { onError: handleSourceError, src: fullImageSrc } = useGalleryImageRecovery(item.fullUrl);
const previewImage = useStreamingImageSource({
fallbackImage: imageUrlToStreamingSource({
alt: item.name,
height: item.height,
kind: 'fallback',
- src: item.fullUrl,
+ src: fullImageSrc,
width: item.width,
}),
});
@@ -756,6 +758,7 @@ const SelectedImagePreview = ({
placeholderSrc={item.thumbnailUrl}
shouldAntialiasHoldImage={shouldAntialiasProgressImage}
source={source}
+ onSourceError={handleSourceError}
onSourceLoaded={handleSourceLoaded}
/>
);
@@ -828,6 +831,7 @@ const SelectedMediaPreview = ({
item,
loupeControlsRef,
onCopyAvailabilityChange,
+ onSourceError,
onSourceLoaded,
placeholderSrc,
shouldAntialiasHoldImage,
@@ -840,6 +844,7 @@ const SelectedMediaPreview = ({
frameHeight: number;
frameWidth: number;
holdSource?: StreamingImageSource | null;
+ onSourceError?: () => void;
onSourceLoaded?: (src: string) => void;
placeholderSrc?: string;
shouldAntialiasHoldImage?: boolean;
@@ -857,6 +862,7 @@ const SelectedMediaPreview = ({
isItemCurrent={isItemCurrent}
isLive={false}
loupeControlsRef={loupeControlsRef}
+ onSourceError={onSourceError}
onSourceLoaded={onSourceLoaded}
onVideoCopyAvailabilityChange={onCopyAvailabilityChange}
onZoomChange={setZoomReadout}
diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts
index c29c4e0346d..456db82c41e 100644
--- a/invokeai/frontend/webv2/vite.config.mts
+++ b/invokeai/frontend/webv2/vite.config.mts
@@ -45,6 +45,10 @@ const ROUTE_SHARED_MODULES = [
'/features/nodes/data/nodeExecutionStore.ts',
'/features/nodes/index.ts',
'/features/nodes/ui/NodesPage.tsx',
+ // Gallery tiles, the lazy Preview and the Launchpad's gallery picker share the failed-image recovery; alone it costs a
+ // startup request.
+ '/features/gallery/data/imageRecovery.ts',
+ '/features/gallery/ui/useGalleryImageRecovery.ts',
'/platform/browser/downloadBlob.ts',
// Editor search fields and the lazy pickers share the IME guard; alone it costs a startup request.
'/platform/browser/imeComposition.ts',
diff --git a/invokeai/frontend/webv2/vitest.browser.config.mts b/invokeai/frontend/webv2/vitest.browser.config.mts
index ade9c52e5aa..d7ab56a9c61 100644
--- a/invokeai/frontend/webv2/vitest.browser.config.mts
+++ b/invokeai/frontend/webv2/vitest.browser.config.mts
@@ -37,9 +37,14 @@ const imeCompose: BrowserCommand<[steps: readonly { kind: 'commit' | 'compose';
};
type ThumbnailRouteState = {
+ /** Requests still to be answered with `failureStatus`. */
+ failuresLeft: number;
+ failureStatus: number;
handler: (route: Route) => Promise;
- pattern: string;
+ matches: (url: URL) => boolean;
statuses: number[];
+ /** Playwright resource types: `image` for elements, `fetch` for scripted reads. */
+ types: string[];
urls: string[];
};
@@ -48,26 +53,35 @@ const thumbnailRoutes = new Map();
const stopThumbnailRoute = async (context: BrowserContext, sessionId: string) => {
const state = thumbnailRoutes.get(sessionId);
if (state) {
- await context.unroute(state.pattern, state.handler);
+ await context.unroute(state.matches, state.handler);
thumbnailRoutes.delete(sessionId);
}
};
-const startThumbnailRoute: BrowserCommand<[pathname: string]> = async ({ context, sessionId }, pathname) => {
+/**
+ * Serves the images at `pathnames`, answering the first `failures` requests with `status` (default: one 404).
+ * `endThumbnailRouteFailures` serves every later request.
+ */
+const startThumbnailRoute: BrowserCommand<
+ [pathnames: string | string[], options?: { failures?: number; status?: number }]
+> = async ({ context, sessionId }, pathnames, { failures = 1, status: failureStatus = 404 } = {}) => {
await stopThumbnailRoute(context, sessionId);
- const path = new URL(pathname, 'http://localhost').pathname;
- const pattern = `**${path}*`;
+ const paths = new Set(
+ (Array.isArray(pathnames) ? pathnames : [pathnames]).map(
+ (pathname) => new URL(pathname, 'http://localhost').pathname
+ )
+ );
const state: ThumbnailRouteState = {
+ failuresLeft: failures,
+ failureStatus,
handler: async (route) => {
- if (new URL(route.request().url()).pathname !== path) {
- await route.continue();
- return;
- }
-
- state.urls.push(route.request().url());
- const status = state.urls.length === 1 ? 404 : 200;
+ const request = route.request();
+ state.urls.push(request.url());
+ state.types.push(request.resourceType());
+ const status = state.failuresLeft > 0 ? state.failureStatus : 200;
+ state.failuresLeft = Math.max(0, state.failuresLeft - 1);
state.statuses.push(status);
- if (status === 404) {
+ if (status !== 200) {
await route.fulfill({ body: 'thumbnail missing', contentType: 'text/plain', status });
} else {
await route.fulfill({
@@ -77,17 +91,27 @@ const startThumbnailRoute: BrowserCommand<[pathname: string]> = async ({ context
});
}
},
- pattern,
+ matches: (url) => paths.has(url.pathname),
statuses: [],
+ types: [],
urls: [],
};
thumbnailRoutes.set(sessionId, state);
- await context.route(pattern, state.handler);
+ await context.route(state.matches, state.handler);
+};
+
+const endThumbnailRouteFailures: BrowserCommand<[]> = ({ sessionId }) => {
+ const state = thumbnailRoutes.get(sessionId);
+ if (state) {
+ state.failuresLeft = 0;
+ }
};
-const getThumbnailRequests: BrowserCommand<[], { statuses: number[]; urls: string[] }> = ({ sessionId }) => {
+const getThumbnailRequests: BrowserCommand<[], { statuses: number[]; types: string[]; urls: string[] }> = ({
+ sessionId,
+}) => {
const state = thumbnailRoutes.get(sessionId);
- return { statuses: state?.statuses ?? [], urls: state?.urls ?? [] };
+ return { statuses: state?.statuses ?? [], types: state?.types ?? [], urls: state?.urls ?? [] };
};
const stopThumbnailRouteCommand: BrowserCommand<[]> = async ({ context, sessionId }) => {
@@ -117,6 +141,7 @@ export default mergeConfig(
test: {
browser: {
commands: {
+ endThumbnailRouteFailures,
getThumbnailRequests,
imeCompose,
resetBrowserPointer,