mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
Compare commits
14 Commits
feature/sq
...
lstein/rec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d54adab3c8 | ||
|
|
3a1067bbf1 | ||
|
|
df5d9d65b1 | ||
|
|
d9fe4ace26 | ||
|
|
3404cd7670 | ||
|
|
5b969af483 | ||
|
|
b9a19afb82 | ||
|
|
c43df36f26 | ||
|
|
6108ca54c4 | ||
|
|
bc9dfb1428 | ||
|
|
7d1bf270ef | ||
|
|
9412d7b2d6 | ||
|
|
4938d0a3de | ||
|
|
4da9fab888 |
@@ -1,377 +0,0 @@
|
||||
---
|
||||
title: Recall Parameters API (Advanced)
|
||||
---
|
||||
|
||||
# Recall Parameters API - LoRAs, ControlNets, and IP Adapters with Images
|
||||
|
||||
The Recall Parameters API supports recalling LoRAs, ControlNets (including T2I Adapters and Control LoRAs), and IP Adapters along with their associated weights and settings. Control Layers and IP Adapters can now include image references from the `INVOKEAI_ROOT/outputs/images` directory for fully functional control and image prompt functionality.
|
||||
|
||||
## Key Features
|
||||
|
||||
✅ **LoRAs**: Fully functional - adds to UI, queries model configs, applies weights<br />
|
||||
✅ **Control Layers**: Full support with optional images from outputs/images<br />
|
||||
✅ **IP Adapters**: Full support with optional reference images from outputs/images<br />
|
||||
✅ **Model Name Resolution**: Automatic lookup from human-readable names to internal keys<br />
|
||||
✅ **Image Validation**: Backend validates that image files exist before sending
|
||||
|
||||
## Endpoints
|
||||
|
||||
### POST `/api/v1/recall/{queue_id}`
|
||||
|
||||
Updates recallable parameters for the frontend, including LoRAs, control adapters, and IP adapters with optional images.
|
||||
|
||||
**Path Parameters:**
|
||||
- `queue_id` (string): The queue ID to associate parameters with (typically "default")
|
||||
|
||||
**Request Body:**
|
||||
|
||||
All fields are optional. Include only the parameters you want to update.
|
||||
|
||||
```typescript
|
||||
{
|
||||
// Standard parameters
|
||||
positive_prompt?: string;
|
||||
negative_prompt?: string;
|
||||
model?: string; // Model name or key
|
||||
steps?: number;
|
||||
cfg_scale?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
seed?: number;
|
||||
// ... other standard parameters
|
||||
|
||||
// LoRAs
|
||||
loras?: Array<{
|
||||
model_name: string; // LoRA model name
|
||||
weight?: number; // Default: 0.75, Range: -10 to 10
|
||||
is_enabled?: boolean; // Default: true
|
||||
}>;
|
||||
|
||||
// Control Layers (ControlNet, T2I Adapter, Control LoRA)
|
||||
control_layers?: Array<{
|
||||
model_name: string; // Control adapter model name
|
||||
image_name?: string; // Optional image filename from outputs/images
|
||||
weight?: number; // Default: 1.0, Range: -1 to 2
|
||||
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
|
||||
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
|
||||
control_mode?: "balanced" | "more_prompt" | "more_control"; // ControlNet only
|
||||
}>;
|
||||
|
||||
// IP Adapters
|
||||
ip_adapters?: Array<{
|
||||
model_name: string; // IP Adapter model name
|
||||
image_name?: string; // Optional reference image filename from outputs/images
|
||||
weight?: number; // Default: 1.0, Range: -1 to 2
|
||||
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
|
||||
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
|
||||
method?: "full" | "style" | "composition"; // Default: "full"
|
||||
influence?: "Lowest" | "Low" | "Medium" | "High" | "Highest"; // Flux Redux only; default: "highest"
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
## Model Name Resolution
|
||||
|
||||
The backend automatically resolves model names to their internal keys:
|
||||
|
||||
1. **Main Models**: Resolved from the name to the model key
|
||||
2. **LoRAs**: Searched in the LoRA model database
|
||||
3. **Control Adapters**: Tried in order - ControlNet → T2I Adapter → Control LoRA
|
||||
4. **IP Adapters**: Searched in the IP Adapter model database
|
||||
|
||||
Models that cannot be resolved are skipped with a warning in the logs.
|
||||
|
||||
## Image File Handling
|
||||
|
||||
### Image Path Resolution
|
||||
|
||||
When you specify an `image_name`, the backend:
|
||||
1. Constructs the full path: `{INVOKEAI_ROOT}/outputs/images/{image_name}`
|
||||
2. Validates that the file exists
|
||||
3. Includes the image reference in the event sent to the frontend
|
||||
4. Logs whether the image was found or not
|
||||
|
||||
### Image Naming
|
||||
|
||||
Images should be referenced by their filename as it appears in the outputs/images directory:
|
||||
- ✅ Correct: `"image_name": "example.png"`
|
||||
- ✅ Correct: `"image_name": "my_control_image_20240110.jpg"`
|
||||
- ❌ Incorrect: `"image_name": "outputs/images/example.png"` (use relative filename only)
|
||||
- ❌ Incorrect: `"image_name": "/full/path/to/example.png"` (use relative filename only)
|
||||
|
||||
## Frontend Behavior
|
||||
|
||||
### LoRAs
|
||||
- **Fully Supported**: LoRAs are immediately added to the LoRA list in the UI
|
||||
- Existing LoRAs are cleared before adding new ones
|
||||
- Each LoRA's model config is fetched and applied with the specified weight
|
||||
- LoRAs appear in the LoRA selector panel
|
||||
|
||||
### Control Layers with Images
|
||||
- **Fully Supported**: Control layers now support images from outputs/images
|
||||
- Configuration includes model, weights, step percentages, and image reference
|
||||
- Image availability is logged in frontend console
|
||||
- Images can be used to create actual control layers through the UI
|
||||
|
||||
### IP Adapters with Images
|
||||
- **Fully Supported**: IP Adapters now support reference images from outputs/images
|
||||
- Configuration includes model, weights, step percentages, method, and image reference
|
||||
- Image availability is logged in frontend console
|
||||
- Images can be used to create actual reference image layers through the UI
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. Add LoRAs Only
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"loras": [
|
||||
{
|
||||
"model_name": "add-detail-xl",
|
||||
"weight": 0.8,
|
||||
"is_enabled": true
|
||||
},
|
||||
{
|
||||
"model_name": "sd_xl_offset_example-lora_1.0",
|
||||
"weight": 0.5,
|
||||
"is_enabled": true
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. Configure Control Layers with Image
|
||||
|
||||
Replace `my_control_image.png` with an actual image filename from your outputs/images directory.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "controlnet-canny-sdxl-1.0",
|
||||
"image_name": "my_control_image.png",
|
||||
"weight": 0.75,
|
||||
"begin_step_percent": 0.0,
|
||||
"end_step_percent": 0.8,
|
||||
"control_mode": "balanced"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. Configure IP Adapters with Reference Image
|
||||
|
||||
Replace `reference_face.png` with an actual image filename from your outputs/images directory.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "ip-adapter-plus-face_sd15",
|
||||
"image_name": "reference_face.png",
|
||||
"weight": 0.7,
|
||||
"begin_step_percent": 0.0,
|
||||
"end_step_percent": 1.0,
|
||||
"method": "composition"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. Complete Configuration with All Features
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"positive_prompt": "masterpiece, detailed photo with specific style",
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"model": "FLUX Schnell",
|
||||
"steps": 25,
|
||||
"cfg_scale": 8.0,
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"seed": 42,
|
||||
"loras": [
|
||||
{
|
||||
"model_name": "add-detail-xl",
|
||||
"weight": 0.6,
|
||||
"is_enabled": true
|
||||
}
|
||||
],
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "controlnet-depth-sdxl-1.0",
|
||||
"image_name": "depth_map.png",
|
||||
"weight": 1.0,
|
||||
"begin_step_percent": 0.0,
|
||||
"end_step_percent": 0.7
|
||||
}
|
||||
],
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "ip-adapter-plus-face_sd15",
|
||||
"image_name": "style_reference.png",
|
||||
"weight": 0.5,
|
||||
"begin_step_percent": 0.0,
|
||||
"end_step_percent": 1.0,
|
||||
"method": "style"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"queue_id": "default",
|
||||
"updated_count": 15,
|
||||
"parameters": {
|
||||
"positive_prompt": "...",
|
||||
"steps": 25,
|
||||
"loras": [
|
||||
{
|
||||
"model_key": "abc123...",
|
||||
"weight": 0.6,
|
||||
"is_enabled": true
|
||||
}
|
||||
],
|
||||
"control_layers": [
|
||||
{
|
||||
"model_key": "controlnet-xyz...",
|
||||
"weight": 1.0,
|
||||
"image": {
|
||||
"image_name": "depth_map.png"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_key": "ip-adapter-xyz...",
|
||||
"weight": 0.5,
|
||||
"image": {
|
||||
"image_name": "style_reference.png"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
When parameters are updated, a `recall_parameters_updated` event is emitted via WebSocket to the queue room. The frontend automatically:
|
||||
|
||||
1. Applies standard parameters (prompts, steps, dimensions, etc.)
|
||||
2. Loads and adds LoRAs to the LoRA list
|
||||
3. Logs control layer and IP adapter configurations with image information
|
||||
4. Makes image references available for manual canvas/reference image creation
|
||||
|
||||
## Logging
|
||||
|
||||
### Backend Logs
|
||||
|
||||
Backend logs show:
|
||||
- Model name → key resolution (success/failure)
|
||||
- Image file validation (found/not found)
|
||||
- Parameter storage confirmation
|
||||
- Event emission status
|
||||
|
||||
Example log messages:
|
||||
```
|
||||
INFO: Resolved ControlNet model name 'controlnet-canny-sdxl-1.0' to key 'controlnet-xyz...'
|
||||
INFO: Found image file: depth_map.png
|
||||
INFO: Updated 12 recall parameters for queue default
|
||||
INFO: Resolved 1 LoRA(s)
|
||||
INFO: Resolved 1 control layer(s)
|
||||
INFO: Resolved 1 IP adapter(s)
|
||||
```
|
||||
|
||||
### Frontend Logs
|
||||
|
||||
Frontend logs (check browser console):
|
||||
- Set `localStorage.ROARR_FILTER = 'debug'` to see all debug messages
|
||||
- Look for messages from the `events` namespace
|
||||
- LoRA loading, model resolution, and parameter application are logged
|
||||
|
||||
Example log messages:
|
||||
```
|
||||
INFO: Applied 5 recall parameters to store
|
||||
INFO: Received 1 control layer(s) with image support
|
||||
INFO: Control layer 1: controlnet-xyz... (weight: 0.75, image: depth_map.png)
|
||||
DEBUG: Control layer 1 image available at: outputs/images/depth_map.png
|
||||
INFO: Received 1 IP adapter(s) with image support
|
||||
INFO: IP adapter 1: ip-adapter-xyz... (weight: 0.7, image: style_reference.png)
|
||||
DEBUG: IP adapter 1 image available at: outputs/images/style_reference.png
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Canvas Integration**: Control layers and IP adapters with images are currently logged but not automatically added to canvas layers
|
||||
- Users can view the configuration and manually create canvas layers with the provided images
|
||||
- Future enhancement: Auto-create canvas layers with stored images
|
||||
|
||||
2. **Model Availability**: Models must be installed in InvokeAI before they can be recalled
|
||||
|
||||
3. **Image Availability**: Images must exist in the outputs/images directory
|
||||
- Missing images are logged as warnings but don't fail the request
|
||||
- Other parameters are still applied even if images are missing
|
||||
|
||||
4. **Image URLs**: Only local filenames from outputs/images are supported
|
||||
- Remote image URLs are not currently supported
|
||||
|
||||
## Testing
|
||||
|
||||
Use the provided test script:
|
||||
|
||||
```bash
|
||||
./test_recall_loras_controlnets.sh
|
||||
```
|
||||
|
||||
This will test:
|
||||
- LoRA addition with multiple models
|
||||
- Control layer configuration with image references
|
||||
- IP adapter configuration with image references
|
||||
- Combined parameter updates with all features
|
||||
|
||||
Note: Update the image names in the test script to match actual images in your outputs/images directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Images Not Found
|
||||
|
||||
If you see "Image file not found" in the logs:
|
||||
1. Verify the image filename matches exactly (case-sensitive)
|
||||
2. Ensure the image is in `{INVOKEAI_ROOT}/outputs/images/`
|
||||
3. Check that the filename doesn't include the `outputs/images/` prefix
|
||||
|
||||
### Models Not Found
|
||||
|
||||
If you see "Could not find model" messages:
|
||||
1. Verify the model name matches exactly (case-sensitive)
|
||||
2. Ensure the model is installed in InvokeAI
|
||||
3. Check the model name using the models browser in the UI
|
||||
|
||||
### Event Not Received
|
||||
|
||||
If the frontend doesn't receive the event:
|
||||
1. Check browser console for connection errors
|
||||
2. Verify the queue_id matches the frontend's queue (usually "default")
|
||||
3. Check backend logs for event emission errors
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
1. Auto-create canvas layers with provided control layer images
|
||||
2. Auto-create reference image layers with provided IP adapter images
|
||||
3. Support for image URLs
|
||||
4. Batch operations for multiple queue IDs
|
||||
5. Image upload capability (accept base64 or file upload)
|
||||
@@ -4,29 +4,54 @@ title: Recall Parameters API
|
||||
|
||||
## Overview
|
||||
|
||||
A new REST API endpoint has been added to the InvokeAI backend that allows programmatic updates to recallable parameters from another process. This enables external applications or scripts to modify frontend parameters like prompts, models, and step counts via HTTP requests.
|
||||
The Recall Parameters API is a REST endpoint on the InvokeAI backend that
|
||||
lets external processes set recallable generation parameters on the
|
||||
frontend. Supported parameters include:
|
||||
|
||||
When parameters are updated via the API, the backend automatically broadcasts a WebSocket event to all connected frontend clients subscribed to that queue, causing them to update immediately.
|
||||
- Core text and numeric parameters (prompts, model, steps, CFG, dimensions, seed, ...)
|
||||
- LoRAs
|
||||
- Control Layers (ControlNet, T2I Adapter, Control LoRA) with optional control images
|
||||
- IP Adapters and FLUX Redux reference images with optional images
|
||||
- Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit)
|
||||
|
||||
When parameters are updated via the API, the backend stores them in client
|
||||
state persistence for the target queue and broadcasts a `recall_parameters_updated`
|
||||
WebSocket event. Any frontend client subscribed to that queue applies the
|
||||
new values immediately — no manual reload required.
|
||||
|
||||
Typical use cases:
|
||||
|
||||
- An external image browser that wants to "recall" or "remix" the
|
||||
generation parameters saved into a PNG's metadata.
|
||||
- A script that pre-populates parameters before the user runs generation.
|
||||
- Automated testing or batch workflows that want to reuse existing model
|
||||
and adapter configurations.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **API Request**: External application sends a POST request with parameters to update
|
||||
2. **Storage**: Parameters are stored in client state persistence, associated with a queue ID
|
||||
3. **Broadcast**: A WebSocket event (`recall_parameters_updated`) is emitted to all frontend clients listening to that queue
|
||||
4. **Frontend Update**: Connected frontend clients receive the event and can process the updated parameters
|
||||
5. **Immediate Display**: The frontend UI updates automatically with the new values
|
||||
|
||||
This means if you have the InvokeAI frontend open in a browser, updating parameters via the API will instantly reflect on the screen without any manual action needed.
|
||||
1. **API request** — your client POSTs a JSON body of parameters to
|
||||
`/api/v1/recall/{queue_id}`.
|
||||
2. **Storage** — non-null parameters are stored under
|
||||
`recall_*` keys in the client state persistence service, scoped to the
|
||||
given `queue_id`.
|
||||
3. **Resolution** — models are resolved from human-readable names to the
|
||||
internal model keys used by the frontend, and image filenames are
|
||||
validated against `{INVOKEAI_ROOT}/outputs/images`.
|
||||
4. **Broadcast** — a `recall_parameters_updated` event is emitted on the
|
||||
websocket room for `queue_id`.
|
||||
5. **Frontend update** — any connected client subscribed to that queue
|
||||
applies the update to its Redux store, so UI fields, LoRAs, control
|
||||
layers, IP adapters, and reference images all populate immediately.
|
||||
|
||||
## Endpoint
|
||||
|
||||
**Base URL**: `http://localhost:9090/api/v1/recall/{queue_id}`
|
||||
**Base URL:** `http://localhost:9090/api/v1/recall/{queue_id}`
|
||||
|
||||
## POST - Update Recall Parameters
|
||||
The queue id is usually `default`.
|
||||
|
||||
Updates recallable parameters for a given queue ID.
|
||||
### POST — Update Recall Parameters
|
||||
|
||||
### Request
|
||||
Updates recallable parameters for the given `queue_id`.
|
||||
|
||||
```http
|
||||
POST /api/v1/recall/{queue_id}
|
||||
@@ -44,11 +69,25 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
The queue id is usually "default".
|
||||
All parameters are optional — only send the fields you want to update.
|
||||
|
||||
### Parameters
|
||||
### GET — Retrieve Recall Parameters
|
||||
|
||||
All parameters are optional. Only provide the parameters you want to update:
|
||||
```http
|
||||
GET /api/v1/recall/{queue_id}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"queue_id": "queue_123",
|
||||
"note": "Use the frontend to access stored recall parameters, or set specific parameters using POST"
|
||||
}
|
||||
```
|
||||
|
||||
## Request Schema
|
||||
|
||||
### Core parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
@@ -67,60 +106,130 @@ All parameters are optional. Only provide the parameters you want to update:
|
||||
| `width` | integer | Image width in pixels (≥64) |
|
||||
| `height` | integer | Image height in pixels (≥64) |
|
||||
| `seed` | integer | Random seed (≥0) |
|
||||
| `denoise_strength` | number | Denoising strength (0-1) |
|
||||
| `refiner_denoise_start` | number | Refiner denoising start (0-1) |
|
||||
| `denoise_strength` | number | Denoising strength (0–1) |
|
||||
| `refiner_denoise_start` | number | Refiner denoising start (0–1) |
|
||||
| `clip_skip` | integer | CLIP skip layers (≥0) |
|
||||
| `seamless_x` | boolean | Enable seamless X tiling |
|
||||
| `seamless_y` | boolean | Enable seamless Y tiling |
|
||||
| `refiner_positive_aesthetic_score` | number | Refiner positive aesthetic score |
|
||||
| `refiner_negative_aesthetic_score` | number | Refiner negative aesthetic score |
|
||||
|
||||
### Response
|
||||
### Collection parameters
|
||||
|
||||
```json
|
||||
```typescript
|
||||
{
|
||||
"status": "success",
|
||||
"queue_id": "queue_123",
|
||||
"updated_count": 7,
|
||||
"parameters": {
|
||||
"positive_prompt": "a beautiful landscape",
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"model": "sd-1.5",
|
||||
"steps": 20,
|
||||
"cfg_scale": 7.5,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"seed": 12345
|
||||
}
|
||||
// LoRAs
|
||||
loras?: Array<{
|
||||
model_name: string; // LoRA model name
|
||||
weight?: number; // Default: 0.75, Range: -10 to 10
|
||||
is_enabled?: boolean; // Default: true
|
||||
}>;
|
||||
|
||||
// Control Layers (ControlNet, T2I Adapter, Control LoRA)
|
||||
control_layers?: Array<{
|
||||
model_name: string; // Control adapter model name
|
||||
image_name?: string; // Optional image filename from outputs/images
|
||||
weight?: number; // Default: 1.0, Range: -1 to 2
|
||||
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
|
||||
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
|
||||
control_mode?: "balanced" | "more_prompt" | "more_control"; // ControlNet only
|
||||
}>;
|
||||
|
||||
// IP Adapters (includes FLUX Redux)
|
||||
ip_adapters?: Array<{
|
||||
model_name: string; // IP Adapter / FLUX Redux model name
|
||||
image_name?: string; // Optional reference image filename from outputs/images
|
||||
weight?: number; // Default: 1.0, Range: -1 to 2
|
||||
begin_step_percent?: number; // Default: 0.0, Range: 0 to 1
|
||||
end_step_percent?: number; // Default: 1.0, Range: 0 to 1
|
||||
method?: "full" | "style" | "composition"; // Default: "full"
|
||||
image_influence?: "lowest" | "low" | "medium" | "high" | "highest"; // FLUX Redux only
|
||||
}>;
|
||||
|
||||
// Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit)
|
||||
reference_images?: Array<{
|
||||
image_name: string; // Reference image filename from outputs/images
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
## GET - Retrieve Recall Parameters
|
||||
## Model Name Resolution
|
||||
|
||||
Retrieves metadata about stored recall parameters.
|
||||
The backend resolves model names to their internal keys:
|
||||
|
||||
### Request
|
||||
1. **Main models** — resolved from the name to the model key.
|
||||
2. **LoRAs** — searched in the LoRA model database.
|
||||
3. **Control adapters** — tried in order: ControlNet → T2I Adapter → Control LoRA.
|
||||
4. **IP Adapters** — searched in the IP Adapter database; falls back to FLUX Redux.
|
||||
|
||||
```http
|
||||
GET /api/v1/recall/{queue_id}
|
||||
```
|
||||
Models that cannot be resolved are skipped with a warning in the logs —
|
||||
the rest of the parameters are still applied.
|
||||
|
||||
### Response
|
||||
## Image File Handling
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"queue_id": "queue_123",
|
||||
"note": "Use the frontend to access stored recall parameters, or set specific parameters using POST"
|
||||
}
|
||||
```
|
||||
When an `image_name` is supplied, the backend:
|
||||
|
||||
1. Resolves `{INVOKEAI_ROOT}/outputs/images/{image_name}` via the image
|
||||
files service (which also validates the path).
|
||||
2. Opens the image to extract width/height.
|
||||
3. Includes the image metadata in the event sent to the frontend.
|
||||
4. Logs whether the image was found.
|
||||
|
||||
Images must be referenced by their filename as it appears in the
|
||||
outputs/images directory:
|
||||
|
||||
- ✅ `"image_name": "example.png"`
|
||||
- ✅ `"image_name": "my_control_image_20240110.jpg"`
|
||||
- ❌ `"image_name": "outputs/images/example.png"` (no prefix)
|
||||
- ❌ `"image_name": "/full/path/to/example.png"` (no absolute paths)
|
||||
|
||||
Missing images are logged as warnings but **do not** fail the request —
|
||||
remaining parameters are still applied.
|
||||
|
||||
## Feature Details
|
||||
|
||||
### LoRAs
|
||||
|
||||
- Existing LoRAs are cleared before new ones are added.
|
||||
- Each LoRA's model config is fetched and applied with the specified weight.
|
||||
- LoRAs appear in the LoRA selector panel.
|
||||
|
||||
### Control Layers
|
||||
|
||||
- Fully supported with optional images from `outputs/images`.
|
||||
- Configuration includes model, weights, step percentages, control mode,
|
||||
and an image reference.
|
||||
- Image availability is logged in the frontend console.
|
||||
|
||||
### IP Adapters / FLUX Redux
|
||||
|
||||
- Reference images loaded from `outputs/images` are validated and passed
|
||||
through.
|
||||
- Configuration includes model, weights, step percentages, method, and an
|
||||
image reference.
|
||||
- FLUX Redux uses `image_influence` instead of a numeric weight.
|
||||
|
||||
### Model-free reference images
|
||||
|
||||
Used by architectures that consume a reference image directly, with no
|
||||
separate adapter model:
|
||||
|
||||
- **FLUX.2 Klein** — built-in reference image support.
|
||||
- **FLUX Kontext** — reference image associated with the main model.
|
||||
- **Qwen Image Edit** — reference image associated with the main model.
|
||||
|
||||
Because there is no adapter model to resolve, these entries carry only
|
||||
`image_name`. When the frontend receives them, it picks the appropriate
|
||||
config flavor (`flux2_reference_image`, `flux_kontext_reference_image`,
|
||||
or `qwen_image_reference_image`) based on the currently-selected main
|
||||
model, matching the behavior of a manual drag-and-drop.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Using cURL
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# Update prompts and model
|
||||
# Core parameters
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -130,81 +239,285 @@ curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
"steps": 30
|
||||
}'
|
||||
|
||||
# Update just the seed
|
||||
# Just the seed
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"seed": 99999}'
|
||||
```
|
||||
|
||||
### Using Python
|
||||
### LoRAs only
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"loras": [
|
||||
{"model_name": "add-detail-xl", "weight": 0.8, "is_enabled": true},
|
||||
{"model_name": "sd_xl_offset_example-lora_1.0", "weight": 0.5}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Control layers with an image
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "controlnet-canny-sdxl-1.0",
|
||||
"image_name": "my_control_image.png",
|
||||
"weight": 0.75,
|
||||
"begin_step_percent": 0.0,
|
||||
"end_step_percent": 0.8,
|
||||
"control_mode": "balanced"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### IP adapters with a reference image
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "ip-adapter-plus-face_sd15",
|
||||
"image_name": "reference_face.png",
|
||||
"weight": 0.7,
|
||||
"method": "composition"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Model-free reference images (FLUX.2 Klein / FLUX Kontext / Qwen Image Edit)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "FLUX.2 Klein",
|
||||
"reference_images": [
|
||||
{"image_name": "style_reference.png"}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Complete configuration
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:9090/api/v1/recall/default \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"positive_prompt": "masterpiece, detailed photo with specific style",
|
||||
"negative_prompt": "blurry, low quality",
|
||||
"model": "FLUX Schnell",
|
||||
"steps": 25,
|
||||
"cfg_scale": 8.0,
|
||||
"width": 1024,
|
||||
"height": 768,
|
||||
"seed": 42,
|
||||
"loras": [
|
||||
{"model_name": "add-detail-xl", "weight": 0.6}
|
||||
],
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "controlnet-depth-sdxl-1.0",
|
||||
"image_name": "depth_map.png",
|
||||
"weight": 1.0,
|
||||
"end_step_percent": 0.7
|
||||
}
|
||||
],
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "ip-adapter-plus-face_sd15",
|
||||
"image_name": "style_reference.png",
|
||||
"weight": 0.5,
|
||||
"method": "style"
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Configuration
|
||||
API_URL = "http://localhost:9090/api/v1/recall/default"
|
||||
|
||||
# Update multiple parameters
|
||||
params = {
|
||||
"positive_prompt": "a serene forest",
|
||||
"negative_prompt": "people, buildings",
|
||||
"steps": 25,
|
||||
"cfg_scale": 7.0,
|
||||
"seed": 42
|
||||
"seed": 42,
|
||||
}
|
||||
|
||||
response = requests.post(API_URL, json=params)
|
||||
result = response.json()
|
||||
|
||||
print(f"Status: {result['status']}")
|
||||
print(f"Updated {result['updated_count']} parameters")
|
||||
print(json.dumps(result['parameters'], indent=2))
|
||||
```
|
||||
|
||||
### Using Node.js/JavaScript
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const API_URL = 'http://localhost:9090/api/v1/recall/default';
|
||||
|
||||
const params = {
|
||||
positive_prompt: 'a beautiful sunset',
|
||||
negative_prompt: 'blurry',
|
||||
steps: 20,
|
||||
width: 768,
|
||||
height: 768,
|
||||
seed: 12345
|
||||
};
|
||||
|
||||
fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params)
|
||||
body: JSON.stringify({
|
||||
positive_prompt: 'a beautiful sunset',
|
||||
steps: 20,
|
||||
width: 768,
|
||||
height: 768,
|
||||
seed: 12345,
|
||||
}),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => console.log(data));
|
||||
.then((res) => res.json())
|
||||
.then((data) => console.log(data));
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"queue_id": "default",
|
||||
"updated_count": 15,
|
||||
"parameters": {
|
||||
"positive_prompt": "...",
|
||||
"steps": 25,
|
||||
"loras": [
|
||||
{"model_key": "abc123...", "weight": 0.6, "is_enabled": true}
|
||||
],
|
||||
"control_layers": [
|
||||
{
|
||||
"model_key": "controlnet-xyz...",
|
||||
"weight": 1.0,
|
||||
"image": {"image_name": "depth_map.png", "width": 1024, "height": 768}
|
||||
}
|
||||
],
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_key": "ip-adapter-xyz...",
|
||||
"weight": 0.5,
|
||||
"image": {"image_name": "style_reference.png", "width": 1024, "height": 1024}
|
||||
}
|
||||
],
|
||||
"reference_images": [
|
||||
{"image": {"image_name": "style_reference.png", "width": 1024, "height": 1024}}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
Parameter updates emit a `recall_parameters_updated` event to the queue
|
||||
room. Connected frontend clients automatically:
|
||||
|
||||
1. Apply standard parameters (prompts, steps, dimensions, etc.).
|
||||
2. Load and add LoRAs to the LoRA list.
|
||||
3. Apply control-layer configurations.
|
||||
4. Apply IP Adapter / FLUX Redux configurations with their images.
|
||||
5. Append model-free reference images, using the config flavor that
|
||||
matches the currently-selected main model.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **400 Bad Request** — invalid parameters or parameter values.
|
||||
- **500 Internal Server Error** — server-side storage or retrieval failure.
|
||||
|
||||
Errors include detailed messages. Missing images and unresolved model
|
||||
names are **not** errors — they are logged and the remaining parameters
|
||||
are still applied.
|
||||
|
||||
## Logging
|
||||
|
||||
### Backend
|
||||
|
||||
```
|
||||
INFO: Resolved ControlNet model name 'controlnet-canny-sdxl-1.0' to key 'controlnet-xyz...'
|
||||
INFO: Found image file: depth_map.png (1024x768)
|
||||
INFO: Updated 12 recall parameters for queue default
|
||||
INFO: Resolved 1 LoRA(s)
|
||||
INFO: Resolved 1 control layer(s)
|
||||
INFO: Resolved 1 IP adapter(s)
|
||||
INFO: Resolved 1 reference image(s)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
Set `localStorage.ROARR_FILTER = 'debug'` in the browser to see all debug
|
||||
messages under the `events` namespace.
|
||||
|
||||
```
|
||||
INFO: Applied 5 recall parameters to store
|
||||
INFO: Applied 1 IP adapter(s), replacing existing list
|
||||
INFO: Applied 1 model-free reference image(s)
|
||||
DEBUG: Built IP adapter ref image state: ip-adapter-xyz... (weight: 0.7)
|
||||
DEBUG: IP adapter image: outputs/images/depth_map.png (1024x768)
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- Parameters are stored in the client state persistence service, using keys prefixed with `recall_`
|
||||
- The parameters are associated with a `queue_id`, allowing multiple concurrent sessions to maintain separate parameter sets
|
||||
- Only non-null parameters are processed and stored
|
||||
- The endpoint provides validation for numeric ranges (e.g., steps ≥ 1, dimensions ≥ 64)
|
||||
- All parameter values are JSON-serialized for storage
|
||||
- When parameter values are changed, the backend generates a web sockets event that the frontend listens to.
|
||||
- Parameters are stored in the client state persistence service under
|
||||
`recall_*` keys, scoped to the `queue_id`.
|
||||
- Numeric validation runs at the FastAPI layer (e.g. `steps ≥ 1`, `width ≥ 64`).
|
||||
- Only non-null parameters are processed, stored, and broadcast.
|
||||
- Model-key resolution runs **after** the raw parameters are stored, so
|
||||
an unresolvable model name simply drops out of the broadcast but does
|
||||
not corrupt the persisted state.
|
||||
- The broadcast payload contains resolved model keys and image metadata
|
||||
(width/height) so the frontend can populate its store without extra
|
||||
round-trips.
|
||||
|
||||
## Integration with Frontend
|
||||
## Troubleshooting
|
||||
|
||||
The stored parameters can be accessed by the frontend through the
|
||||
existing client state API or by implementing hooks that read from the
|
||||
recall parameter storage. This allows external applications to
|
||||
pre-populate generation parameters before the user initiates image
|
||||
generation.
|
||||
### Image not found
|
||||
|
||||
## Error Handling
|
||||
If you see "Image file not found" in the logs:
|
||||
|
||||
- **400 Bad Request**: Invalid parameters or parameter values
|
||||
- **500 Internal Server Error**: Server-side error storing or retrieving parameters
|
||||
1. Verify the filename matches exactly (case-sensitive).
|
||||
2. Ensure the image is in `{INVOKEAI_ROOT}/outputs/images/`.
|
||||
3. Check that the filename does not include the `outputs/images/` prefix.
|
||||
|
||||
Errors include detailed messages explaining what went wrong.
|
||||
### Model not found
|
||||
|
||||
If you see "Could not find model":
|
||||
|
||||
1. Verify the model name matches exactly (case-sensitive).
|
||||
2. Ensure the model is installed.
|
||||
3. Check the name via the Models Manager panel.
|
||||
|
||||
### Event not received
|
||||
|
||||
1. Check the browser console for socket connection errors.
|
||||
2. Verify the `queue_id` matches the frontend's queue (usually `default`).
|
||||
3. Check backend logs for event emission errors.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Model availability** — models referenced in the payload must be installed.
|
||||
- **Image availability** — images must exist in `outputs/images`; remote
|
||||
URLs are not supported.
|
||||
- **Canvas auto-layer creation** — control layers and IP adapters with
|
||||
images populate the recall state, but creating a canvas layer from
|
||||
them still happens through the UI.
|
||||
|
||||
## Future enhancements
|
||||
|
||||
Potential improvements not yet implemented:
|
||||
|
||||
1. Auto-create canvas layers from control-layer images in the payload.
|
||||
2. Auto-create reference-image layers from IP Adapter images in the payload.
|
||||
3. Support remote image URLs in addition to local `outputs/images` filenames.
|
||||
4. Image upload capability (accept base64 or file upload directly via the API).
|
||||
5. Batch operations that target multiple `queue_id`s in a single request.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from fastapi import Body, HTTPException, Path
|
||||
from fastapi import Body, HTTPException, Path, Query
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -58,6 +58,20 @@ class IPAdapterRecallParameter(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ReferenceImageRecallParameter(BaseModel):
|
||||
"""Global reference-image configuration for recall.
|
||||
|
||||
Used for reference images that feed directly into the main model rather
|
||||
than through a separate IP-Adapter / ControlNet model — for example
|
||||
FLUX.2 Klein, FLUX Kontext, and Qwen Image Edit. The receiving frontend
|
||||
picks the correct config type (``flux2_reference_image`` /
|
||||
``qwen_image_reference_image`` / ``flux_kontext_reference_image``) based
|
||||
on the currently-selected main model.
|
||||
"""
|
||||
|
||||
image_name: str = Field(description="The filename of the reference image in outputs/images")
|
||||
|
||||
|
||||
class RecallParameter(BaseModel):
|
||||
"""Request model for updating recallable parameters."""
|
||||
|
||||
@@ -105,6 +119,14 @@ class RecallParameter(BaseModel):
|
||||
ip_adapters: Optional[list[IPAdapterRecallParameter]] = Field(
|
||||
None, description="List of IP Adapters with their settings"
|
||||
)
|
||||
reference_images: Optional[list[ReferenceImageRecallParameter]] = Field(
|
||||
None,
|
||||
description=(
|
||||
"List of model-free reference images for architectures that consume reference "
|
||||
"images directly (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit). The frontend "
|
||||
"picks the correct config type based on the currently-selected main model."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_model_name_to_key(model_name: str, model_type: ModelType = ModelType.Main) -> Optional[str]:
|
||||
@@ -292,11 +314,43 @@ def resolve_ip_adapter_models(ip_adapters: list[IPAdapterRecallParameter]) -> li
|
||||
return resolved_adapters
|
||||
|
||||
|
||||
def resolve_reference_images(
|
||||
reference_images: list[ReferenceImageRecallParameter],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Validate model-free reference images and build the configuration list.
|
||||
|
||||
Unlike IP Adapters and ControlNets, these reference images are consumed
|
||||
directly by the main model (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit),
|
||||
so there is no adapter-model name to resolve. We simply verify that each
|
||||
referenced file exists in ``outputs/images`` and pass the image metadata
|
||||
through to the frontend.
|
||||
|
||||
Args:
|
||||
reference_images: List of reference-image recall parameters
|
||||
|
||||
Returns:
|
||||
List of reference-image configurations with resolved image metadata.
|
||||
Entries whose image file cannot be loaded are dropped with a warning.
|
||||
"""
|
||||
logger = ApiDependencies.invoker.services.logger
|
||||
resolved: list[dict[str, Any]] = []
|
||||
|
||||
for ref in reference_images:
|
||||
image_data = load_image_file(ref.image_name)
|
||||
if image_data is None:
|
||||
logger.warning(f"Skipping reference image '{ref.image_name}' - file not found")
|
||||
continue
|
||||
resolved.append({"image": image_data})
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def _assert_recall_image_access(parameters: "RecallParameter", current_user: CurrentUserOrDefault) -> None:
|
||||
"""Validate that the caller can read every image referenced in the recall parameters.
|
||||
|
||||
Control layers and IP adapters may reference image_name fields. Without this
|
||||
check an attacker who knows another user's image UUID could use the recall
|
||||
Control layers, IP adapters, and reference images may reference image_name fields.
|
||||
Without this check an attacker who knows another user's image UUID could use the recall
|
||||
endpoint to extract image dimensions and — for ControlNet preprocessors — mint
|
||||
a derived processed image they can then fetch.
|
||||
"""
|
||||
@@ -311,6 +365,10 @@ def _assert_recall_image_access(parameters: "RecallParameter", current_user: Cur
|
||||
for adapter in parameters.ip_adapters:
|
||||
if adapter.image_name is not None:
|
||||
image_names.append(adapter.image_name)
|
||||
if parameters.reference_images:
|
||||
for ref in parameters.reference_images:
|
||||
if ref.image_name is not None:
|
||||
image_names.append(ref.image_name)
|
||||
|
||||
if not image_names:
|
||||
return
|
||||
@@ -346,6 +404,10 @@ async def update_recall_parameters(
|
||||
current_user: CurrentUserOrDefault,
|
||||
queue_id: str = Path(..., description="The queue id to perform this operation on"),
|
||||
parameters: RecallParameter = Body(..., description="Recall parameters to update"),
|
||||
strict: bool = Query(
|
||||
default=False,
|
||||
description="When true, parameters not included in the request are reset to their defaults (cleared).",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Update recallable parameters that can be recalled on the frontend.
|
||||
@@ -357,21 +419,23 @@ async def update_recall_parameters(
|
||||
Args:
|
||||
queue_id: The queue ID to associate these parameters with
|
||||
parameters: The RecallParameter object containing the parameters to update
|
||||
strict: When true, parameters not included in the request body are reset
|
||||
to their defaults (cleared on the frontend). Defaults to false,
|
||||
which preserves the existing behaviour of only updating the
|
||||
parameters that are explicitly provided.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the updated parameters and status
|
||||
|
||||
Example:
|
||||
POST /api/v1/recall/{queue_id}
|
||||
POST /api/v1/recall/{queue_id}?strict=true
|
||||
{
|
||||
"positive_prompt": "a beautiful landscape",
|
||||
"model": "sd-1.5",
|
||||
"steps": 20,
|
||||
"cfg_scale": 7.5,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"seed": 12345
|
||||
"steps": 20
|
||||
}
|
||||
# In strict mode, all other parameters (reference_images, loras, etc.)
|
||||
# are cleared. In non-strict mode (default) they would be left as-is.
|
||||
"""
|
||||
logger = ApiDependencies.invoker.services.logger
|
||||
|
||||
@@ -380,8 +444,18 @@ async def update_recall_parameters(
|
||||
_assert_recall_image_access(parameters, current_user)
|
||||
|
||||
try:
|
||||
# Get only the parameters that were actually provided (non-None values)
|
||||
provided_params = {k: v for k, v in parameters.model_dump().items() if v is not None}
|
||||
# In strict mode, include all parameters so the frontend clears anything
|
||||
# not explicitly provided. List-typed fields use [] instead of None so
|
||||
# the frontend sees an empty collection rather than a null it might skip.
|
||||
if strict:
|
||||
_list_fields = {
|
||||
name for name, field in RecallParameter.model_fields.items() if "list" in str(field.annotation).lower()
|
||||
}
|
||||
provided_params = {
|
||||
k: ([] if v is None and k in _list_fields else v) for k, v in parameters.model_dump().items()
|
||||
}
|
||||
else:
|
||||
provided_params = {k: v for k, v in parameters.model_dump().items() if v is not None}
|
||||
|
||||
if not provided_params:
|
||||
return {"status": "no_parameters_provided", "updated_count": 0}
|
||||
@@ -442,6 +516,14 @@ async def update_recall_parameters(
|
||||
provided_params["ip_adapters"] = resolved_adapters
|
||||
logger.info(f"Resolved {len(resolved_adapters)} IP adapter(s)")
|
||||
|
||||
# Process model-free reference images if provided
|
||||
if "reference_images" in provided_params:
|
||||
reference_images_param = parameters.reference_images
|
||||
if reference_images_param is not None:
|
||||
resolved_refs = resolve_reference_images(reference_images_param)
|
||||
provided_params["reference_images"] = resolved_refs
|
||||
logger.info(f"Resolved {len(resolved_refs)} reference image(s)")
|
||||
|
||||
# Emit event to notify frontend of parameter updates
|
||||
try:
|
||||
logger.info(
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2552,21 +2552,23 @@ export type paths = {
|
||||
* Args:
|
||||
* queue_id: The queue ID to associate these parameters with
|
||||
* parameters: The RecallParameter object containing the parameters to update
|
||||
* strict: When true, parameters not included in the request body are reset
|
||||
* to their defaults (cleared on the frontend). Defaults to false,
|
||||
* which preserves the existing behaviour of only updating the
|
||||
* parameters that are explicitly provided.
|
||||
*
|
||||
* Returns:
|
||||
* A dictionary containing the updated parameters and status
|
||||
*
|
||||
* Example:
|
||||
* POST /api/v1/recall/{queue_id}
|
||||
* POST /api/v1/recall/{queue_id}?strict=true
|
||||
* {
|
||||
* "positive_prompt": "a beautiful landscape",
|
||||
* "model": "sd-1.5",
|
||||
* "steps": 20,
|
||||
* "cfg_scale": 7.5,
|
||||
* "width": 512,
|
||||
* "height": 512,
|
||||
* "seed": 12345
|
||||
* "steps": 20
|
||||
* }
|
||||
* # In strict mode, all other parameters (reference_images, loras, etc.)
|
||||
* # are cleared. In non-strict mode (default) they would be left as-is.
|
||||
*/
|
||||
post: operations["update_recall_parameters"];
|
||||
delete?: never;
|
||||
@@ -25175,6 +25177,11 @@ export type components = {
|
||||
* @description List of IP Adapters with their settings
|
||||
*/
|
||||
ip_adapters?: components["schemas"]["IPAdapterRecallParameter"][] | null;
|
||||
/**
|
||||
* Reference Images
|
||||
* @description List of model-free reference images for architectures that consume reference images directly (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit). The frontend picks the correct config type based on the currently-selected main model.
|
||||
*/
|
||||
reference_images?: components["schemas"]["ReferenceImageRecallParameter"][] | null;
|
||||
};
|
||||
/**
|
||||
* RecallParametersUpdatedEvent
|
||||
@@ -25274,6 +25281,24 @@ export type components = {
|
||||
*/
|
||||
type: "rectangle_mask";
|
||||
};
|
||||
/**
|
||||
* ReferenceImageRecallParameter
|
||||
* @description Global reference-image configuration for recall.
|
||||
*
|
||||
* Used for reference images that feed directly into the main model rather
|
||||
* than through a separate IP-Adapter / ControlNet model — for example
|
||||
* FLUX.2 Klein, FLUX Kontext, and Qwen Image Edit. The receiving frontend
|
||||
* picks the correct config type (``flux2_reference_image`` /
|
||||
* ``qwen_image_reference_image`` / ``flux_kontext_reference_image``) based
|
||||
* on the currently-selected main model.
|
||||
*/
|
||||
ReferenceImageRecallParameter: {
|
||||
/**
|
||||
* Image Name
|
||||
* @description The filename of the reference image in outputs/images
|
||||
*/
|
||||
image_name: string;
|
||||
};
|
||||
/**
|
||||
* RemoteModelFile
|
||||
* @description Information about a downloadable file that forms part of a model.
|
||||
@@ -36284,7 +36309,10 @@ export interface operations {
|
||||
};
|
||||
update_recall_parameters: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description When true, parameters not included in the request are reset to their defaults (cleared). */
|
||||
strict?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The queue id to perform this operation on */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { socketConnected } from 'app/store/middleware/listenerMiddleware/listene
|
||||
import type { AppStore } from 'app/store/store';
|
||||
import { deepClone } from 'common/util/deepClone';
|
||||
import { forEach, isNil, round } from 'es-toolkit/compat';
|
||||
import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks';
|
||||
import { allEntitiesDeleted, controlLayerRecalled } from 'features/controlLayers/store/canvasSlice';
|
||||
import { canvasWorkflowIntegrationProcessingCompleted } from 'features/controlLayers/store/canvasWorkflowIntegrationSlice';
|
||||
import { loraAllDeleted, loraRecalled } from 'features/controlLayers/store/lorasSlice';
|
||||
@@ -736,110 +737,182 @@ export const setEventListeners = ({ socket, store, setIsConnected }: SetEventLis
|
||||
}
|
||||
}
|
||||
|
||||
// Handle IP Adapters as Reference Images
|
||||
if (data.parameters.ip_adapters !== undefined && Array.isArray(data.parameters.ip_adapters)) {
|
||||
log.debug(`Processing ${data.parameters.ip_adapters.length} IP adapter(s)`);
|
||||
// Handle IP Adapters and model-free reference images together.
|
||||
//
|
||||
// Both ip_adapters and reference_images feed into the same refImages
|
||||
// Redux slice. Previously they were dispatched as two independent
|
||||
// Promise.all chains — the first with replace:true, the second with
|
||||
// replace:false — which created a race: if a previous recall's
|
||||
// reference-image promises were still in-flight they could resolve
|
||||
// after the clear and re-append stale entries, doubling the list.
|
||||
//
|
||||
// Fix: collect every promise into a single array and dispatch exactly
|
||||
// once with replace:true after all of them settle.
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const ipAdaptersArr: any[] = Array.isArray(data.parameters.ip_adapters)
|
||||
? (data.parameters.ip_adapters as any[])
|
||||
: [];
|
||||
const refImagesArr: any[] = Array.isArray(data.parameters.reference_images)
|
||||
? (data.parameters.reference_images as any[])
|
||||
: [];
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// If the list is explicitly empty, clear existing reference images
|
||||
if (data.parameters.ip_adapters.length === 0) {
|
||||
dispatch(refImagesRecalled({ entities: [], replace: true }));
|
||||
log.info('Cleared all IP adapter reference images');
|
||||
} else {
|
||||
// Build promises for all IP adapters, then dispatch once with replace: true
|
||||
const ipAdapterPromises = data.parameters.ip_adapters
|
||||
.filter((cfg) => cfg.model_key && typeof cfg.model_key === 'string')
|
||||
.map(async (adapterConfig) => {
|
||||
try {
|
||||
const modelConfig = await dispatch(
|
||||
modelsApi.endpoints.getModelConfig.initiate(adapterConfig.model_key!)
|
||||
).unwrap();
|
||||
const hasIpAdapters = data.parameters.ip_adapters !== undefined;
|
||||
const hasRefImages = data.parameters.reference_images !== undefined;
|
||||
|
||||
// Pre-fetch the image DTO if an image is provided, to avoid validation errors
|
||||
if (adapterConfig.image?.image_name) {
|
||||
try {
|
||||
await dispatch(imagesApi.endpoints.getImageDTO.initiate(adapterConfig.image.image_name)).unwrap();
|
||||
} catch (imageError) {
|
||||
log.warn(
|
||||
`Could not pre-fetch image ${adapterConfig.image.image_name}, continuing anyway: ${imageError}`
|
||||
if (hasIpAdapters || hasRefImages) {
|
||||
const allRefImagePromises: Promise<RefImageState | null>[] = [];
|
||||
|
||||
// --- IP Adapters ---
|
||||
if (hasIpAdapters && ipAdaptersArr.length > 0) {
|
||||
log.debug(`Processing ${ipAdaptersArr.length} IP adapter(s)`);
|
||||
|
||||
const ipAdapterPromises = ipAdaptersArr
|
||||
.filter((cfg: any) => cfg.model_key && typeof cfg.model_key === 'string') // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(async (adapterConfig: any): Promise<RefImageState | null> => {
|
||||
try {
|
||||
const modelConfig = await dispatch(
|
||||
modelsApi.endpoints.getModelConfig.initiate(adapterConfig.model_key!)
|
||||
).unwrap();
|
||||
|
||||
// Pre-fetch the image DTO if an image is provided, to avoid validation errors
|
||||
if (adapterConfig.image?.image_name) {
|
||||
try {
|
||||
await dispatch(imagesApi.endpoints.getImageDTO.initiate(adapterConfig.image.image_name)).unwrap();
|
||||
} catch (imageError) {
|
||||
log.warn(
|
||||
`Could not pre-fetch image ${adapterConfig.image.image_name}, continuing anyway: ${imageError}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build RefImageState using helper function - supports both ip_adapter and flux_redux
|
||||
const imageData = adapterConfig.image
|
||||
? {
|
||||
original: {
|
||||
image: {
|
||||
image_name: adapterConfig.image.image_name,
|
||||
width: adapterConfig.image.width ?? 512,
|
||||
height: adapterConfig.image.height ?? 512,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
const isFluxRedux = modelConfig.type === 'flux_redux';
|
||||
const refImageState = getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
|
||||
isEnabled: true,
|
||||
config: isFluxRedux
|
||||
? {
|
||||
type: 'flux_redux',
|
||||
image: imageData,
|
||||
model: {
|
||||
key: modelConfig.key,
|
||||
hash: modelConfig.hash,
|
||||
name: modelConfig.name,
|
||||
base: modelConfig.base,
|
||||
type: modelConfig.type,
|
||||
},
|
||||
imageInfluence: (adapterConfig.image_influence as FLUXReduxImageInfluence) || 'highest',
|
||||
}
|
||||
: {
|
||||
type: 'ip_adapter',
|
||||
image: imageData,
|
||||
model: {
|
||||
key: modelConfig.key,
|
||||
hash: modelConfig.hash,
|
||||
name: modelConfig.name,
|
||||
base: modelConfig.base,
|
||||
type: modelConfig.type,
|
||||
},
|
||||
weight: typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0,
|
||||
beginEndStepPct: [
|
||||
typeof adapterConfig.begin_step_percent === 'number' ? adapterConfig.begin_step_percent : 0,
|
||||
typeof adapterConfig.end_step_percent === 'number' ? adapterConfig.end_step_percent : 1,
|
||||
] as [number, number],
|
||||
method: (adapterConfig.method as IPMethodV2) || 'full',
|
||||
clipVisionModel: 'ViT-H',
|
||||
},
|
||||
});
|
||||
|
||||
if (isFluxRedux) {
|
||||
log.debug(`Built FLUX Redux ref image state: ${modelConfig.name}`);
|
||||
} else {
|
||||
log.debug(
|
||||
`Built IP adapter ref image state: ${modelConfig.name} (weight: ${typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0})`
|
||||
);
|
||||
}
|
||||
if (adapterConfig.image?.image_name) {
|
||||
log.debug(
|
||||
`IP adapter image: outputs/images/${adapterConfig.image.image_name} (${adapterConfig.image.width}x${adapterConfig.image.height})`
|
||||
);
|
||||
}
|
||||
|
||||
return refImageState;
|
||||
} catch (error) {
|
||||
log.error(`Failed to load IP adapter ${adapterConfig.model_key}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
allRefImagePromises.push(...ipAdapterPromises);
|
||||
}
|
||||
|
||||
// --- Model-free reference images (FLUX.2 Klein, FLUX Kontext, Qwen Image Edit) ---
|
||||
// These feed the reference image directly into the main model rather than going
|
||||
// through an IP Adapter, so the backend sends them without a model_key and we
|
||||
// pick the right config type via getDefaultRefImageConfig() based on the main
|
||||
// model that is currently selected in the UI.
|
||||
if (hasRefImages && refImagesArr.length > 0) {
|
||||
log.debug(`Processing ${refImagesArr.length} reference image(s)`);
|
||||
|
||||
const referenceImagePromises = refImagesArr
|
||||
.filter((cfg: any) => cfg.image?.image_name && typeof cfg.image.image_name === 'string') // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(async (refConfig: any): Promise<RefImageState | null> => {
|
||||
const imageName = refConfig.image.image_name as string;
|
||||
try {
|
||||
// Pre-fetch the image DTO so ref image validation succeeds.
|
||||
await dispatch(imagesApi.endpoints.getImageDTO.initiate(imageName)).unwrap();
|
||||
} catch (imageError) {
|
||||
log.warn(`Could not pre-fetch reference image ${imageName}, continuing anyway: ${imageError}`);
|
||||
}
|
||||
|
||||
// Build RefImageState using helper function - supports both ip_adapter and flux_redux
|
||||
const imageData = adapterConfig.image
|
||||
? {
|
||||
original: {
|
||||
image: {
|
||||
image_name: adapterConfig.image.image_name,
|
||||
width: adapterConfig.image.width ?? 512,
|
||||
height: adapterConfig.image.height ?? 512,
|
||||
},
|
||||
},
|
||||
}
|
||||
: null;
|
||||
// Pick the config flavor (flux2 / flux_kontext / ip_adapter fallback) that
|
||||
// matches the currently-selected main model.
|
||||
const baseConfig = getDefaultRefImageConfig(getState);
|
||||
const imageData = {
|
||||
original: {
|
||||
image: {
|
||||
image_name: imageName,
|
||||
width: typeof refConfig.image.width === 'number' ? refConfig.image.width : 512,
|
||||
height: typeof refConfig.image.height === 'number' ? refConfig.image.height : 512,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isFluxRedux = modelConfig.type === 'flux_redux';
|
||||
const refImageState = getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
|
||||
return getReferenceImageState(`recalled-ref-image-${Date.now()}-${Math.random()}`, {
|
||||
isEnabled: true,
|
||||
config: isFluxRedux
|
||||
? {
|
||||
type: 'flux_redux',
|
||||
image: imageData,
|
||||
model: {
|
||||
key: modelConfig.key,
|
||||
hash: modelConfig.hash,
|
||||
name: modelConfig.name,
|
||||
base: modelConfig.base,
|
||||
type: modelConfig.type,
|
||||
},
|
||||
imageInfluence: (adapterConfig.image_influence as FLUXReduxImageInfluence) || 'highest',
|
||||
}
|
||||
: {
|
||||
type: 'ip_adapter',
|
||||
image: imageData,
|
||||
model: {
|
||||
key: modelConfig.key,
|
||||
hash: modelConfig.hash,
|
||||
name: modelConfig.name,
|
||||
base: modelConfig.base,
|
||||
type: modelConfig.type,
|
||||
},
|
||||
weight: typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0,
|
||||
beginEndStepPct: [
|
||||
typeof adapterConfig.begin_step_percent === 'number' ? adapterConfig.begin_step_percent : 0,
|
||||
typeof adapterConfig.end_step_percent === 'number' ? adapterConfig.end_step_percent : 1,
|
||||
] as [number, number],
|
||||
method: (adapterConfig.method as IPMethodV2) || 'full',
|
||||
clipVisionModel: 'ViT-H',
|
||||
},
|
||||
config: { ...baseConfig, image: imageData },
|
||||
});
|
||||
});
|
||||
|
||||
if (isFluxRedux) {
|
||||
log.debug(`Built FLUX Redux ref image state: ${modelConfig.name}`);
|
||||
} else {
|
||||
log.debug(
|
||||
`Built IP adapter ref image state: ${modelConfig.name} (weight: ${typeof adapterConfig.weight === 'number' ? adapterConfig.weight : 1.0})`
|
||||
);
|
||||
}
|
||||
if (adapterConfig.image?.image_name) {
|
||||
log.debug(
|
||||
`IP adapter image: outputs/images/${adapterConfig.image.image_name} (${adapterConfig.image.width}x${adapterConfig.image.height})`
|
||||
);
|
||||
}
|
||||
allRefImagePromises.push(...referenceImagePromises);
|
||||
}
|
||||
|
||||
return refImageState;
|
||||
} catch (error) {
|
||||
log.error(`Failed to load IP adapter ${adapterConfig.model_key}: ${error}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all IP adapters to load, then dispatch with replace: true
|
||||
Promise.all(ipAdapterPromises).then((refImageStates) => {
|
||||
const validStates = refImageStates.filter((state): state is RefImageState => state !== null);
|
||||
// Single dispatch after all IP adapter + reference image promises settle.
|
||||
// Always replace:true so stale entries from a previous recall are cleared.
|
||||
Promise.all(allRefImagePromises).then((results) => {
|
||||
const validStates = results.filter((state): state is RefImageState => state !== null);
|
||||
dispatch(refImagesRecalled({ entities: validStates, replace: true }));
|
||||
if (validStates.length > 0) {
|
||||
dispatch(refImagesRecalled({ entities: validStates, replace: true }));
|
||||
log.info(`Applied ${validStates.length} IP adapter(s), replacing existing list`);
|
||||
log.info(
|
||||
`Applied ${validStates.length} reference image(s) (IP adapters + model-free), replacing existing list`
|
||||
);
|
||||
} else {
|
||||
log.info('Cleared all reference images');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
693
tests/app/routers/test_recall_parameters.py
Normal file
693
tests/app/routers/test_recall_parameters.py
Normal file
@@ -0,0 +1,693 @@
|
||||
"""Tests for the recall parameters router.
|
||||
|
||||
These tests monkey-patch the heavy-weight lookup helpers
|
||||
(``resolve_model_name_to_key``, ``load_image_file``,
|
||||
``process_controlnet_image``) rather than wiring up a real model manager
|
||||
or image-files service. This keeps each test focused on the router's
|
||||
request-validation, resolver sequencing, and broadcast payload shape.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from invokeai.app.api.dependencies import ApiDependencies
|
||||
from invokeai.app.api.routers import recall_parameters as recall_module
|
||||
from invokeai.app.api_app import app
|
||||
from invokeai.app.services.invoker import Invoker
|
||||
from invokeai.backend.model_manager.taxonomy import ModelType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class MockApiDependencies(ApiDependencies):
|
||||
"""Minimal ApiDependencies stand-in that only wires up an invoker."""
|
||||
|
||||
invoker: Invoker
|
||||
|
||||
def __init__(self, invoker: Invoker) -> None:
|
||||
self.invoker = invoker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_dependencies(monkeypatch: Any, mock_invoker: Invoker) -> MockApiDependencies:
|
||||
"""Install a mock ApiDependencies for the recall_parameters router.
|
||||
|
||||
The router persists each parameter via ``client_state_persistence.set_by_key``,
|
||||
whose ``user_id`` column has a FOREIGN KEY constraint back to the users
|
||||
table. The mock invoker uses an in-memory SQLite database that is not
|
||||
pre-populated with any users, so persistence would fail with "FOREIGN
|
||||
KEY constraint failed" — that's an orthogonal concern to the reference-
|
||||
images resolver under test, so we stub it out.
|
||||
"""
|
||||
dependencies = MockApiDependencies(mock_invoker)
|
||||
monkeypatch.setattr("invokeai.app.api.routers.recall_parameters.ApiDependencies", dependencies)
|
||||
monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", dependencies)
|
||||
monkeypatch.setattr(
|
||||
mock_invoker.services.client_state_persistence,
|
||||
"set_by_key",
|
||||
lambda user_id, key, value: value,
|
||||
)
|
||||
return dependencies
|
||||
|
||||
|
||||
def make_name_to_key_stub(
|
||||
mapping: dict[tuple[str, ModelType], str],
|
||||
) -> Callable[[str, ModelType], Optional[str]]:
|
||||
"""Build a ``resolve_model_name_to_key`` stand-in from a (name, type) dict.
|
||||
|
||||
Any lookup that is not present in ``mapping`` returns ``None``, mirroring
|
||||
what the real resolver does when the model manager cannot find a match.
|
||||
"""
|
||||
|
||||
def _lookup(model_name: str, model_type: ModelType = ModelType.Main) -> Optional[str]:
|
||||
return mapping.get((model_name, model_type))
|
||||
|
||||
return _lookup
|
||||
|
||||
|
||||
def make_load_image_file_stub(
|
||||
known_images: dict[str, tuple[int, int]],
|
||||
) -> Callable[[str], Optional[dict[str, Any]]]:
|
||||
"""Build a ``load_image_file`` stand-in from a name → (width, height) dict."""
|
||||
|
||||
def _load(image_name: str) -> Optional[dict[str, Any]]:
|
||||
dims = known_images.get(image_name)
|
||||
if dims is None:
|
||||
return None
|
||||
width, height = dims
|
||||
return {"image_name": image_name, "width": width, "height": height}
|
||||
|
||||
return _load
|
||||
|
||||
|
||||
class TestReferenceImagesRecall:
|
||||
def test_reference_images_forwarded_when_image_exists(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Reference images whose files exist should flow through to the event payload."""
|
||||
|
||||
# Stub load_image_file so we don't need a real outputs/images directory.
|
||||
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
|
||||
return {"image_name": image_name, "width": 1024, "height": 768}
|
||||
|
||||
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"reference_images": [
|
||||
{"image_name": "cat.png"},
|
||||
{"image_name": "dog.png"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "success"
|
||||
assert body["queue_id"] == "default"
|
||||
# Both references came through, in order.
|
||||
resolved = body["parameters"]["reference_images"]
|
||||
assert len(resolved) == 2
|
||||
assert resolved[0]["image"]["image_name"] == "cat.png"
|
||||
assert resolved[1]["image"]["image_name"] == "dog.png"
|
||||
assert resolved[0]["image"]["width"] == 1024
|
||||
|
||||
def test_missing_reference_images_are_dropped_without_failing(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""An image that can't be loaded should be skipped — never 500."""
|
||||
|
||||
def fake_load_image_file(image_name: str) -> dict[str, Any] | None:
|
||||
if image_name == "present.png":
|
||||
return {"image_name": image_name, "width": 512, "height": 512}
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(recall_module, "load_image_file", fake_load_image_file)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"reference_images": [
|
||||
{"image_name": "missing.png"},
|
||||
{"image_name": "present.png"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resolved = response.json()["parameters"]["reference_images"]
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0]["image"]["image_name"] == "present.png"
|
||||
|
||||
def test_reference_images_do_not_require_model_name(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""The schema must accept a reference image entry with only ``image_name``.
|
||||
|
||||
This pins down the "model-free" contract: unlike ``ip_adapters``,
|
||||
these entries are for FLUX.2 Klein / FLUX Kontext / Qwen Image Edit,
|
||||
where the reference image feeds the main model directly and there is
|
||||
no adapter model to name. Callers should be able to omit every
|
||||
field except ``image_name``.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
lambda image_name: {"image_name": image_name, "width": 64, "height": 64},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"reference_images": [{"image_name": "ok.png"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resolved = response.json()["parameters"]["reference_images"]
|
||||
assert resolved == [{"image": {"image_name": "ok.png", "width": 64, "height": 64}}]
|
||||
|
||||
def test_empty_reference_images_is_noop_for_other_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Sending an empty reference_images list should not break other fields."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
lambda image_name: {"image_name": image_name, "width": 1, "height": 1},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"positive_prompt": "hello",
|
||||
"reference_images": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "hello"
|
||||
assert params["reference_images"] == []
|
||||
|
||||
|
||||
class TestLorasRecall:
|
||||
def test_multiple_loras_resolved_with_weights_and_is_enabled(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Each LoRA's model name is resolved to a key and weight/is_enabled pass through."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub(
|
||||
{
|
||||
("detail-lora", ModelType.LoRA): "key-detail",
|
||||
("style-lora", ModelType.LoRA): "key-style",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"loras": [
|
||||
{"model_name": "detail-lora", "weight": 0.8, "is_enabled": True},
|
||||
{"model_name": "style-lora", "weight": 0.5, "is_enabled": False},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
loras = response.json()["parameters"]["loras"]
|
||||
assert loras == [
|
||||
{"model_key": "key-detail", "weight": 0.8, "is_enabled": True},
|
||||
{"model_key": "key-style", "weight": 0.5, "is_enabled": False},
|
||||
]
|
||||
|
||||
def test_unresolvable_loras_are_dropped(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""LoRAs whose names do not resolve are silently skipped — not an error."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("keeper", ModelType.LoRA): "key-keeper"}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"loras": [
|
||||
{"model_name": "keeper", "weight": 0.7},
|
||||
{"model_name": "ghost-lora"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
loras = response.json()["parameters"]["loras"]
|
||||
assert len(loras) == 1
|
||||
assert loras[0]["model_key"] == "key-keeper"
|
||||
|
||||
def test_is_enabled_defaults_to_true(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Omitting is_enabled should default to True per the pydantic schema."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("x", ModelType.LoRA): "key-x"}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"loras": [{"model_name": "x"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["loras"][0]["is_enabled"] is True
|
||||
|
||||
|
||||
class TestControlLayersRecall:
|
||||
def test_controlnet_resolution_takes_precedence(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A name that matches a ControlNet model should resolve to it directly."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ctl.png": (512, 512)}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "canny",
|
||||
"image_name": "ctl.png",
|
||||
"weight": 0.75,
|
||||
"begin_step_percent": 0.1,
|
||||
"end_step_percent": 0.9,
|
||||
"control_mode": "balanced",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
layer = response.json()["parameters"]["control_layers"][0]
|
||||
assert layer["model_key"] == "key-canny"
|
||||
assert layer["weight"] == 0.75
|
||||
assert layer["begin_step_percent"] == 0.1
|
||||
assert layer["end_step_percent"] == 0.9
|
||||
assert layer["control_mode"] == "balanced"
|
||||
assert layer["image"] == {"image_name": "ctl.png", "width": 512, "height": 512}
|
||||
# processor returned None → no processed_image field
|
||||
assert "processed_image" not in layer
|
||||
|
||||
def test_falls_back_to_t2i_adapter(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When no ControlNet match exists, T2I Adapter is tried next."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("sketchy", ModelType.T2IAdapter): "key-t2i"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "sketchy", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-t2i"
|
||||
|
||||
def test_falls_back_to_control_lora(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When neither ControlNet nor T2I Adapter matches, Control LoRA is tried last."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("clora", ModelType.LoRA): "key-clora"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "clora", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["control_layers"][0]["model_key"] == "key-clora"
|
||||
|
||||
def test_missing_image_still_resolves_config(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A missing control image is warned about but does not block the rest of the config."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"control_layers": [
|
||||
{
|
||||
"model_name": "canny",
|
||||
"image_name": "missing.png",
|
||||
"weight": 0.75,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
layer = response.json()["parameters"]["control_layers"][0]
|
||||
assert layer["model_key"] == "key-canny"
|
||||
assert layer["weight"] == 0.75
|
||||
assert "image" not in layer
|
||||
assert "processed_image" not in layer
|
||||
|
||||
def test_processed_image_included_when_processor_returns_data(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When the processor produces a derived image, it is attached to the resolved layer."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("canny", ModelType.ControlNet): "key-canny"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ctl.png": (768, 768)}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"process_controlnet_image",
|
||||
lambda image_name, model_key, services: {
|
||||
"image_name": f"processed-{image_name}",
|
||||
"width": 768,
|
||||
"height": 768,
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
layer = response.json()["parameters"]["control_layers"][0]
|
||||
assert layer["processed_image"]["image_name"] == "processed-ctl.png"
|
||||
|
||||
def test_unresolvable_control_layers_are_dropped(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Control entries whose model doesn't resolve by any type are skipped."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"control_layers": [{"model_name": "unknown", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["control_layers"] == []
|
||||
|
||||
|
||||
class TestIPAdaptersRecall:
|
||||
def test_ip_adapter_resolved_with_image_and_method(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""IPAdapter lookup is tried first and all config fields pass through."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("ipa-face", ModelType.IPAdapter): "key-ipa"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ref.png": (1024, 1024)}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "ipa-face",
|
||||
"image_name": "ref.png",
|
||||
"weight": 0.7,
|
||||
"begin_step_percent": 0.0,
|
||||
"end_step_percent": 0.8,
|
||||
"method": "style",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
adapter = response.json()["parameters"]["ip_adapters"][0]
|
||||
assert adapter["model_key"] == "key-ipa"
|
||||
assert adapter["weight"] == 0.7
|
||||
assert adapter["begin_step_percent"] == 0.0
|
||||
assert adapter["end_step_percent"] == 0.8
|
||||
assert adapter["method"] == "style"
|
||||
assert adapter["image"] == {"image_name": "ref.png", "width": 1024, "height": 1024}
|
||||
# image_influence was not sent, so it must not appear in the resolved config
|
||||
assert "image_influence" not in adapter
|
||||
|
||||
def test_falls_back_to_flux_redux(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""When the name doesn't match an IPAdapter, FluxRedux is tried next."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("redux-1", ModelType.FluxRedux): "key-redux"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub({"ref.png": (512, 512)}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"ip_adapters": [
|
||||
{
|
||||
"model_name": "redux-1",
|
||||
"image_name": "ref.png",
|
||||
"weight": 1.0,
|
||||
"image_influence": "high",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
adapter = response.json()["parameters"]["ip_adapters"][0]
|
||||
assert adapter["model_key"] == "key-redux"
|
||||
assert adapter["image_influence"] == "high"
|
||||
|
||||
def test_missing_image_still_resolves_config(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A missing reference image is warned about but the adapter still lands."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({("ipa", ModelType.IPAdapter): "key-ipa"}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"ip_adapters": [{"model_name": "ipa", "image_name": "missing.png", "weight": 0.5}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
adapter = response.json()["parameters"]["ip_adapters"][0]
|
||||
assert adapter["model_key"] == "key-ipa"
|
||||
assert adapter["weight"] == 0.5
|
||||
assert "image" not in adapter
|
||||
|
||||
def test_unresolvable_ip_adapters_are_dropped(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Adapters whose model can't be resolved (neither IPAdapter nor FluxRedux) are skipped."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({}),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "load_image_file", make_load_image_file_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"ip_adapters": [{"model_name": "unknown", "weight": 1.0}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["parameters"]["ip_adapters"] == []
|
||||
|
||||
|
||||
class TestCombinedRecall:
|
||||
def test_all_collection_fields_together(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Exercise the full happy path: prompts, model, loras, control_layers, ip_adapters, reference_images."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub(
|
||||
{
|
||||
("my-model", ModelType.Main): "key-main",
|
||||
("detail-lora", ModelType.LoRA): "key-lora",
|
||||
("canny", ModelType.ControlNet): "key-canny",
|
||||
("ipa-face", ModelType.IPAdapter): "key-ipa",
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"load_image_file",
|
||||
make_load_image_file_stub(
|
||||
{
|
||||
"ctl.png": (512, 512),
|
||||
"face.png": (768, 768),
|
||||
"ref.png": (1024, 1024),
|
||||
}
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(recall_module, "process_controlnet_image", lambda *a, **kw: None)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={
|
||||
"positive_prompt": "a cat",
|
||||
"negative_prompt": "blurry",
|
||||
"model": "my-model",
|
||||
"steps": 30,
|
||||
"cfg_scale": 7.5,
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"seed": 42,
|
||||
"loras": [{"model_name": "detail-lora", "weight": 0.6}],
|
||||
"control_layers": [{"model_name": "canny", "image_name": "ctl.png", "weight": 0.75}],
|
||||
"ip_adapters": [
|
||||
{"model_name": "ipa-face", "image_name": "face.png", "weight": 0.5, "method": "composition"}
|
||||
],
|
||||
"reference_images": [{"image_name": "ref.png"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
|
||||
# Core fields
|
||||
assert params["positive_prompt"] == "a cat"
|
||||
assert params["negative_prompt"] == "blurry"
|
||||
assert params["model"] == "key-main"
|
||||
assert params["steps"] == 30
|
||||
assert params["seed"] == 42
|
||||
|
||||
# Collections
|
||||
assert params["loras"] == [{"model_key": "key-lora", "weight": 0.6, "is_enabled": True}]
|
||||
assert params["control_layers"][0]["model_key"] == "key-canny"
|
||||
assert params["control_layers"][0]["image"]["image_name"] == "ctl.png"
|
||||
assert params["ip_adapters"][0]["model_key"] == "key-ipa"
|
||||
assert params["ip_adapters"][0]["method"] == "composition"
|
||||
assert params["reference_images"] == [{"image": {"image_name": "ref.png", "width": 1024, "height": 1024}}]
|
||||
|
||||
def test_unresolvable_main_model_drops_from_payload(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""A model name that doesn't resolve should be scrubbed from the broadcast payload."""
|
||||
monkeypatch.setattr(
|
||||
recall_module,
|
||||
"resolve_model_name_to_key",
|
||||
make_name_to_key_stub({}),
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"positive_prompt": "x", "model": "ghost-model"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "x"
|
||||
assert "model" not in params
|
||||
|
||||
|
||||
class TestStrictMode:
|
||||
"""Regression tests for the ``strict`` query parameter.
|
||||
|
||||
When ``strict=True``, parameters not included in the request body must
|
||||
be reset — list-typed fields to ``[]`` and scalar fields to ``None``.
|
||||
"""
|
||||
|
||||
def test_strict_clears_list_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""List fields (loras, control_layers, ip_adapters, reference_images) are
|
||||
sent as empty lists when omitted in strict mode."""
|
||||
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default?strict=true",
|
||||
json={"positive_prompt": "hello"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "hello"
|
||||
assert params["loras"] == []
|
||||
assert params["control_layers"] == []
|
||||
assert params["ip_adapters"] == []
|
||||
assert params["reference_images"] == []
|
||||
|
||||
def test_strict_clears_scalar_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Scalar fields not in the request are sent as None in strict mode."""
|
||||
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default?strict=true",
|
||||
json={"steps": 20},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["steps"] == 20
|
||||
assert params["positive_prompt"] is None
|
||||
assert params["seed"] is None
|
||||
assert params["loras"] == []
|
||||
|
||||
def test_non_strict_omits_unset_fields(
|
||||
self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient
|
||||
) -> None:
|
||||
"""Default (non-strict) behaviour: unset fields are absent from the response."""
|
||||
monkeypatch.setattr(recall_module, "resolve_model_name_to_key", make_name_to_key_stub({}))
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/recall/default",
|
||||
json={"positive_prompt": "hello"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
params = response.json()["parameters"]
|
||||
assert params["positive_prompt"] == "hello"
|
||||
assert "loras" not in params
|
||||
assert "reference_images" not in params
|
||||
assert "seed" not in params
|
||||
Reference in New Issue
Block a user