Skip to content

Commit df83fd0

Browse files
DustyShoelstein
andauthored
Fix: add SD negative prompt graph wiring and history (invoke-ai#9243)
* Fix: add SD negative prompt graph wiring and history * Guard negativePromptChanged dispatch to only run in models that support negative prompt --------- Co-authored-by: Lincoln Stein <lincoln.stein@gmail.com>
1 parent 134eeb6 commit df83fd0

16 files changed

Lines changed: 564 additions & 65 deletions

File tree

invokeai/frontend/web/public/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@
227227
"direction": "Direction",
228228
"ipAdapter": "IP Adapter",
229229
"t2iAdapter": "T2I Adapter",
230+
"prompt": "Prompt",
230231
"positivePrompt": "Positive Prompt",
231232
"negativePrompt": "Negative Prompt",
232233
"removeNegativePrompt": "Remove Negative Prompt",

invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { describe, expect, it } from 'vitest';
99

1010
import {
1111
paramsSliceConfig,
12+
positivePromptAddedToHistory,
13+
promptRemovedFromHistory,
1214
selectModelSupportsDimensions,
1315
selectModelSupportsGuidance,
1416
selectModelSupportsNegativePrompt,
@@ -165,4 +167,52 @@ describe('paramsSliceConfig persisted state migration', () => {
165167
expect(result.dimensions.width).toBe(768);
166168
expect(result.dimensions.height).toBe(768);
167169
});
170+
171+
it('migrates old positive prompt history entries to prompt pairs', () => {
172+
expect(migrate).toBeDefined();
173+
174+
const initial = getInitialParamsState();
175+
const v3State: Record<string, unknown> = {
176+
...initial,
177+
positivePromptHistory: ['a fluffy cat'],
178+
};
179+
180+
const result = migrate?.(v3State) as ReturnType<typeof getInitialParamsState>;
181+
182+
expect(result.positivePromptHistory).toEqual([{ positivePrompt: 'a fluffy cat', negativePrompt: null }]);
183+
});
184+
});
185+
186+
describe('paramsSlice prompt history', () => {
187+
it('stores positive and negative prompts in the same history item', () => {
188+
const initial = getInitialParamsState();
189+
const state = paramsSliceConfig.slice.reducer(
190+
initial,
191+
positivePromptAddedToHistory({ positivePrompt: ' a fluffy cat ', negativePrompt: ' blurry ' })
192+
);
193+
194+
expect(state.positivePromptHistory).toEqual([{ positivePrompt: 'a fluffy cat', negativePrompt: 'blurry' }]);
195+
});
196+
197+
it('deduplicates and removes prompt history by positive and negative prompt pair', () => {
198+
const initial = getInitialParamsState();
199+
const withFirstPrompt = paramsSliceConfig.slice.reducer(
200+
initial,
201+
positivePromptAddedToHistory({ positivePrompt: 'a cat', negativePrompt: 'blurry' })
202+
);
203+
const withSecondPrompt = paramsSliceConfig.slice.reducer(
204+
withFirstPrompt,
205+
positivePromptAddedToHistory({ positivePrompt: 'a cat', negativePrompt: 'low quality' })
206+
);
207+
const removed = paramsSliceConfig.slice.reducer(
208+
withSecondPrompt,
209+
promptRemovedFromHistory({ positivePrompt: 'a cat', negativePrompt: 'blurry' })
210+
);
211+
212+
expect(withSecondPrompt.positivePromptHistory).toEqual([
213+
{ positivePrompt: 'a cat', negativePrompt: 'low quality' },
214+
{ positivePrompt: 'a cat', negativePrompt: 'blurry' },
215+
]);
216+
expect(removed.positivePromptHistory).toEqual([{ positivePrompt: 'a cat', negativePrompt: 'low quality' }]);
217+
});
168218
});

invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMul
77
import { isPlainObject } from 'es-toolkit';
88
import { clamp } from 'es-toolkit/compat';
99
import { logout } from 'features/auth/store/authSlice';
10-
import type { AspectRatioID, InfillMethod, ParamsState, RgbaColor } from 'features/controlLayers/store/types';
10+
import type {
11+
AspectRatioID,
12+
InfillMethod,
13+
ParamsState,
14+
PromptHistoryItem,
15+
RgbaColor,
16+
} from 'features/controlLayers/store/types';
1117
import {
1218
ASPECT_RATIO_MAP,
1319
DEFAULT_ASPECT_RATIO_CONFIG,
@@ -299,20 +305,33 @@ const slice = createSlice({
299305
positivePromptChanged: (state, action: PayloadAction<ParameterPositivePrompt>) => {
300306
state.positivePrompt = action.payload;
301307
},
302-
positivePromptAddedToHistory: (state, action: PayloadAction<ParameterPositivePrompt>) => {
303-
const prompt = action.payload.trim();
304-
if (prompt.length === 0) {
308+
positivePromptAddedToHistory: (state, action: PayloadAction<PromptHistoryItem>) => {
309+
const prompt: PromptHistoryItem = {
310+
positivePrompt: action.payload.positivePrompt.trim(),
311+
negativePrompt: action.payload.negativePrompt?.trim() || null,
312+
};
313+
if (prompt.positivePrompt.length === 0 && !prompt.negativePrompt) {
305314
return;
306315
}
307316

308-
state.positivePromptHistory = [prompt, ...state.positivePromptHistory.filter((p) => p !== prompt)];
317+
state.positivePromptHistory = [
318+
prompt,
319+
...state.positivePromptHistory.filter(
320+
(p) =>
321+
p.positivePrompt !== prompt.positivePrompt || (p.negativePrompt ?? null) !== (prompt.negativePrompt ?? null)
322+
),
323+
];
309324

310325
if (state.positivePromptHistory.length > MAX_POSITIVE_PROMPT_HISTORY) {
311326
state.positivePromptHistory = state.positivePromptHistory.slice(0, MAX_POSITIVE_PROMPT_HISTORY);
312327
}
313328
},
314-
promptRemovedFromHistory: (state, action: PayloadAction<string>) => {
315-
state.positivePromptHistory = state.positivePromptHistory.filter((p) => p !== action.payload);
329+
promptRemovedFromHistory: (state, action: PayloadAction<PromptHistoryItem>) => {
330+
state.positivePromptHistory = state.positivePromptHistory.filter(
331+
(p) =>
332+
p.positivePrompt !== action.payload.positivePrompt ||
333+
(p.negativePrompt ?? null) !== (action.payload.negativePrompt ?? null)
334+
);
316335
},
317336
promptHistoryCleared: (state) => {
318337
state.positivePromptHistory = [];

invokeai/frontend/web/src/features/controlLayers/store/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,8 +765,16 @@ const zDimensionsState = z.object({
765765
});
766766

767767
export const MAX_POSITIVE_PROMPT_HISTORY = 100;
768+
const zPromptHistoryItem = z.union([
769+
zParameterPositivePrompt.transform((positivePrompt) => ({ positivePrompt, negativePrompt: null })),
770+
z.object({
771+
positivePrompt: zParameterPositivePrompt,
772+
negativePrompt: zParameterNegativePrompt,
773+
}),
774+
]);
775+
export type PromptHistoryItem = z.infer<typeof zPromptHistoryItem>;
768776
const zPositivePromptHistory = z
769-
.array(zParameterPositivePrompt)
777+
.array(zPromptHistoryItem)
770778
.transform((arr) => arr.slice(0, MAX_POSITIVE_PROMPT_HISTORY));
771779

772780
export const zInfillMethod = z.enum(['patchmatch', 'lama', 'cv2', 'color', 'tile']);

invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { range } from 'es-toolkit/compat';
44
import type { SeedBehaviour } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
55
import type { BaseModelType } from 'features/nodes/types/common';
66
import type { Graph } from 'features/nodes/util/graph/generation/Graph';
7+
import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
78
import type { components } from 'services/api/schema';
89
import type { Batch, EnqueueBatchArg, Invocation } from 'services/api/types';
910

@@ -26,11 +27,12 @@ export const prepareLinearUIBatch = (arg: {
2627
prepend: boolean;
2728
base: BaseModelType;
2829
positivePromptNode: Invocation<'string'>;
30+
negativePromptNode?: Invocation<'string'>;
2931
seedNode?: Invocation<'integer'>;
3032
origin: string;
3133
destination: string;
3234
}): EnqueueBatchArg => {
33-
const { state, g, base, prepend, positivePromptNode, seedNode, origin, destination } = arg;
35+
const { state, g, base, prepend, positivePromptNode, negativePromptNode, seedNode, origin, destination } = arg;
3436
const { iterations, shouldRandomizeSeed, seed } = state.params;
3537
const { prompts, seedBehaviour } = state.dynamicPrompts;
3638

@@ -74,6 +76,15 @@ export const prepareLinearUIBatch = (arg: {
7476
items: extendedPrompts,
7577
});
7678

79+
if (negativePromptNode) {
80+
const negativePrompt = selectPresetModifiedPrompts(state).negative;
81+
firstBatchDatumList.push({
82+
node_path: negativePromptNode.id,
83+
field_name: 'value',
84+
items: extendedPrompts.map(() => negativePrompt),
85+
});
86+
}
87+
7788
data.push(firstBatchDatumList);
7889

7990
// Models without a seed node (e.g. external API models without seed support) can't express

invokeai/frontend/web/src/features/nodes/util/graph/buildMultidiffusionUpscaleGraph.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { isNonRefinerMainModelConfig, isSpandrelImageToImageModelConfig } from '
77
import { assert } from 'tsafe';
88

99
import { addLoRAs } from './generation/addLoRAs';
10-
import { getBoardField, selectPresetModifiedPrompts } from './graphBuilderUtils';
10+
import { getBoardField } from './graphBuilderUtils';
1111
import type { GraphBuilderReturn } from './types';
1212

1313
export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise<GraphBuilderReturn> => {
@@ -35,6 +35,10 @@ export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise
3535
id: getPrefixedId('positive_prompt'),
3636
type: 'string',
3737
});
38+
const negativePrompt = g.addNode({
39+
id: getPrefixedId('negative_prompt'),
40+
type: 'string',
41+
});
3842

3943
const spandrelAutoscale = g.addNode({
4044
type: 'spandrel_image_to_image_autoscale',
@@ -105,17 +109,13 @@ export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise
105109
let modelLoader;
106110

107111
if (model.base === 'sdxl') {
108-
const prompts = selectPresetModifiedPrompts(state);
109-
110112
posCond = g.addNode({
111113
type: 'sdxl_compel_prompt',
112114
id: getPrefixedId('pos_cond'),
113115
});
114116
negCond = g.addNode({
115117
type: 'sdxl_compel_prompt',
116118
id: getPrefixedId('neg_cond'),
117-
prompt: prompts.negative,
118-
style: prompts.negative,
119119
});
120120
modelLoader = g.addNode({
121121
type: 'sdxl_model_loader',
@@ -131,24 +131,21 @@ export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise
131131

132132
g.addEdge(positivePrompt, 'value', posCond, 'prompt');
133133
g.addEdge(positivePrompt, 'value', posCond, 'style');
134+
g.addEdge(negativePrompt, 'value', negCond, 'prompt');
135+
g.addEdge(negativePrompt, 'value', negCond, 'style');
134136

135137
addSDXLLoRAs(state, g, tiledMultidiffusion, modelLoader, null, posCond, negCond);
136138

137-
g.upsertMetadata({
138-
negative_prompt: prompts.negative,
139-
});
140139
g.addEdgeToMetadata(positivePrompt, 'value', 'positive_prompt');
140+
g.addEdgeToMetadata(negativePrompt, 'value', 'negative_prompt');
141141
} else {
142-
const prompts = selectPresetModifiedPrompts(state);
143-
144142
posCond = g.addNode({
145143
type: 'compel',
146144
id: getPrefixedId('pos_cond'),
147145
});
148146
negCond = g.addNode({
149147
type: 'compel',
150148
id: getPrefixedId('neg_cond'),
151-
prompt: prompts.negative,
152149
});
153150
modelLoader = g.addNode({
154151
type: 'main_model_loader',
@@ -166,14 +163,12 @@ export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise
166163
g.addEdge(modelLoader, 'unet', tiledMultidiffusion, 'unet');
167164

168165
g.addEdge(positivePrompt, 'value', posCond, 'prompt');
166+
g.addEdge(negativePrompt, 'value', negCond, 'prompt');
169167

170168
addLoRAs(state, g, tiledMultidiffusion, modelLoader, null, clipSkipNode, posCond, negCond);
171169

172-
g.upsertMetadata({
173-
negative_prompt: prompts.negative,
174-
});
175-
176170
g.addEdgeToMetadata(positivePrompt, 'value', 'positive_prompt');
171+
g.addEdgeToMetadata(negativePrompt, 'value', 'negative_prompt');
177172
}
178173

179174
const modelConfig = await fetchModelConfigWithTypeGuard(model.key, isNonRefinerMainModelConfig);
@@ -261,5 +256,6 @@ export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise
261256
g,
262257
seed,
263258
positivePrompt,
259+
negativePrompt,
264260
};
265261
};

invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSD1Graph.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { addSeamless } from 'features/nodes/util/graph/generation/addSeamless';
1515
import { addTextToImage } from 'features/nodes/util/graph/generation/addTextToImage';
1616
import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker';
1717
import { Graph } from 'features/nodes/util/graph/generation/Graph';
18-
import { selectCanvasOutputFields, selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
18+
import { selectCanvasOutputFields } from 'features/nodes/util/graph/graphBuilderUtils';
1919
import type { GraphBuilderArg, GraphBuilderReturn, ImageOutputNodes } from 'features/nodes/util/graph/types';
2020
import { selectActiveTab } from 'features/ui/store/uiSelectors';
2121
import type { Invocation } from 'services/api/types';
@@ -51,8 +51,6 @@ export const buildSD1Graph = async (arg: GraphBuilderArg): Promise<GraphBuilderR
5151
} = params;
5252

5353
const fp32 = vaePrecision === 'fp32';
54-
const prompts = selectPresetModifiedPrompts(state);
55-
5654
const g = new Graph(getPrefixedId('sd1_graph'));
5755
const seed = g.addNode({
5856
id: getPrefixedId('seed'),
@@ -62,6 +60,10 @@ export const buildSD1Graph = async (arg: GraphBuilderArg): Promise<GraphBuilderR
6260
id: getPrefixedId('positive_prompt'),
6361
type: 'string',
6462
});
63+
const negativePrompt = g.addNode({
64+
id: getPrefixedId('negative_prompt'),
65+
type: 'string',
66+
});
6567
const modelLoader = g.addNode({
6668
type: 'main_model_loader',
6769
id: getPrefixedId('sd1_model_loader'),
@@ -83,7 +85,6 @@ export const buildSD1Graph = async (arg: GraphBuilderArg): Promise<GraphBuilderR
8385
const negCond = g.addNode({
8486
type: 'compel',
8587
id: getPrefixedId('neg_cond'),
86-
prompt: prompts.negative,
8788
});
8889
const negCondCollect = g.addNode({
8990
type: 'collect',
@@ -127,6 +128,7 @@ export const buildSD1Graph = async (arg: GraphBuilderArg): Promise<GraphBuilderR
127128
g.addEdge(posCond, 'conditioning', posCondCollect, 'item');
128129
g.addEdge(posCondCollect, 'collection', denoise, 'positive_conditioning');
129130

131+
g.addEdge(negativePrompt, 'value', negCond, 'prompt');
130132
g.addEdge(negCond, 'conditioning', negCondCollect, 'item');
131133
g.addEdge(negCondCollect, 'collection', denoise, 'negative_conditioning');
132134

@@ -137,7 +139,6 @@ export const buildSD1Graph = async (arg: GraphBuilderArg): Promise<GraphBuilderR
137139
g.upsertMetadata({
138140
cfg_scale,
139141
cfg_rescale_multiplier,
140-
negative_prompt: prompts.negative,
141142
model: Graph.getModelMetadataField(model),
142143
steps,
143144
rand_device: shouldUseCpuNoise ? 'cpu' : 'cuda',
@@ -147,6 +148,7 @@ export const buildSD1Graph = async (arg: GraphBuilderArg): Promise<GraphBuilderR
147148
});
148149
g.addEdgeToMetadata(seed, 'value', 'seed');
149150
g.addEdgeToMetadata(positivePrompt, 'value', 'positive_prompt');
151+
g.addEdgeToMetadata(negativePrompt, 'value', 'negative_prompt');
150152

151153
const seamless = addSeamless(state, g, denoise, modelLoader, vaeLoader);
152154

@@ -325,5 +327,6 @@ export const buildSD1Graph = async (arg: GraphBuilderArg): Promise<GraphBuilderR
325327
g,
326328
seed,
327329
positivePrompt,
330+
negativePrompt,
328331
};
329332
};

0 commit comments

Comments
 (0)