Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions invokeai/frontend/webv2/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4221,6 +4221,7 @@
"boardVisibility": "Board visibility",
"boardsLoadFailed": "Could not load boards.",
"boardsRefreshFailed": "Could not refresh boards.",
"externalGenerationRevealed": "Showing an image from another app",
"destination": "Destination",
"followSelectedBoard": "Send new results to the selected board",
"commands": {
Expand Down
27 changes: 25 additions & 2 deletions invokeai/frontend/webv2/src/app/GalleryRealtimeRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,48 @@ import { isWebv2QueueItemOrigin } from '@features/queue/contracts';
import { useMountEffect } from '@platform/react/useMountEffect';
import { socketHub } from '@platform/transport/socketHub';
import { useQueryClient } from '@tanstack/react-query';
import { isEditableHotkeyTarget } from '@workbench/hotkeys/keys';
import { attachExternalGenerationReveal } from '@workbench/image-actions/recallParametersBridge';
import { useWorkbenchCommands, useWorkbenchQueries } from '@workbench/WorkbenchContext';

/**
* App-owned composition: uploads and other clients' completed generations the backend announces over the socket
* refresh the Gallery read model.
* refresh the Gallery read model, and those whose batch asked for it are revealed in the Gallery.
*/
export const GalleryRealtimeRuntime = () => {
const queryClient = useQueryClient();
const commands = useWorkbenchCommands();
const queries = useWorkbenchQueries();

useMountEffect(() => {
const revealer = attachExternalGenerationReveal({
context: {
commands,
// A field keeps focus when the user switches to another window, such as the chatbot beside Invoke.
isEditingText: () => document.hasFocus() && isEditableHotkeyTarget(document.activeElement),
isVisible: () => document.visibilityState === 'visible',
onVisibilityChange: (listener) => {
document.addEventListener('visibilitychange', listener);
return () => document.removeEventListener('visibilitychange', listener);
},
queries,
queryClient,
},
});
const runtime = createGalleryRealtimeRuntime({
backend: socketHub,
getSessionUserId,
invalidate: () => invalidateGallery(queryClient),
isClientQueueItemOrigin: isWebv2QueueItemOrigin,
onRevealRequest: revealer.request,
});

runtime.start();

return runtime.dispose;
return () => {
runtime.dispose();
revealer.dispose();
};
});

return null;
Expand Down
25 changes: 25 additions & 0 deletions invokeai/frontend/webv2/src/features/gallery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,28 @@
Paths are relative to the webv2 `src/` directory.

Gallery ordinary listings are Query-owned sparse pages shared by grid, picker, Preview, and deep-reveal flows. Page key = account lifetime + canonical listing filters + aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. Cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by recent-item limit so an authoritative item does not reappear in overlay when its page is evicted. Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies item at returned absolute position, and retries that rank/page check at most once if offsets shifted. Persisted Preview selection keeps listing identity from when item was selected; changing active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations; its async navigation is fenced by captured account epoch so a late page cannot stamp selection into a newer account lifetime.

## Realtime refresh and external reveal

Uploads, reconnects, and the signed-in user's completed queue items whose origin does not start with `webv2:` refresh the listings. This client's own items are refreshed by Queue, in the tab that tracks them only.

An external client asks for its results to be revealed by enqueueing with `POST /api/v1/queue/default/enqueue_batch` and `batch.destination` set to `gallery:reveal`. The request is honored only for the signed-in user's items (any user's on a single-user server) whose origin does not start with `webv2:`. Every other destination only refreshes, `gallery` included: legacy web sends it on every workflow and upscale run.

The reveal is best effort. Each completed item asks once; a reveal that stands down is dropped, not retried, except in a hidden tab. Every visible tab or device signed in as that user may reveal. The reveal takes the last non-intermediate image the item saved and lands on it as Find in Gallery does: board, page and selection, plus the starred-only filter and the images/assets view when the item needs them, after which Preview follows the selection and the grid scrolls to it. With the Gallery widget closed, the selection still changes, unseen. It raises no widget and leaves Canvas, Generate panels and the route untouched. Only while the Gallery widget is mounted is it announced, once, in the Gallery's polite live region; a Gallery mounted later does not repeat it.

It never takes the Gallery from the user. These conditions are checked when the completion arrives, before and after its result is read, and immediately before the first Gallery change; the first failure drops the reveal:

- the user is not typing (this window has focus and a text field in it does);
- the project on screen at arrival is still the active one and has no open run of its own;
- no search text, semantic text or semantic query narrows the listing (an open but empty semantic field does not count);
- at most one item is selected;
- no other reveal, such as Find in Gallery or an image-map click, is locating its item;
- the user has not navigated the Gallery since arrival: board, selection, view, starred-only, search, page, or a claimed navigation;
- no newer reveal request has arrived. Only the newest completion reveals, so a batch of several runs reveals at most its last completed item.

A hidden tab defers instead: the newest request waits until the tab is shown, and the conditions are checked then.

Known limitations:

- The status event does not say whether an item is a workflow-call child, which inherits its parent's destination. A child's completion supersedes a reveal still in flight before its read finds it is a child and reveals nothing; if the parent then fails or is cancelled, the earlier item is not revealed.
- An image a graph passes through from its inputs counts as an output, so it is picked if it is the last non-intermediate one.
6 changes: 6 additions & 0 deletions invokeai/frontend/webv2/src/features/gallery/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,21 @@ export {
type GallerySelectedImageQuery,
} from './ui/galleryStateView';
export {
captureGalleryNavigation,
claimGalleryNavigationSequence,
getGalleryDeletionSuccessor,
getGalleryNavigationStep,
getGallerySessionNavigationKey,
getPersistedSelectedGalleryItemKeys,
getSelectedGalleryImageFromValues,
getSelectedGalleryItemFromValues,
hasGalleryListingSearch,
hasGalleryLocatorRequests,
isGalleryNavigationCurrent,
isGalleryNavigationStateUnchanged,
requestGalleryItemReveal,
type GalleryNavigationSnapshot,
type GalleryRevealAnnouncement,
type GalleryNavigationDirection,
type GalleryNavigationEntry,
} from './core/selection';
57 changes: 55 additions & 2 deletions invokeai/frontend/webv2/src/features/gallery/core/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,16 @@ export const getGalleryDeletionSuccessor = (
* scrolls; colocating avoids another gallery chunk.
*/

/**
* Why a reveal is announced, once, by a Gallery widget mounted when it is requested; unset for reveals the user asked
* for themselves.
*/
export type GalleryRevealAnnouncement = 'external-generation';

export interface GalleryRevealRequest {
accountSignal: AbortSignal;
absoluteIndex?: number;
announcement?: GalleryRevealAnnouncement;
itemKey: GalleryItemKey;
token: number;
}
Expand All @@ -150,10 +157,11 @@ const listeners = new Set<() => void>();
export const requestGalleryItemReveal = (
itemKey: GalleryItemKey,
accountSignal: AbortSignal,
absoluteIndex?: number
absoluteIndex?: number,
announcement?: GalleryRevealAnnouncement
): void => {
nextToken += 1;
currentRequest = { accountSignal, absoluteIndex, itemKey, token: nextToken };
currentRequest = { accountSignal, absoluteIndex, announcement, itemKey, token: nextToken };
for (const listener of listeners) {
listener();
}
Expand Down Expand Up @@ -197,6 +205,9 @@ export const createGalleryLocatorRequest = (): GalleryLocatorRequest => {
};
};

/** Whether a reveal is still locating its item; a reveal nobody asked for must not supersede it. */
export const hasGalleryLocatorRequests = (): boolean => activeGalleryLocatorRequests.size > 0;

/** A later Gallery navigation supersedes every locator currently in flight. */
export const abortGalleryLocatorRequests = (): void => {
for (const controller of activeGalleryLocatorRequests) {
Expand All @@ -214,6 +225,48 @@ export const claimGalleryNavigationSequence = (): number => ++navigationSequence
/** False once a newer navigation has been claimed, which is when a slow hydrate must stand down. */
export const isGalleryNavigationCurrent = (sequence: number): boolean => sequence === navigationSequence;

/**
* The Gallery navigation a user controls, as one comparable value: the latest claimed navigation, plus the board,
* selection, view, filters, search and page in the Gallery widget's values. Ordinary clicks claim no navigation, so
* the values cover them.
*/
export interface GalleryNavigationSnapshot {
readonly sequence: number;
readonly state: string;
}

const NAVIGATION_VALUE_KEYS = [
'galleryPage',
'galleryView',
'searchTerm',
'selectedBoardId',
'selectedImageName',
'selectedImageNames',
'semanticImageQuery',
'semanticSearchText',
'starredOnly',
] as const;

export const captureGalleryNavigation = (values: Record<string, unknown>): GalleryNavigationSnapshot => ({
sequence: navigationSequence,
state: JSON.stringify(NAVIGATION_VALUE_KEYS.map((key) => values[key] ?? null)),
});

/** Whether the Gallery values still hold the captured navigation; the claimed sequence is compared separately. */
export const isGalleryNavigationStateUnchanged = (
snapshot: GalleryNavigationSnapshot,
values: Record<string, unknown>
): boolean => captureGalleryNavigation(values).state === snapshot.state;

/**
* Whether the user composed a search a reveal would discard: search text, semantic text, or a semantic query. An open
* but empty semantic field holds nothing to lose.
*/
export const hasGalleryListingSearch = (values: Record<string, unknown>): boolean =>
(typeof values.searchTerm === 'string' && values.searchTerm !== '') ||
(typeof values.semanticSearchText === 'string' && values.semanticSearchText !== '') ||
(values.semanticImageQuery !== null && values.semanticImageQuery !== undefined);

/*
* Grid and Preview share section order: the starred strip, the in-progress sessions, then the listing. Horizontal
* navigation crosses seams while vertical navigation preserves columns.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,26 @@ describe('gallery realtime runtime', () => {
const invalidate = vi.fn();
let sessionUserId: string | null = 'alice';
const getSessionUserId = () => sessionUserId;
const onRevealRequest = vi.fn();
const CLIENT_ORIGIN = 'this-client';
const createRuntime = () =>
createGalleryRealtimeRuntime({
backend,
getSessionUserId,
invalidate,
isClientQueueItemOrigin: (origin) => origin === CLIENT_ORIGIN,
onRevealRequest,
});
const emitStatusChanged = (event: { origin?: string | null; status?: string; user_id?: string }): void =>
const emitStatusChanged = (event: {
destination?: string | null;
item_id?: number;
origin?: string | null;
status?: string;
user_id?: string;
}): void =>
handlers.get('queue_item_status_changed')?.({
destination: null,
item_id: 1,
origin: 'chatbot',
status: 'completed',
user_id: 'alice',
Expand All @@ -56,6 +66,7 @@ describe('gallery realtime runtime', () => {
backend.on.mockClear();
backend.onConnectionChange.mockClear();
invalidate.mockReset();
onRevealRequest.mockReset();
});

afterEach(() => vi.useRealTimers());
Expand Down Expand Up @@ -181,21 +192,42 @@ describe('gallery realtime runtime', () => {

it('ignores completions that cannot add to this gallery or that Queue already refreshes for', () => {
const runtime = createRuntime();
const destination = 'gallery:reveal';

runtime.start();
// Another user's item, as an admin receives it, and as everyone else receives it.
emitStatusChanged({ user_id: 'bob' });
emitStatusChanged({ origin: null, user_id: 'redacted' });
emitStatusChanged({ destination, user_id: 'bob' });
emitStatusChanged({ destination: null, origin: null, user_id: 'redacted' });
// An item this client enqueued, which Queue refreshes for in the tab tracking it.
emitStatusChanged({ origin: CLIENT_ORIGIN });
emitStatusChanged({ destination, origin: CLIENT_ORIGIN });

for (const status of ['pending', 'in_progress', 'waiting', 'failed', 'canceled'] as const) {
emitStatusChanged({ status });
emitStatusChanged({ destination, status });
}

vi.advanceTimersByTime(1000);

expect(invalidate).not.toHaveBeenCalled();
expect(onRevealRequest).not.toHaveBeenCalled();
runtime.dispose();
});

it('hands on only the completions whose batch asked to be revealed', () => {
const runtime = createRuntime();

runtime.start();
emitStatusChanged({ destination: 'gallery:reveal', item_id: 7 });
// Legacy web sends `gallery` on every workflow and upscale run, so it cannot opt in.
emitStatusChanged({ destination: 'gallery', item_id: 8 });
emitStatusChanged({ destination: 'canvas', item_id: 9 });
emitStatusChanged({ item_id: 10 });

expect(onRevealRequest.mock.calls).toEqual([[7]]);

accountLifecycle.invalidate();
emitStatusChanged({ destination: 'gallery:reveal', item_id: 11 });

expect(onRevealRequest).toHaveBeenCalledTimes(1);
runtime.dispose();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@ export interface GalleryRealtimeBackend {

/** The fields of the backend's `queue_item_status_changed` payload that Gallery reads. */
interface QueueItemStatusChangedPayload {
destination: string | null;
item_id: number;
origin: string | null;
status: string;
user_id: string;
}

/**
* External-client reveal contract. A batch enqueued with `POST /api/v1/queue/default/enqueue_batch` and
* `batch.destination` set to this value, by the signed-in user (any user on a single-user server) and with an origin
* that does not start with `webv2:`, asks for the last saved image of each completed item to be revealed, best
* effort, in that user's open Gallery. Every other destination, `gallery` included (legacy web sends it on every
* workflow and upscale run), only refreshes the Gallery. When the reveal happens, what it changes, and its limits
* (workflow-call children among them) are documented in the Gallery README.
*/
const REVEAL_DESTINATION = 'gallery:reveal';

export interface GalleryRealtimeRuntime {
dispose(): void;
start(): void;
Expand All @@ -31,6 +43,7 @@ export const createGalleryRealtimeRuntime = ({
invalidate,
isClientQueueItemOrigin,
minIntervalMs = 1000,
onRevealRequest,
}: {
backend: GalleryRealtimeBackend;
coalesceMs?: number;
Expand All @@ -40,6 +53,8 @@ export const createGalleryRealtimeRuntime = ({
/** True for origins of items this client enqueues; Queue refreshes for those in the tab that tracks them. */
isClientQueueItemOrigin: (origin: string | null) => boolean;
minIntervalMs?: number;
/** Receives the backend item id of each of those completions whose batch asked to be revealed. */
onRevealRequest?: (queueItemId: number) => void;
}): GalleryRealtimeRuntime => {
const owner = captureAccountScope();
const detachers: Array<() => void> = [];
Expand Down Expand Up @@ -75,11 +90,18 @@ export const createGalleryRealtimeRuntime = ({
const sessionUserId = getSessionUserId();

if (
event.status === 'completed' &&
!isClientQueueItemOrigin(event.origin) &&
(sessionUserId === null || event.user_id === sessionUserId)
!isActive() ||
event.status !== 'completed' ||
isClientQueueItemOrigin(event.origin) ||
(sessionUserId !== null && event.user_id !== sessionUserId)
) {
scheduleInvalidation();
return;
}

scheduleInvalidation();

if (event.destination === REVEAL_DESTINATION) {
onRevealRequest?.(event.item_id);
}
};

Expand Down
Loading
Loading