Merge branch 'main' of https://github.com/invoke-ai/InvokeAI into feature/sqlmodel-migration

This commit is contained in:
Alexander Eichhorn
2026-04-20 22:53:30 +02:00
206 changed files with 8820 additions and 651 deletions

View File

@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Project-Documentation
url: https://invoke-ai.github.io/InvokeAI/
url: https://invoke.ai/
about: Should be your first place to go when looking for manuals/FAQs regarding our InvokeAI Toolkit
- name: Discord
url: https://discord.gg/ZmtBAhwWhy

View File

@@ -21,15 +21,51 @@ on:
permissions:
contents: read
pages: write
id-token: write
pull-requests: read
concurrency:
group: 'pages'
group: ${{ github.workflow }}-${{ github.ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
outputs:
docs: ${{ steps.manual.outputs.docs || steps.filter.outputs.docs }}
steps:
- name: checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: mark manual run
if: github.event_name == 'workflow_dispatch'
id: manual
run: echo "docs=true" >> "$GITHUB_OUTPUT"
- name: detect docs-related changes
if: github.event_name != 'workflow_dispatch'
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
docs:
- '.github/workflows/deploy-docs.yml'
- 'docs/**'
- 'scripts/generate_docs_json.py'
- 'invokeai/app/**'
- 'invokeai/backend/**'
- 'pyproject.toml'
- 'uv.lock'
check-and-build:
needs: changes
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.draft == false &&
needs.changes.outputs.docs == 'true') ||
(github.event_name == 'push' && needs.changes.outputs.docs == 'true')
runs-on: ubuntu-22.04
timeout-minutes: 20
steps:
@@ -49,8 +85,10 @@ jobs:
with:
python-version: '3.11'
# generate_docs_json.py only needs the invokeai package importable
# (pydantic + invokeai.app/backend). Skip the [test] extra to keep CI fast.
- name: install python dependencies
run: uv pip install --editable .[test]
run: uv pip install --editable .
# Node (needed for docs build)
- name: setup node
@@ -77,7 +115,7 @@ jobs:
run: pnpm build
working-directory: docs
env:
DEPLOY_TARGET: ${{ inputs.deploy_target || 'custom' }}
DEPLOY_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_target || github.ref == 'refs/heads/main' && 'ghpages' || 'custom' }}
# Upload artifact for deploy (main branch only)
- name: upload pages artifact
@@ -90,6 +128,10 @@ jobs:
if: github.ref == 'refs/heads/main'
needs: check-and-build
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}

View File

@@ -1,49 +0,0 @@
# This is a mostly a copy-paste from https://github.com/squidfunk/mkdocs-material/blob/master/docs/publishing-your-site.md
name: mkdocs
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: write
jobs:
deploy:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
env:
REPO_URL: '${{ github.server_url }}/${{ github.repository }}'
REPO_NAME: '${{ github.repository }}'
SITE_URL: 'https://${{ github.repository_owner }}.github.io/InvokeAI'
steps:
- name: checkout
uses: actions/checkout@v5
- name: setup python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: pyproject.toml
- name: set cache id
run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- name: use cache
uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: install dependencies
run: python -m pip install ".[docs]"
- name: build & deploy
run: mkdocs gh-deploy --force

View File

@@ -59,7 +59,7 @@ Invoke offers a fully featured workflow management solution, enabling users to c
Invoke features an organized gallery system for easily storing, accessing, and remixing your content in the Invoke workspace. Images can be dragged/dropped onto any Image-base UI element in the application, and rich metadata within the Image allows for easy recall of key prompts or settings used in your workflow.
### Model Support
- SD 1.5
- SD 1.5
- SD 2.0
- SDXL
- SD 3.5 Medium
@@ -106,14 +106,14 @@ Invoke is a combined effort of [passionate and talented people from across the w
Original portions of the software are Copyright © 2024 by respective contributors.
[features docs]: https://invoke-ai.github.io/InvokeAI/features/database/
[faq]: https://invoke-ai.github.io/InvokeAI/faq/
[contributors]: https://invoke-ai.github.io/InvokeAI/contributing/contributors/
[features docs]: https://invoke.ai/
[faq]: https://invoke.ai/troubleshooting/faq/
[contributors]: https://invoke.ai/contributing/contributors/
[github issues]: https://github.com/invoke-ai/InvokeAI/issues
[docs home]: https://invoke-ai.github.io/InvokeAI
[installation docs]: https://invoke-ai.github.io/InvokeAI/installation/
[docs home]: https://invoke.ai
[installation docs]: https://invoke.ai/start-here/installation/
[#dev-chat]: https://discord.com/channels/1020123559063990373/1049495067846524939
[contributing docs]: https://invoke-ai.github.io/InvokeAI/contributing/
[contributing docs]: https://invoke.ai/contributing/
[CI checks on main badge]: https://flat.badgen.net/github/checks/invoke-ai/InvokeAI/main?label=CI%20status%20on%20main&cache=900&icon=github
[CI checks on main link]: https://github.com/invoke-ai/InvokeAI/actions?query=branch%3Amain
[discord badge]: https://flat.badgen.net/discord/members/ZmtBAhwWhy?icon=discord

View File

@@ -1,30 +0,0 @@
# Crowdin Configuration
# https://developer.crowdin.com/configuration-file/
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
preserve_hierarchy: true
# Map Crowdin's zh-TW to zh-Hant to match the existing file convention
languages_mapping:
locale:
zh-TW: zh-Hant
files:
# Web App UI Translations
- source: /invokeai/frontend/web/public/locales/en.json
translation: /invokeai/frontend/web/public/locales/%locale%.json
# Documentation - Starlight UI Strings
- source: /docs/src/content/i18n/en.json
translation: /docs/src/content/i18n/%locale%.json
# Documentation - Content Pages (MD and MDX)
- source: /docs/src/content/docs/**/*.{md,mdx}
translation: /docs/src/content/docs/%locale%/**/%original_file_name%
# Exclude translations directory to avoid re-uploading them as source files
ignore:
- /docs/src/content/docs/%locale%/**/*
# Translate full paragraphs rather than splitting into sentences
content_segmentation: 0

View File

@@ -109,7 +109,7 @@ CONTAINER_UID=1000
GPU_DRIVER=cuda
```
Any environment variables supported by InvokeAI can be set here. See the [Configuration docs](https://invoke-ai.github.io/InvokeAI/features/CONFIGURATION/) for further detail.
Any environment variables supported by InvokeAI can be set here. See the [Configuration docs](https://invoke.ai/configuration/invokeai-yaml/) for further detail.
---

View File

@@ -8,6 +8,10 @@ We welcome contributions, whether features, bug fixes, code cleanup, testing, co
If youd like to help with development, please see our [development guide](contribution_guides/development.md).
## External Providers
If you are adding external image generation providers or configs, see our [external provider integration guide](EXTERNAL_PROVIDERS.md).
**New Contributors:** If youre unfamiliar with contributing to open source projects, take a look at our [new contributor guide](contribution_guides/newContributorChecklist.md).
## Nodes

View File

@@ -29,7 +29,7 @@ export default defineConfig({
alt: 'InvokeAI Logo',
replacesTitle: true,
},
favicon: '/favicon.svg',
favicon: 'favicon.svg',
editLink: {
baseUrl: 'https://github.com/invoke-ai/InvokeAI/edit/main/docs',
},
@@ -122,17 +122,10 @@ export default defineConfig({
PageFrame: './src/layouts/PageFrameExtended.astro',
},
plugins: [
// The links validator is skipped for the ghpages target because content uses
// root-absolute links (e.g. /concepts/...) that don't include the /InvokeAI base.
// Production (custom domain) still enforces link validation.
...(isGhPages
? []
: [
starlightLinksValidator({
errorOnRelativeLinks: false,
errorOnLocalLinks: false,
}),
]),
starlightLinksValidator({
errorOnRelativeLinks: false,
errorOnLocalLinks: false,
}),
starlightLlmsText(),
starlightChangelogs(),
// starlightContextualMenu({
@@ -140,7 +133,45 @@ export default defineConfig({
// 'copy', 'view', 'chatgpt', 'claude'
// ]
// }),
],
]
}),
],
redirects: {
'/CODE_OF_CONDUCT': '/contributing/code-of-conduct',
'/RELEASE': '/development/process/release-process',
'/installation': '/start-here/installation',
'/installation/docker': '/configuration/docker',
'/installation/manual': '/start-here/manual',
'/installation/models': '/concepts/models',
'/installation/patchmatch': '/configuration/patchmatch',
'/installation/quick_start': '/start-here/installation',
'/installation/requirements': '/start-here/system-requirements',
'/configuration': '/configuration/invokeai-yaml',
'/features/low-vram/': '/configuration/low-vram-mode/',
'/faq': '/troubleshooting/faq',
'/help/SAMPLER_CONVERGENCE': '/concepts/parameters',
'/help/diffusion': '/concepts/diffusion',
'/help/gettingStartedWithAI': '/concepts/image-generation',
'/nodes/NODES': '/workflows/editor-interface',
'/nodes/NODES_MIGRATION_V3_V4': '/development/guides/api-development',
'/nodes/comfyToInvoke': '/workflows/comfyui-migration',
'/nodes/communityNodes': '/workflows/community-nodes',
'/nodes/contributingNodes': '/development/guides/creating-nodes',
'/nodes/invocation-api': '/development/guides/api-development',
'/contributing/ARCHITECTURE': '/development/architecture/overview',
'/contributing/DOWNLOAD_QUEUE': '/development/architecture/model-manager',
'/contributing/HOTKEYS': '/features/hotkeys',
'/contributing/INVOCATIONS': '/development/architecture/invocations',
'/contributing/LOCAL_DEVELOPMENT': '/development/setup/dev-environment',
'/contributing/MODEL_MANAGER': '/development/architecture/model-manager',
'/contributing/NEW_MODEL_INTEGRATION': '/development/guides/models',
'/contributing/PR-MERGE-POLICY': '/development/process/pr-merge-policy',
'/contributing/TESTS': '/development/guides/tests',
'/contributing/contribution_guides/development': '/development',
'/contributing/contribution_guides/newContributorChecklist': '/contributing/new-contributor-guide',
'/contributing/dev-environment': '/development/setup/dev-environment',
'/contributing/frontend': '/development/front-end',
'/contributing/frontend/state-management': '/development/front-end/state-management',
'/contributing/frontend/workflows': '/development/front-end/workflows',
}
});

View File

@@ -0,0 +1,129 @@
# External Provider Integration
This guide covers:
1. Adding a new **external model** (most common; existing provider).
2. Adding a brand-new **external provider** (adapter + config + UI wiring).
## 1) Add a New External Model (Existing Provider)
For provider-backed models (for example, OpenAI or Gemini), the source of truth is
`invokeai/backend/model_manager/starter_models.py`.
### Required model fields
Define a `StarterModel` with:
- `base=BaseModelType.External`
- `type=ModelType.ExternalImageGenerator`
- `format=ModelFormat.ExternalApi`
- `source="external://<provider_id>/<provider_model_id>"`
- `name`, `description`
- `capabilities=ExternalModelCapabilities(...)`
- optional `default_settings=ExternalApiModelDefaultSettings(...)`
Example:
```python
new_external_model = StarterModel(
name="Provider Model Name",
base=BaseModelType.External,
source="external://openai/my-model-id",
description=(
"Provider model (external API). "
"Requires a configured OpenAI API key and may incur provider usage costs."
),
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img", "inpaint"],
supports_negative_prompt=False,
supports_seed=False,
supports_guidance=False,
supports_steps=False,
supports_reference_images=True,
max_images_per_request=4,
),
default_settings=ExternalApiModelDefaultSettings(
width=1024,
height=1024,
num_images=1,
),
)
```
Then append it to `STARTER_MODELS`.
### Required description text
External starter model descriptions must clearly state:
- an API key is required
- usage may incur provider-side costs
### Capabilities must be accurate
These flags directly control UI visibility and request payload fields:
- `supports_negative_prompt`
- `supports_seed`
- `supports_guidance`
- `supports_steps`
- `supports_reference_images`
`supports_steps` is especially important: if `False`, steps are hidden for that model and `steps` is sent as `null`.
### Source string stability
Starter overrides are matched by `source` (`external://provider/model-id`). Keep this stable:
- runtime capability/default overrides depend on it
- installation detection in starter-model APIs depends on it
`STARTER_MODELS` enforces unique `source` values with an assertion.
### Install behavior notes
- External starter models are managed in **External Providers** setup (not the regular Starter Models tab).
- External starter models auto-install when a provider is configured.
- Removing a provider API key removes installed external models for that provider.
## 2) Credentials and Config
External provider API keys are stored separately from `invokeai.yaml`:
- default file: `~/invokeai/api_keys.yaml`
- resolved path: `<INVOKEAI_ROOT>/api_keys.yaml`
Non-secret provider settings (for example base URL overrides) stay in `invokeai.yaml`.
Environment variables are still supported, e.g.:
- `INVOKEAI_EXTERNAL_GEMINI_API_KEY`
- `INVOKEAI_EXTERNAL_OPENAI_API_KEY`
## 3) Add a New Provider (Only If Needed)
If your model uses a provider that is not already integrated:
1. Add config fields in `invokeai/app/services/config/config_default.py`
`external_<provider>_api_key` and optional `external_<provider>_base_url`.
2. Add provider field mapping in `invokeai/app/api/routers/app_info.py`
(`EXTERNAL_PROVIDER_FIELDS`).
3. Implement provider adapter in `invokeai/app/services/external_generation/providers/`
by subclassing `ExternalProvider`.
4. Register the provider in `invokeai/app/api/dependencies.py` when building
`ExternalGenerationService`.
5. Add starter model entries using `source="external://<provider>/<model-id>"`.
6. Optional UI ordering tweak:
`invokeai/frontend/web/src/features/modelManagerV2/subpanels/AddModelPanel/ExternalProviders/ExternalProvidersForm.tsx`
(`PROVIDER_SORT_ORDER`).
## 4) Optional Manual Installation
You can also install external models directly via:
`POST /api/v2/models/install?source=external://<provider_id>/<provider_model_id>`
If omitted, `path`, `source`, and `hash` are auto-populated for external model configs.
Set capabilities conservatively; the external generation service enforces capability checks at runtime.

View File

@@ -0,0 +1,46 @@
---
title: Google Gemini
---
# :material-google: Google Gemini
Invoke supports Google's Gemini image generation models through the Gemini API. This provider is a good fit if you want high-quality text-to-image and reference-based image edits without running a local model.
## Getting an API Key
1. Open [Google AI Studio](https://aistudio.google.com/) and sign in with your Google account.
2. Generate a new API key.
3. Note the key — it will only be shown once.
## Configuration
Add your key to `api_keys.yaml` in your Invoke root directory:
```yaml
external_gemini_api_key: "your-gemini-api-key"
# Optional — only set this if you need to route requests through a different endpoint
external_gemini_base_url: "https://generativelanguage.googleapis.com"
```
Restart Invoke for the change to take effect.
## Available Models
| Model | Modes | Reference Images | Notes |
| --- | --- | --- | --- |
| **Gemini 2.5 Flash Image** | txt2img, img2img, inpaint | Yes | 10 aspect ratios, fixed per-ratio resolutions. |
| **Gemini 3 Pro Image Preview** | txt2img, img2img, inpaint | Up to 14 (6 object + 5 character) | 1K / 2K / 4K resolution presets. |
| **Gemini 3.1 Flash Image Preview** | txt2img, img2img, inpaint | Up to 14 (10 object + 4 character) | 512 / 1K / 2K / 4K resolution presets. |
All Gemini models are single-image-per-request — batch size is fixed at 1. To generate multiple variations, queue multiple invocations.
## Provider-Specific Options
Gemini exposes a **temperature** control in the parameters panel. Lower values make outputs more deterministic, higher values increase variability.
## Tips
- **Reference images** are sent directly to the API as inlined PNG data. Large references increase request latency and cost — crop tightly where possible.
- **Aspect ratios** are mapped to the closest Gemini-supported ratio. For Gemini 3 models, use the resolution presets to stay at the provider's native output sizes and avoid unnecessary rescaling.
- **Pricing** varies by model and region. Check Google's documentation before running large batches.

View File

@@ -0,0 +1,58 @@
---
title: External Models
---
# :material-cloud-outline: External Models
External models let you generate images in Invoke by calling third-party image generation APIs instead of running a model locally. This is useful when:
- You don't have the GPU or VRAM to run a model locally.
- You want access to closed-source models (e.g. GPT Image, Gemini).
- You need a specific provider capability (very high resolutions, fast batches, bilingual text rendering, etc.).
External models appear in the model picker alongside locally installed models. Generations are routed to the provider's API, billed against your provider account, and the resulting images are imported back into Invoke like any other generation.
## Supported Providers
- [Google Gemini](gemini.md) — Gemini 2.5 Flash Image, Gemini 3 Pro Image Preview, Gemini 3.1 Flash Image Preview
- [OpenAI](openai.md) — GPT Image 1 / 1.5 / 1-mini, DALL·E 3, DALL·E 2
## Configuring API Keys
External provider credentials are stored in a dedicated `api_keys.yaml` file alongside `invokeai.yaml` in your Invoke root directory.
```yaml
# api_keys.yaml
external_gemini_api_key: "your-gemini-api-key"
external_openai_api_key: "your-openai-api-key"
# Optional: override the provider base URL (e.g. for a compatible proxy or regional endpoint)
external_gemini_base_url: "https://generativelanguage.googleapis.com"
external_openai_base_url: "https://api.openai.com"
```
Restart Invoke after editing `api_keys.yaml` so the new values are picked up.
!!! warning "Keep your keys private"
`api_keys.yaml` contains secrets. Do not commit it to version control and do not share it with other users of your machine.
## Installing External Models
External models are listed in the starter models dialog under their provider. Install them like any other starter model — Invoke records a model reference but does not download weights (there are no weights to download).
Once installed, external models show up everywhere a model can be selected. Choose one, set the usual parameters (prompt, dimensions, num images, etc.), and invoke as normal.
## Capabilities and Settings Visibility
Each external model declares its own **capabilities** — for example:
- Which generation modes it supports (`txt2img`, `img2img`, `inpaint`).
- Whether it accepts reference images, and how many.
- Which aspect ratios and resolutions it allows.
- Whether it supports a negative prompt, seed, or batch size > 1.
Invoke uses these capabilities to drive the UI: only the settings a given model actually supports will be shown in the parameters panel. If a field you expect is missing, it's because the selected model does not support it.
## Costs and Rate Limits
External providers charge for each request. Check the provider's pricing page before running large batches. Rate-limit errors from the provider are surfaced in Invoke as generation failures — wait a moment and try again, or lower your concurrent batch size.

View File

@@ -0,0 +1,56 @@
---
title: OpenAI
---
# :material-alpha-o-circle-outline: OpenAI
Invoke supports OpenAI's image generation models — both the GPT Image family and the older DALL·E models — through the OpenAI API.
## Getting an API Key
1. Open the [OpenAI API Platform](https://platform.openai.com/api-keys) and sign in.
2. Create a new secret API key.
3. Make sure your account has billing set up — image endpoints are paid per request.
## Configuration
Add your key to `api_keys.yaml` in your Invoke root directory:
```yaml
external_openai_api_key: "sk-..."
# Optional — use this to point at a compatible proxy or Azure OpenAI deployment
external_openai_base_url: "https://api.openai.com"
```
Restart Invoke for the change to take effect.
## Available Models
| Model | Modes | Aspect Ratios | Batch | Notes |
| --- | --- | --- | --- | --- |
| **GPT Image 1.5** | txt2img, img2img, inpaint | 1:1, 3:2, 2:3 | up to 10 | Fastest and cheapest GPT Image model. |
| **GPT Image 1** | txt2img, img2img, inpaint | 1:1, 3:2, 2:3 | up to 10 | Highest quality of the GPT Image family. |
| **GPT Image 1 Mini** | txt2img, img2img, inpaint | 1:1, 3:2, 2:3 | up to 10 | ~80% cheaper than GPT Image 1. |
| **DALL·E 3** | txt2img only | 1:1, 7:4, 4:7 | 1 | No reference-image / edit support. |
| **DALL·E 2** | txt2img, img2img, inpaint | 1:1 | up to 10 | Square only. |
## Provider-Specific Options
For **GPT Image** models, Invoke surfaces two provider-specific options in the parameters panel:
- **Quality** — `low`, `medium`, `high`, or `auto`. Higher quality costs more and takes longer.
- **Background** — `auto`, `transparent`, or `opaque`. Use `transparent` for PNG output with an alpha channel.
DALL·E 2 and DALL·E 3 do not expose these options.
## How Requests Are Routed
- Pure text-to-image requests hit `/v1/images/generations`.
- Any request with an init image or reference images is sent to `/v1/images/edits` instead. This is done transparently — you don't need to pick an endpoint.
## Tips
- **Batching** on GPT Image and DALL·E 2 tops out at 10 per request. Larger batches are split into multiple API calls.
- **Costs** can climb quickly with high-quality GPT Image generations. Start with GPT Image 1 Mini when iterating on prompts.
- **Rate limits** from OpenAI surface as failed invocations — retry after a short wait.

View File

@@ -130,4 +130,4 @@ In the current UI, the `Seed Behaviour` setting controls how seeds are reused ac
- Be careful with multiple groups, because the number of combinations grows quickly.
- Review the expanded prompt list before launching a large batch.
- Use dynamic prompting for variation, not to avoid thinking through the base prompt.
- When one specific term needs more emphasis, use [Prompting Syntax](/concepts/prompt-syntax) instead of adding more dynamic groups.
- When one specific term needs more emphasis, use [Prompting Syntax](../prompt-syntax) instead of adding more dynamic groups.

View File

@@ -43,17 +43,17 @@ There are two prompt boxes: **Positive Prompt** & **Negative Prompt**.
<LinkCard
title="Prompting Guide"
description="Learn how to structure prompts, use positive and negative prompts well, and iterate toward better results."
href="/concepts/prompting-guide"
href="../prompting-guide"
/>
<LinkCard
title="Prompting Syntax"
description="Learn InvokeAI's advanced prompt weighting and composition syntax, including `+`, `-`, `.blend()`, and `.and()`."
href="/concepts/prompt-syntax"
href="../prompt-syntax"
/>
<LinkCard
title="Dynamic Prompting"
description="Expand one prompt into many prompt variations with curly-brace syntax."
href="/concepts/dynamic-prompting"
href="../dynamic-prompting"
/>
</CardGrid>
@@ -78,7 +78,7 @@ Invoke offers a number of different workflows for interacting with models to pro
<Steps>
1. **Fine-tuning your prompt:**
The more specific you are, the closer the image will turn out to what is in your head. Adding more details in the Positive or Negative Prompt can help add or remove parts of the image. You can also use advanced techniques like upweighting and downweighting to control the influence of specific words. Learn more in the [Prompting Guide](/concepts/prompting-guide) and [Prompting Syntax](/concepts/prompt-syntax).
The more specific you are, the closer the image will turn out to what is in your head. Adding more details in the Positive or Negative Prompt can help add or remove parts of the image. You can also use advanced techniques like upweighting and downweighting to control the influence of specific words. Learn more in the [Prompting Guide](../prompting-guide) and [Prompting Syntax](../prompt-syntax).
:::tip
If you're seeing poor results, try adding the things you don't like about the image to your negative prompt. E.g. *distorted, low quality, unrealistic, etc.*
@@ -99,7 +99,7 @@ Invoke offers a number of different workflows for interacting with models to pro
5. **Explore Advanced Settings:**
InvokeAI has a full suite of tools available to allow you complete control over your image creation process. Check out our [docs if you want to learn more](https://invoke-ai.github.io/InvokeAI/features/).
InvokeAI has a full suite of tools available to allow you complete control over your image creation process. Check out our [features docs](../../features/gallery) if you want to learn more.
</Steps>
## Terms & Concepts

View File

@@ -53,4 +53,4 @@ In this situation, you may need to provide some additional information to identi
Add `:v2` to the repo ID and use that when installing the model: `monster-labs/control_v1p_sd15_qrcode_monster:v2`
:::
[set up in the config file]: /configuration/invokeai-yaml
[set up in the config file]: ../../configuration/invokeai-yaml

View File

@@ -10,18 +10,18 @@ import { Card, LinkCard, CardGrid } from '@astrojs/starlight/components';
<CardGrid>
<LinkCard
title="Prompting Guide"
href="/concepts/prompting-guide"
href="../prompting-guide"
description="Learn how to write effective prompts for InvokeAI."
/>
<LinkCard
title="Dynamic Prompting"
href="/concepts/dynamic-prompting"
href="../dynamic-prompting"
description="Learn how to create many prompt variations from a single template."
/>
</CardGrid>
InvokeAI supports Compel-style prompt weighting and prompt functions for `SD 1.5` and `SDXL` text conditioning workflows. Recent model families, including `FLUX`, `Z-Image`, `CogView4`, and `Qwen Image`, bypass Compel and do not use the syntax documented on this page. This page documents syntax for those Compel-based workflows only. If you want general advice on writing better prompts, start with [Prompting Guide](/concepts/prompting-guide).
InvokeAI supports Compel-style prompt weighting and prompt functions for `SD 1.5` and `SDXL` text conditioning workflows. Recent model families, including `FLUX`, `Z-Image`, `CogView4`, and `Qwen Image`, bypass Compel and do not use the syntax documented on this page. This page documents syntax for those Compel-based workflows only. If you want general advice on writing better prompts, start with [Prompting Guide](../prompting-guide).
:::note[Compatibility note]
If a weighted prompt seems to be ignored, check whether you are using an `SD 1.5` or `SDXL` workflow. Compel syntax on this page does not apply to newer model families such as `FLUX`, `Z-Image`, `CogView4`, and `Qwen Image`.
@@ -134,5 +134,5 @@ Use unescaped parentheses only when you mean grouping or weighting.
## Related pages
- For practical prompt-writing advice, read [Prompting Guide](/concepts/prompting-guide).
- For prompt expansion and permutations, read [Dynamic Prompting](/concepts/dynamic-prompting).
- For practical prompt-writing advice, read [Prompting Guide](../prompting-guide).
- For prompt expansion and permutations, read [Dynamic Prompting](../dynamic-prompting).

View File

@@ -10,13 +10,13 @@ import { Card, CardGrid, Steps, LinkCard } from '@astrojs/starlight/components';
<CardGrid>
<LinkCard
title="Prompting Syntax"
href="/concepts/prompt-syntax"
href="../prompt-syntax"
description="Learn how to weight prompt terms, blend concepts, and use prompt conjunctions for more control."
/>
<LinkCard
title="Dynamic Prompting"
href="/concepts/dynamic-prompting"
href="../dynamic-prompting"
description="Learn how to create many prompt variations from a single template."
/>
</CardGrid>
@@ -82,7 +82,7 @@ Good negative prompts usually name specific failure modes: `blurry`, `distorted
5. Escalate only when needed
If the result is close but one element is too weak or too strong, move to [Prompting Syntax](/concepts/prompt-syntax) for weighting. If you want lots of variations, use [Dynamic Prompting](/concepts/dynamic-prompting).
If the result is close but one element is too weak or too strong, move to [Prompting Syntax](../prompt-syntax) for weighting. If you want lots of variations, use [Dynamic Prompting](../dynamic-prompting).
</Steps>
Here is the same idea refined in stages:
@@ -108,10 +108,10 @@ The same prompt can behave very differently across models.
Reach for advanced syntax when a normal comma-separated prompt is almost right, but you need more control.
- Use [Prompting Syntax](/concepts/prompt-syntax) when one term needs more or less influence.
- Use [Prompting Syntax](../prompt-syntax) when one term needs more or less influence.
- Use `.blend()` when you want to mix concepts or styles deliberately.
- Use `.and()` when you want separate prompt clauses encoded individually.
- Use [Dynamic Prompting](/concepts/dynamic-prompting) when you want many prompt variations from one template.
- Use [Dynamic Prompting](../dynamic-prompting) when you want many prompt variations from one template.
## Common mistakes

View File

@@ -14,7 +14,7 @@ import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
Docker Desktop on Windows [includes GPU support](https://www.docker.com/blog/wsl-2-gpu-support-for-docker-desktop-on-nvidia-gpus/).
</TabItem>
<TabItem label="MacOS" icon="apple">
Docker can not access the GPU on macOS, so your generation speeds will be slow. Use the [launcher](/start-here/installation) instead.
Docker can not access the GPU on macOS, so your generation speeds will be slow. Use the [launcher](../../start-here/installation) instead.
</TabItem>
<TabItem label="Linux" icon="linux">
Configure Docker to access your machine's GPU.
@@ -55,7 +55,7 @@ On the [Docker Desktop app](https://docs.docker.com/get-docker/), go to `Prefere
Set up your environment variables. In the `docker` directory, make a copy of `.env.sample` and name it `.env`. Make changes as necessary.
Any environment variables supported by InvokeAI can be set here - please see [Configurations](../configuration.md) for further detail.
Any environment variables supported by InvokeAI can be set here - please see the [configuration docs](/configuration/invokeai-yaml/) for further detail.
At the very least, you might want to set the `INVOKEAI_ROOT` environment variable
to point to the location where you wish to store your InvokeAI models, configuration, and outputs.

View File

@@ -49,7 +49,7 @@ It has two sections - one for internal use and one for user settings:
# Internal metadata - do not edit:
schema_version: 4.0.2
# Put user settings here - see https://invoke-ai.github.io/InvokeAI/features/CONFIGURATION/:
# Put user settings here - see https://invoke.ai/configuration/invokeai-yaml/:
host: 0.0.0.0 # serve the app on your local network
models_dir: D:\invokeai\models # store models on an external drive
precision: float16 # always use fp16 precision
@@ -70,6 +70,93 @@ You can use any config file with the `--config` CLI arg. Pass in the path to the
Note that environment variables will trump any settings in the config file.
#### Model Marketplace API Keys
Some model marketplaces require an API key to download models. You can provide a URL pattern and appropriate token in your `invokeai.yaml` file to provide that API key.
The pattern can be any valid regex (you may need to surround the pattern with quotes):
```yaml
remote_api_tokens:
# Any URL containing `models.com` will automatically use `your_models_com_token`
- url_regex: models.com
token: your_models_com_token
# Any URL matching this contrived regex will use `some_other_token`
- url_regex: '^[a-z]{3}whatever.*\.com$'
token: some_other_token
```
The provided token will be added as a `Bearer` token to the network requests to download the model files. As far as we know, this works for all model marketplaces that require authorization.
:::tip[Hugging face Models]
If you get an error when installing a HF model using a URL instead of repo id, you may need to [set up a HF API token](https://huggingface.co/settings/tokens) and add an entry for it under `remote_api_tokens`. Use `huggingface.co` for `url_regex`.
:::
#### Model Hashing
Models are hashed during installation, providing a stable identifier for models across all platforms. Hashing is a one-time operation.
```yaml
hashing_algorithm: blake3_single # default value
```
You might want to change this setting, depending on your system:
- `blake3_single` (default): Single-threaded - best for spinning HDDs, still OK for SSDs
- `blake3_multi`: Parallelized, memory-mapped implementation - best for SSDs, terrible for spinning disks
- `random`: Skip hashing entirely - fastest but of course no hash
During the first startup after upgrading to v4, all of your models will be hashed. This can take a few minutes.
Most common algorithms are supported, like `md5`, `sha256`, and `sha512`. These are typically much, much slower than either of the BLAKE3 variants.
#### Path Settings
These options set the paths of various directories and files used by InvokeAI. Any user-defined paths should be absolute paths.
#### Logging
Several different log handler destinations are available, and multiple destinations are supported by providing a list:
```yaml
log_handlers:
- console
- syslog=localhost
- file=/var/log/invokeai.log
```
- `console` is the default. It prints log messages to the command-line window from which InvokeAI was launched.
- `syslog` is only available on Linux and Macintosh systems. It uses
the operating system's "syslog" facility to write log file entries
locally or to a remote logging machine. `syslog` offers a variety
of configuration options:
```yaml
syslog=/dev/log` - log to the /dev/log device
syslog=localhost` - log to the network logger running on the local machine
syslog=localhost:512` - same as above, but using a non-standard port
syslog=fredserver,facility=LOG_USER,socktype=SOCK_DRAM`
- Log to LAN-connected server "fredserver" using the facility LOG_USER and datagram packets.
```
- `http` can be used to log to a remote web server. The server must be
properly configured to receive and act on log messages. The option
accepts the URL to the web server, and a `method` argument
indicating whether the message should be submitted using the GET or
POST method.
```yaml
http=http://my.server/path/to/logger,method=POST
```
The `log_format` option provides several alternative formats:
- `color` - default format providing time, date and a message, using text colors to distinguish different log severities
- `plain` - same as above, but monochrome text only
- `syslog` - the log level and error message only, allowing the syslog system to attach the time and date
- `legacy` - a format similar to the one used by the legacy 2.3 InvokeAI releases.
### Environment Variables
All settings may be set via environment variables by prefixing `INVOKEAI_`
@@ -84,4 +171,23 @@ export INVOKEAI_REMOTE_API_TOKENS='[{"url_regex":"modelmarketplace", "token": "1
We suggest using `invokeai.yaml`, as it is more user-friendly.
### CLI Args
A subset of settings may be specified using CLI args:
- `--root`: specify the root directory
- `--config`: override the default `invokeai.yaml` file location
### Low-VRAM Mode
See the [Low-VRAM mode docs][low-vram] for details on enabling this feature.
### All Settings
The full settings reference is below. Additional explanations for selected settings appear earlier on this page.
<SettingsDocs />
[basic guide to yaml files]: https://circleci.com/blog/what-is-yaml-a-beginner-s-guide/
[Model Marketplace API Keys]: #model-marketplace-api-keys
[low-vram]: /configuration/low-vram-mode

View File

@@ -10,13 +10,13 @@ We welcome contributions, whether features, bug fixes, code cleanup, testing, co
## Development
If youd like to help with development, please see our [development guide](contribution_guides/development.md).
If youd like to help with development, please see our [development guide](/development/).
**New Contributors:** If youre unfamiliar with contributing to open source projects, take a look at our [new contributor guide](contribution_guides/newContributorChecklist.md).
**New Contributors:** If youre unfamiliar with contributing to open source projects, take a look at our [new contributor guide](/contributing/new-contributor-guide/).
## Nodes
If youd like to add a Node, please see our [nodes contribution guide](../nodes/contributingNodes.md).
If youd like to add a Node, please see our [nodes contribution guide](/development/guides/creating-nodes/).
## Support and Triaging
@@ -42,7 +42,7 @@ This project is a combined effort of dedicated people from across the world. [C
## Code of Conduct
The InvokeAI community is a welcoming place, and we want your help in maintaining that. Please review our [Code of Conduct](../CODE_OF_CONDUCT.md) to learn more - it's essential to maintaining a respectful and inclusive environment.
The InvokeAI community is a welcoming place, and we want your help in maintaining that. Please review our [Code of Conduct](/contributing/code-of-conduct/) to learn more - it's essential to maintaining a respectful and inclusive environment.
By making a contribution to this project, you certify that:

View File

@@ -10,11 +10,11 @@ If you're a new contributor to InvokeAI or Open Source Projects, this is the gui
## New Contributor Checklist
<Steps>
1. Set up your local development environment & fork of InvokAI by following [the steps outlined here](/development/setup/dev-environment/#initial-setup)
1. Set up your local development environment & fork of InvokAI by following [the steps outlined here](../../development/setup/dev-environment/#initial-setup)
2. Set up your local tooling with [this guide](../LOCAL_DEVELOPMENT.md). Feel free to skip this step if you already have tooling you're comfortable with.
2. Set up your local tooling with [this guide](/development/). Feel free to skip this step if you already have tooling you're comfortable with.
3. Familiarize yourself with [Git](https://www.atlassian.com/git) & our project structure by reading through the [development documentation](development.md)
3. Familiarize yourself with [Git](https://www.atlassian.com/git) & our project structure by reading through the [development documentation](/development/)
4. Join the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord
@@ -29,7 +29,7 @@ If you're a new contributor to InvokeAI or Open Source Projects, this is the gui
Never made an open source contribution before? Wondering how contributions work in our project? Here's a quick rundown!
Before starting these steps, ensure you have your local environment [configured for development](../LOCAL_DEVELOPMENT.md).
Before starting these steps, ensure you have your local environment [configured for development](/development/setup/dev-environment/).
<Steps>
1. Find a [good first issue](https://github.com/invoke-ai/InvokeAI/contribute) that you are interested in addressing or a feature that you would like to add. Then, reach out to our team in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord to ensure you are setup for success.

View File

@@ -316,8 +316,8 @@ new Invocation ready to be used.
Once you've created a Node, the next step is to share it with the community! The
best way to do this is to submit a Pull Request to add the Node to the
[Community Nodes](../nodes/communityNodes.md) list. If you're not sure how to do that,
take a look a at our [contributing nodes overview](../nodes/contributingNodes.md).
[Community Nodes](/workflows/community-nodes/) list. If you're not sure how to do that,
take a look a at our [contributing nodes overview](/development/guides/creating-nodes/).
## Advanced

View File

@@ -93,7 +93,7 @@ The session graph does not support looping. This is left as an application probl
### Invocations
Invocations represent individual units of execution, with inputs and outputs. All invocations are located in `/invokeai/app/invocations`, and are all automatically discovered and made available in the applications. These are the primary way to expose new functionality in Invoke.AI, and the [implementation guide](INVOCATIONS.md) explains how to add new invocations.
Invocations represent individual units of execution, with inputs and outputs. All invocations are located in `/invokeai/app/invocations`, and are all automatically discovered and made available in the applications. These are the primary way to expose new functionality in Invoke.AI, and the [implementation guide](/development/architecture/invocations/) explains how to add new invocations.
### Services

View File

@@ -7,7 +7,7 @@ Invoke's UI is made possible by many contributors and open-source libraries. Tha
## Dev environment
Follow the [dev environment](../dev-environment.md) guide to get set up. Run the UI using `pnpm dev`.
Follow the [dev environment](/development/setup/dev-environment/) guide to get set up. Run the UI using `pnpm dev`.
## Package scripts

View File

@@ -10,11 +10,11 @@ import { Steps, LinkCard } from '@astrojs/starlight/components';
<LinkCard
title="Invocations"
description="Learn about the invocation system, which is the foundation for creating nodes in InvokeAI."
href="/development/architecture/invocations" />
href="../../architecture/invocations" />
2. Make sure the node is contained in a new Python (.py) file. Preferably, the node is in a repo with a README detailing the nodes usage & examples to help others more easily use your node. Including the tag "invokeai-node" in your repository's README can also help other users find it more easily.
3. Submit a pull request with a link to your node(s) repo in GitHub against the `main` branch to add the node to the [Community Nodes](communityNodes.md) list
3. Submit a pull request with a link to your node(s) repo in GitHub against the `main` branch to add the node to the [Community Nodes](../../../workflows/community-nodes) list
Make sure you are following the template below and have provided all relevant details about the node and what it does. Example output images and workflows are very helpful for other users looking to use your node.
@@ -23,7 +23,7 @@ import { Steps, LinkCard } from '@astrojs/starlight/components';
### Community Node Template
Append the following template to your pull request and the [Community Nodes](/workflows/community-nodes) page when submitting a node to be added to the community nodes list:
Append the following template to your pull request and the [Community Nodes](../../../workflows/community-nodes) page when submitting a node to be added to the community nodes list:
```md
---

View File

@@ -81,7 +81,7 @@ But, if you want to be extra-super careful, here's how to test it:
- Download the `dist.zip` build artifact from the `build-wheel` job
- Unzip it and find the wheel file
- Create a fresh Invoke install by following the [manual install guide](https://invoke-ai.github.io/InvokeAI/installation/manual/) - but instead of installing from PyPI, install from the wheel
- Create a fresh Invoke install by following the [manual install guide](/start-here/manual/) - but instead of installing from PyPI, install from the wheel
- Test the app
##### Something isn't right
@@ -116,7 +116,7 @@ This job is not required for the production PyPI publish, but included just in c
- Approve this publish job without approving the prod publish
- Let it finish
- Create a fresh Invoke install by following the [manual install guide](https://invoke-ai.github.io/InvokeAI/installation/manual/), making sure to use the Test PyPI index URL: `https://test.pypi.org/simple/`
- Create a fresh Invoke install by following the [manual install guide](/start-here/manual/), making sure to use the Test PyPI index URL: `https://test.pypi.org/simple/`
- Test the app
#### `publish-pypi` Job

View File

@@ -44,7 +44,7 @@ import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
git lfs pull
```
4. Create a directory for user data (images, models, db, etc). This is typically at `~/invokeai`, but if you already have a non-dev install, you may want to create a separate directory for the dev install.
5. Follow the [manual install](../installation/manual) guide, with some modifications to the install command:
5. Follow the [manual install](/start-here/manual/) guide, with some modifications to the install command:
- Use `.` instead of `invokeai` to install from the current directory. You don't need to specify the version.
- Add `-e` after the `install` operation to make this an [editable install](https://pip.pypa.io/en/latest/cli/pip_install/#cmdoption-e). That means your changes to the python code will be reflected when you restart the Invoke server.

View File

@@ -13,35 +13,35 @@ This section of the documentation is for developers interested in contributing t
<Card title="Setup" icon="download">
Instructions for setting up your local development environment, including how to run the project locally and how to set up your tooling.
<LinkButton href="/development/setup/dev-environment/" icon="right-arrow" variant="primary">
<LinkButton href="./setup/dev-environment/" icon="right-arrow" variant="primary">
Learn more
</LinkButton>
</Card>
<Card title="Front End" icon="laptop">
An introduction to the front end codebase, including the technologies used and how to get started.
<LinkButton href="/development/front-end/" icon="right-arrow" variant="secondary">
<LinkButton href="./front-end/" icon="right-arrow" variant="secondary">
Learn more
</LinkButton>
</Card>
<Card title="Guides" icon="open-book">
A collection of guides for common development tasks, such as adding new model architectures, making tests, and more.
<LinkButton href="/development/guides/models" icon="right-arrow" variant="secondary">
<LinkButton href="./guides/models" icon="right-arrow" variant="secondary">
Learn more
</LinkButton>
</Card>
<Card title="Architecture" icon="puzzle">
An overview of the InvokeAI architecture, including the major components and how they interact.
<LinkButton href="/development/architecture/overview/" icon="right-arrow" variant="secondary">
<LinkButton href="./architecture/overview/" icon="right-arrow" variant="secondary">
Learn more
</LinkButton>
</Card>
<Card title="Process" icon="list-format">
An overview of the development processes we follow, including our pull request merge policy and release process.
<LinkButton href="/development/process/pr-merge-policy/" icon="right-arrow" variant="secondary">
<LinkButton href="./process/pr-merge-policy/" icon="right-arrow" variant="secondary">
Learn more
</LinkButton>
</Card>

View File

@@ -107,7 +107,7 @@ Additionally, each image has a context menu (right-click or Ctrl+click) with pow
## Summary
This walkthrough covers the Gallery interface and Boards. For instructions on actually generating images, please refer to the documentation on [Prompts](PROMPTS.md), the [Image to Image](IMG2IMG.md) tab, and the [Unified Canvas](UNIFIED_CANVAS.md).
This walkthrough covers the Gallery interface and Boards. For guidance on prompting and generation workflows, please refer to the [Prompting Guide](/concepts/prompting-guide/) and [AI Image Generation](/concepts/image-generation/).
## Acknowledgements

View File

@@ -27,5 +27,5 @@ The text is committed to a raster layer when you press **Enter**. Press **Esc**
<LinkCard
title="Canvas Text Tool"
description="Learn about the implementation of the Text tool, including the editor overlay, rasterization"
href="/development/front-end/text-tool/"
href="../../development/front-end/text-tool/"
/>

View File

@@ -1,8 +1,9 @@
---
title: AI Image Generation<br /> for Creatives
title: AI Image Generation for Creatives
description: A leading creative engine built to empower professionals and enthusiasts alike.
template: splash
hero:
title: AI Image Generation<br /> for Creatives
tagline: Invoke is a free and open-source creative engine for AI-powered image generation. Built by creatives, for creatives. Self-hosted, fully customizable, and Apache 2.0 licensed.
actions:
- text: Get Started
@@ -77,7 +78,7 @@ Whether you are looking to install the app, get support, train your own models,
<CardGrid>
<Card title="Get Installed" icon="download">
Ready to dive in? The [Invoke Launcher](installation/quick_start.md) is the fastest way to get up and running on Windows, macOS, and Linux. For advanced setups, try [Docker](installation/docker.md) or a [manual Python installation](installation/manual.md).
Ready to dive in? The [Invoke Launcher](/start-here/installation/) is the fastest way to get up and running on Windows, macOS, and Linux. For advanced setups, try [Docker](/configuration/docker/) or a [manual Python installation](/start-here/manual/).
<LinkButton href="download" icon="right-arrow">
Get Invoke
@@ -93,7 +94,7 @@ Whether you are looking to install the app, get support, train your own models,
</Card>
<Card title="Get Support" icon="discord">
Stuck? Check out our comprehensive [FAQ](./faq.md) for quick answers. If you still need a hand, our community is incredibly active and helpful.
Stuck? Check out our comprehensive [FAQ](/troubleshooting/faq/) for quick answers. If you still need a hand, our community is incredibly active and helpful.
<LinkButton href="https://discord.gg/ZmtBAhwWhy" variant="secondary" icon="discord">Join our Discord</LinkButton>
</Card>

View File

@@ -13,13 +13,13 @@ import SystemRequirementsLink from '@components/SystemRequirmentsLink.astro'
<LinkCard
title="Local Installation Guide"
description="If you want to use Invoke locally, you should probably use the launcher instead."
href="/start-here/installation"
href="../installation"
/>
<LinkCard
title="Developer Installation Guide"
description="If you want to contribute to InvokeAI or run the app on the main branch, follow the developer installation guide instead."
href="/development"
href="../../development"
/>
## Walkthrough
@@ -188,6 +188,6 @@ The following commands vary depending on the version of Invoke being installed a
If you run Invoke on a headless server, you might want to install and run Invoke on the command line.
We do not plan to maintain scripts to do this moving forward, instead focusing our dev resources on the GUI [launcher](/start-here/installation).
We do not plan to maintain scripts to do this moving forward, instead focusing our dev resources on the GUI [launcher](../installation).
You can create your own scripts for this by copying the handful of commands in this guide. `uv`'s [`pip` interface docs](https://docs.astral.sh/uv/reference/cli/#uv-pip-install) may be useful.

View File

@@ -36,7 +36,7 @@ The requirements below are rough guidelines for best performance. GPUs with less
## Python
:::tip[The launcher installs python for you]
You don't need to do this if you are installing with the [Invoke Launcher](/start-here/installation).
You don't need to do this if you are installing with the [Invoke Launcher](../installation).
:::
Invoke requires python `3.11` through `3.12`. If you don't already have one of these versions installed, we suggest installing `3.12`, as it will be supported for longer.
@@ -126,12 +126,12 @@ Confirm that `rocm-smi` displays driver and CUDA versions after installation.
An alternative to installing ROCm locally is to use a [ROCm docker container] to run the application in a container.
[Low VRAM Guide]: /configuration/low-vram-mode
[Low VRAM Guide]: ../../configuration/low-vram-mode
[Nvidia Container Runtime]: https://developer.nvidia.com/container-runtime
[an official installer]: https://www.python.org/downloads/
[using `uv` to manage your python installation]: https://docs.astral.sh/uv/concepts/python-versions/#installing-a-python-version
[Microsoft Visual C++ Redistributable]: https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170
[Invoke Launcher]: /start-here/installation
[Invoke Launcher]: ../installation
[CUDA Toolkit Downloads]: https://developer.nvidia.com/cuda-downloads
[Cuda Docs]: https://developer.nvidia.com/cudnn
[cuDNN support matrix]: https://docs.nvidia.com/deeplearning/cudnn/support-matrix/index.html

View File

@@ -12,7 +12,7 @@ If the troubleshooting steps on this page don't get you up and running, please e
<LinkCard
title="Installation Guide"
description="Step-by-step instructions to get Invoke up and running."
href="/start-here/installation"
href="../../start-here/installation"
/>
## Downloading models and using existing models
@@ -109,9 +109,9 @@ To better understand how the `glibc` memory allocator works, see these reference
Note the differences between memory allocated as chunks in an arena vs. memory allocated with `mmap`. Under `glibc`'s default configuration, most model tensors get allocated as chunks in an arena making them vulnerable to the problem of fragmentation.
[model install docs]: /concepts/models
[system requirements]: /start-here/system-requirements
[Low VRAM mode guide]: /configuration/low-vram-mode
[model install docs]: ../../concepts/models
[system requirements]: ../../start-here/system-requirements
[Low VRAM mode guide]: ../../configuration/low-vram-mode
[create an issue]: https://github.com/invoke-ai/InvokeAI/issues
[discord]: https://discord.gg/ZmtBAhwWhy
[configuration docs]: /configuration/invokeai-yaml
[configuration docs]: ../../configuration/invokeai-yaml

View File

@@ -136,7 +136,7 @@ InvokeAI's node system is extensible. Community-created nodes can add new capabi
To install a community node pack:
<Steps>
1. Find a node pack from the [Community Nodes](/workflows/community-nodes) list.
1. Find a node pack from the [Community Nodes](../community-nodes) list.
2. Clone or download the node pack into the `nodes` folder inside your InvokeAI installation directory.
3. Restart InvokeAI. The new nodes will appear in the node picker.
</Steps>
@@ -145,4 +145,4 @@ To install a community node pack:
The recommended method is `git clone`, which makes it easy to update node packs later with `git pull`.
:::
For more details and a full catalog of available community nodes, see the [Community Nodes](/workflows/community-nodes) page.
For more details and a full catalog of available community nodes, see the [Community Nodes](../community-nodes) page.

View File

@@ -12,7 +12,7 @@ If you're coming to InvokeAI from ComfyUI, welcome! You'll find things are simil
InvokeAI's nodes tend to be more granular than default nodes in Comfy. This means each node in Invoke will do a specific task, and you might need to use multiple nodes to achieve the same result. The added granularity improves the control you have over your workflows.
</Card>
<Card title="Backend Differences" icon="puzzle">
InvokeAI's backend and ComfyUI's backend are very different, which means Comfy workflows are not able to be imported directly into InvokeAI. However, we have created a [list of popular workflows](/workflows/community-nodes) for you to get started with Nodes in InvokeAI!
InvokeAI's backend and ComfyUI's backend are very different, which means Comfy workflows are not able to be imported directly into InvokeAI. However, we have created a [list of popular workflows](../community-nodes) for you to get started with Nodes in InvokeAI!
</Card>
## Node Equivalents

View File

@@ -2,9 +2,9 @@
title: Community Nodes
---
These are nodes that have been developed by the community, for the community. If you're not sure what a node is, you can learn more about nodes [here](overview.md).
These are nodes that have been developed by the community, for the community. If you're not sure what a node is, you can learn more about nodes [here](/concepts/nodes-workflows/).
If you'd like to submit a node for the community, please refer to the [node creation overview](contributingNodes.md).
If you'd like to submit a node for the community, please refer to the [node creation overview](/development/guides/creating-nodes/).
To use a node, add the node to the `nodes` folder found in your InvokeAI install location.

View File

@@ -13,7 +13,7 @@ The workflow editor is a blank canvas allowing for the use of individual functio
A node graph is composed of multiple nodes that are connected together to create a workflow. Nodes' inputs and outputs are connected by dragging connectors from node to node. Inputs and outputs are color-coded for ease of use.
:::tip[New to Diffusion?]
If you're not familiar with Diffusion, take a look at our [Diffusion Overview](/concepts/diffusion). Understanding how diffusion works will enable you to more easily use the Workflow Editor and build workflows to suit your needs.
If you're not familiar with Diffusion, take a look at our [Diffusion Overview](../../concepts/diffusion). Understanding how diffusion works will enable you to more easily use the Workflow Editor and build workflows to suit your needs.
:::
## Features

View File

@@ -17,7 +17,7 @@ You can read more about nodes and how to use the node editor by checking out the
<LinkCard
title="Node Editor Deep Dive"
description="Learn how to interact with the Node Editor, connect nodes, and build powerful custom workflows."
href="/workflows/editor-interface"
href="../workflows/editor-interface"
/>
## Downloading New Nodes
@@ -27,5 +27,5 @@ To download a new node and enhance your workflows with new features, visit our l
<LinkCard
title="Explore Community Nodes"
description="Discover and download new nodes created by the InvokeAI community to extend your workflow capabilities."
href="/workflows/community-nodes"
href="../workflows/community-nodes"
/>

View File

@@ -723,6 +723,50 @@
"required": false,
"type": "<class 'bool'>",
"validation": {}
},
{
"category": "EXTERNAL PROVIDERS",
"default": null,
"description": "API key for Gemini image generation.",
"env_var": "INVOKEAI_EXTERNAL_GEMINI_API_KEY",
"literal_values": [],
"name": "external_gemini_api_key",
"required": false,
"type": "typing.Optional[str]",
"validation": {}
},
{
"category": "EXTERNAL PROVIDERS",
"default": null,
"description": "API key for OpenAI image generation.",
"env_var": "INVOKEAI_EXTERNAL_OPENAI_API_KEY",
"literal_values": [],
"name": "external_openai_api_key",
"required": false,
"type": "typing.Optional[str]",
"validation": {}
},
{
"category": "EXTERNAL PROVIDERS",
"default": null,
"description": "Base URL override for Gemini image generation.",
"env_var": "INVOKEAI_EXTERNAL_GEMINI_BASE_URL",
"literal_values": [],
"name": "external_gemini_base_url",
"required": false,
"type": "typing.Optional[str]",
"validation": {}
},
{
"category": "EXTERNAL PROVIDERS",
"default": null,
"description": "Base URL override for OpenAI image generation.",
"env_var": "INVOKEAI_EXTERNAL_OPENAI_BASE_URL",
"literal_values": [],
"name": "external_openai_base_url",
"required": false,
"type": "typing.Optional[str]",
"validation": {}
}
]
}

View File

@@ -0,0 +1,6 @@
export const withBase = (path: string, baseUrl: string) => {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
const normalizedPath = path.replace(/^\//, '');
return `${normalizedBase}${normalizedPath}`;
};

View File

@@ -1,6 +1,7 @@
---
import { LinkCard, Icon, LinkButton } from '@astrojs/starlight/components';
import { type StarlightIcon } from '@astrojs/starlight/types';
import { withBase } from '../base-path';
type LauncherDownloadOption = {
icon: StarlightIcon;
@@ -45,7 +46,7 @@ const manualDownloadOptions = {
docker: {
headline: 'Run with Docker',
description: 'For users who want to run Invoke without installing dependencies directly on their system.',
href: '/configuration/docker/',
href: withBase('/configuration/docker/', import.meta.env.BASE_URL),
},
};
---

View File

@@ -1,9 +1,10 @@
---
import { LinkCard } from '@astrojs/starlight/components';
import { withBase } from '../base-path';
---
<LinkCard
title="System Requirements"
description="Please check the system requirements page to make sure your hardware is capable of running the desired models."
href="/start-here/system-requirements"
href={withBase('/start-here/system-requirements/', import.meta.env.BASE_URL)}
/>

View File

@@ -18,6 +18,9 @@ from invokeai.app.services.client_state_persistence.client_state_persistence_sql
from invokeai.app.services.config.config_default import InvokeAIAppConfig
from invokeai.app.services.download.download_default import DownloadQueueService
from invokeai.app.services.events.events_fastapievents import FastAPIEventService
from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService
from invokeai.app.services.external_generation.providers import GeminiProvider, OpenAIProvider
from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models
from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage
from invokeai.app.services.image_records.image_records_sqlmodel import SqlModelImageRecordStorage
from invokeai.app.services.images.images_default import ImageService
@@ -151,13 +154,22 @@ class ApiDependencies:
),
)
download_queue_service = DownloadQueueService(app_config=configuration, event_bus=events)
model_images_service = ModelImageFileStorageDisk(model_images_folder / "model_images")
model_record_service = ModelRecordServiceSqlModel(db=db, logger=logger)
model_manager = ModelManagerService.build_model_manager(
app_config=configuration,
model_record_service=ModelRecordServiceSqlModel(db=db, logger=logger),
model_record_service=model_record_service,
download_queue=download_queue_service,
events=events,
)
external_generation = ExternalGenerationService(
providers={
GeminiProvider.provider_id: GeminiProvider(app_config=configuration, logger=logger),
OpenAIProvider.provider_id: OpenAIProvider(app_config=configuration, logger=logger),
},
logger=logger,
record_store=model_record_service,
)
model_images_service = ModelImageFileStorageDisk(model_images_folder / "model_images")
model_relationships = ModelRelationshipsService()
model_relationship_records = SqlModelModelRelationshipRecordStorage(db=db)
names = SimpleNameService()
@@ -190,6 +202,7 @@ class ApiDependencies:
model_relationships=model_relationships,
model_relationship_records=model_relationship_records,
download_queue=download_queue_service,
external_generation=external_generation,
names=names,
performance_statistics=performance_statistics,
session_processor=session_processor,
@@ -206,6 +219,16 @@ class ApiDependencies:
)
ApiDependencies.invoker = Invoker(services)
configured_external_providers = {
provider_id
for provider_id, status in external_generation.get_provider_statuses().items()
if status.configured
}
sync_configured_external_starter_models(
configured_provider_ids=configured_external_providers,
model_manager=model_manager,
logger=logger,
)
db.clean()
@staticmethod

View File

@@ -1,21 +1,30 @@
import locale
from enum import Enum
from importlib.metadata import distributions
from pathlib import Path as FilePath
from threading import Lock
import torch
from fastapi import Body
import yaml
from fastapi import Body, HTTPException, Path
from fastapi.routing import APIRouter
from pydantic import BaseModel, Field
from invokeai.app.api.auth_dependencies import AdminUserOrDefault
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.services.config.config_default import (
EXTERNAL_PROVIDER_CONFIG_FIELDS,
DefaultInvokeAIAppConfig,
InvokeAIAppConfig,
get_config,
load_and_migrate_config,
load_external_api_keys,
)
from invokeai.app.services.external_generation.external_generation_common import ExternalProviderStatus
from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus
from invokeai.app.services.model_records.model_records_base import UnknownModelException
from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
from invokeai.backend.util.logging import logging
from invokeai.version import __version__
@@ -47,7 +56,7 @@ async def get_version() -> AppVersion:
async def get_app_deps() -> dict[str, str]:
deps: dict[str, str] = {dist.metadata["Name"]: dist.version for dist in distributions()}
try:
cuda = torch.version.cuda or "N/A"
cuda = getattr(getattr(torch, "version", None), "cuda", None) or "N/A" # pyright: ignore[reportAttributeAccessIssue]
except Exception:
cuda = "N/A"
@@ -70,6 +79,30 @@ class InvokeAIAppConfigWithSetFields(BaseModel):
config: InvokeAIAppConfig = Field(description="The InvokeAI App Config")
class ExternalProviderStatusModel(BaseModel):
provider_id: str = Field(description="The external provider identifier")
configured: bool = Field(description="Whether credentials are configured for the provider")
message: str | None = Field(default=None, description="Optional provider status detail")
class ExternalProviderConfigUpdate(BaseModel):
api_key: str | None = Field(default=None, description="API key for the external provider")
base_url: str | None = Field(default=None, description="Optional base URL override for the provider")
class ExternalProviderConfigModel(BaseModel):
provider_id: str = Field(description="The external provider identifier")
api_key_configured: bool = Field(description="Whether an API key is configured")
base_url: str | None = Field(default=None, description="Optional base URL override")
EXTERNAL_PROVIDER_FIELDS: dict[str, tuple[str, str]] = {
"gemini": ("external_gemini_api_key", "external_gemini_base_url"),
"openai": ("external_openai_api_key", "external_openai_base_url"),
}
_EXTERNAL_PROVIDER_CONFIG_LOCK = Lock()
class UpdateAppGenerationSettingsRequest(BaseModel):
"""Writable generation-related app settings."""
@@ -112,6 +145,166 @@ async def update_runtime_config(
return InvokeAIAppConfigWithSetFields(set_fields=config.model_fields_set, config=config)
@app_router.get(
"/external_providers/status",
operation_id="get_external_provider_statuses",
status_code=200,
response_model=list[ExternalProviderStatusModel],
)
async def get_external_provider_statuses() -> list[ExternalProviderStatusModel]:
statuses = ApiDependencies.invoker.services.external_generation.get_provider_statuses()
return [status_to_model(status) for status in statuses.values()]
@app_router.get(
"/external_providers/config",
operation_id="get_external_provider_configs",
status_code=200,
response_model=list[ExternalProviderConfigModel],
)
async def get_external_provider_configs() -> list[ExternalProviderConfigModel]:
config = get_config()
return [_build_external_provider_config(provider_id, config) for provider_id in EXTERNAL_PROVIDER_FIELDS]
@app_router.post(
"/external_providers/config/{provider_id}",
operation_id="set_external_provider_config",
status_code=200,
response_model=ExternalProviderConfigModel,
)
async def set_external_provider_config(
provider_id: str = Path(description="The external provider identifier"),
update: ExternalProviderConfigUpdate = Body(description="External provider configuration settings"),
) -> ExternalProviderConfigModel:
api_key_field, base_url_field = _get_external_provider_fields(provider_id)
updates: dict[str, str | None] = {}
if update.api_key is not None:
api_key = update.api_key.strip()
updates[api_key_field] = api_key or None
if update.base_url is not None:
base_url = update.base_url.strip()
updates[base_url_field] = base_url or None
if not updates:
raise HTTPException(status_code=400, detail="No external provider config fields provided")
api_key_removed = update.api_key is not None and updates.get(api_key_field) is None
_apply_external_provider_update(updates)
if api_key_removed:
_remove_external_models_for_provider(provider_id)
return _build_external_provider_config(provider_id, get_config())
@app_router.delete(
"/external_providers/config/{provider_id}",
operation_id="reset_external_provider_config",
status_code=200,
response_model=ExternalProviderConfigModel,
)
async def reset_external_provider_config(
provider_id: str = Path(description="The external provider identifier"),
) -> ExternalProviderConfigModel:
api_key_field, base_url_field = _get_external_provider_fields(provider_id)
_apply_external_provider_update({api_key_field: None, base_url_field: None})
_remove_external_models_for_provider(provider_id)
return _build_external_provider_config(provider_id, get_config())
def status_to_model(status: ExternalProviderStatus) -> ExternalProviderStatusModel:
return ExternalProviderStatusModel(
provider_id=status.provider_id,
configured=status.configured,
message=status.message,
)
def _get_external_provider_fields(provider_id: str) -> tuple[str, str]:
if provider_id not in EXTERNAL_PROVIDER_FIELDS:
raise HTTPException(status_code=404, detail=f"Unknown external provider '{provider_id}'")
return EXTERNAL_PROVIDER_FIELDS[provider_id]
def _write_external_api_keys_file(api_keys_file_path: FilePath, api_keys: dict[str, str]) -> None:
if not api_keys:
if api_keys_file_path.exists():
api_keys_file_path.unlink()
return
api_keys_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(api_keys_file_path, "w", encoding=locale.getpreferredencoding()) as api_keys_file:
yaml.safe_dump(api_keys, api_keys_file, sort_keys=False)
def _apply_external_provider_update(updates: dict[str, str | None]) -> None:
with _EXTERNAL_PROVIDER_CONFIG_LOCK:
runtime_config = get_config()
config_path = runtime_config.config_file_path
api_keys_file_path = runtime_config.api_keys_file_path
if config_path.exists():
file_config = load_and_migrate_config(config_path)
else:
file_config = DefaultInvokeAIAppConfig()
runtime_config.update_config(updates)
provider_config_fields = set(EXTERNAL_PROVIDER_CONFIG_FIELDS)
provider_updates = {field: value for field, value in updates.items() if field in provider_config_fields}
non_provider_updates = {field: value for field, value in updates.items() if field not in provider_config_fields}
if non_provider_updates:
file_config.update_config(non_provider_updates)
persisted_api_keys = load_external_api_keys(api_keys_file_path)
for field_name in EXTERNAL_PROVIDER_CONFIG_FIELDS:
file_value = getattr(file_config, field_name, None)
if field_name not in persisted_api_keys and isinstance(file_value, str) and file_value.strip():
persisted_api_keys[field_name] = file_value
for field_name, value in provider_updates.items():
if value is None:
persisted_api_keys.pop(field_name, None)
else:
persisted_api_keys[field_name] = value
_write_external_api_keys_file(api_keys_file_path, persisted_api_keys)
for field_name in EXTERNAL_PROVIDER_CONFIG_FIELDS:
setattr(file_config, field_name, None)
file_config_to_write = type(file_config).model_validate(
file_config.model_dump(exclude_unset=True, exclude_none=True)
)
file_config_to_write.write_file(config_path, as_example=False)
def _build_external_provider_config(provider_id: str, config: InvokeAIAppConfig) -> ExternalProviderConfigModel:
api_key_field, base_url_field = _get_external_provider_fields(provider_id)
return ExternalProviderConfigModel(
provider_id=provider_id,
api_key_configured=bool(getattr(config, api_key_field)),
base_url=getattr(config, base_url_field),
)
def _remove_external_models_for_provider(provider_id: str) -> None:
model_manager = ApiDependencies.invoker.services.model_manager
external_models = model_manager.store.search_by_attr(
base_model=BaseModelType.External,
model_type=ModelType.ExternalImageGenerator,
)
for model in external_models:
if getattr(model, "provider_id", None) != provider_id:
continue
try:
model_manager.install.delete(model.key)
except UnknownModelException:
logging.warning(f"External model key '{model.key}' was already removed while resetting '{provider_id}'")
except Exception as error:
logging.warning(f"Failed removing external model key '{model.key}' for '{provider_id}': {error}")
@app_router.get(
"/logging",
operation_id="get_log_level",

View File

@@ -30,6 +30,7 @@ from invokeai.app.services.model_records import (
)
from invokeai.app.services.orphaned_models import OrphanedModelInfo
from invokeai.app.util.suppress_output import SuppressOutput
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig
from invokeai.backend.model_manager.configs.factory import AnyModelConfig, ModelConfigFactory
from invokeai.backend.model_manager.configs.main import (
Main_Checkpoint_SD1_Config,
@@ -75,8 +76,36 @@ class CacheType(str, Enum):
def add_cover_image_to_model_config(config: AnyModelConfig, dependencies: Type[ApiDependencies]) -> AnyModelConfig:
"""Add a cover image URL to a model configuration."""
cover_image = dependencies.invoker.services.model_images.get_url(config.key)
config.cover_image = cover_image
return config
return config.model_copy(update={"cover_image": cover_image})
def apply_external_starter_model_overrides(config: AnyModelConfig) -> AnyModelConfig:
"""Overlay starter-model metadata onto installed external model configs."""
if not isinstance(config, ExternalApiModelConfig):
return config
starter_match = next((starter for starter in STARTER_MODELS if starter.source == config.source), None)
if starter_match is None:
return config
model_updates: dict[str, object] = {}
if starter_match.capabilities is not None:
model_updates["capabilities"] = starter_match.capabilities
if starter_match.default_settings is not None:
model_updates["default_settings"] = starter_match.default_settings
if starter_match.panel_schema is not None:
model_updates["panel_schema"] = starter_match.panel_schema
if not model_updates:
return config
return config.model_copy(update=model_updates)
def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type[ApiDependencies]) -> AnyModelConfig:
"""Apply API-only model config overlays before returning a response."""
config = apply_external_starter_model_overrides(config)
return add_cover_image_to_model_config(config, dependencies)
##############################################################################
@@ -145,8 +174,8 @@ async def list_model_records(
found_models.extend(
record_store.search_by_attr(model_type=model_type, model_name=model_name, model_format=model_format)
)
for model in found_models:
model = add_cover_image_to_model_config(model, ApiDependencies)
for index, model in enumerate(found_models):
found_models[index] = prepare_model_config_for_response(model, ApiDependencies)
return ModelsList(models=found_models)
@@ -166,6 +195,8 @@ async def list_missing_models() -> ModelsList:
missing_models: list[AnyModelConfig] = []
for model_config in record_store.all_models():
if model_config.base == BaseModelType.External or model_config.format == ModelFormat.ExternalApi:
continue
if not (models_path / model_config.path).resolve().exists():
missing_models.append(model_config)
@@ -190,7 +221,7 @@ async def get_model_records_by_attrs(
if not configs:
raise HTTPException(status_code=404, detail="No model found with these attributes")
return configs[0]
return prepare_model_config_for_response(configs[0], ApiDependencies)
@model_manager_router.get(
@@ -207,7 +238,7 @@ async def get_model_records_by_hash(
if not configs:
raise HTTPException(status_code=404, detail="No model found with this hash")
return configs[0]
return prepare_model_config_for_response(configs[0], ApiDependencies)
@model_manager_router.get(
@@ -228,7 +259,7 @@ async def get_model_record(
"""Get a model record"""
try:
config = ApiDependencies.invoker.services.model_manager.store.get_model(key)
return add_cover_image_to_model_config(config, ApiDependencies)
return prepare_model_config_for_response(config, ApiDependencies)
except UnknownModelException as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -268,7 +299,7 @@ async def reidentify_model(
result.config.name = config.name
result.config.description = config.description
result.config.cover_image = config.cover_image
if hasattr(config, "trigger_phrases") and hasattr(result.config, "trigger_phrases"):
if hasattr(result.config, "trigger_phrases") and hasattr(config, "trigger_phrases"):
result.config.trigger_phrases = config.trigger_phrases
result.config.source = config.source
result.config.source_type = config.source_type
@@ -392,7 +423,7 @@ async def update_model_record(
record_store = ApiDependencies.invoker.services.model_manager.store
try:
config = record_store.update_model(key, changes=changes, allow_class_change=True)
config = add_cover_image_to_model_config(config, ApiDependencies)
config = prepare_model_config_for_response(config, ApiDependencies)
logger.info(f"Updated model: {key}")
except UnknownModelException as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -1124,7 +1155,7 @@ async def convert_model(
# return the config record for the new diffusers directory
new_config = store.get_model(new_key)
new_config = add_cover_image_to_model_config(new_config, ApiDependencies)
new_config = prepare_model_config_for_response(new_config, ApiDependencies)
return new_config

View File

@@ -0,0 +1,281 @@
from typing import TYPE_CHECKING, Any, ClassVar, Literal
from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation
from invokeai.app.invocations.fields import (
FieldDescriptions,
ImageField,
InputField,
MetadataField,
WithBoard,
WithMetadata,
)
from invokeai.app.invocations.model import ModelIdentifierField
from invokeai.app.invocations.primitives import ImageCollectionOutput
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGenerationRequest,
ExternalGenerationResult,
ExternalReferenceImage,
)
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalGenerationMode
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType
if TYPE_CHECKING:
from invokeai.app.services.invocation_services import InvocationServices
class BaseExternalImageGenerationInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Generate images using an external provider."""
provider_id: ClassVar[str | None] = None
model: ModelIdentifierField = InputField(
description=FieldDescriptions.main_model,
ui_model_base=[BaseModelType.External],
ui_model_type=[ModelType.ExternalImageGenerator],
ui_model_format=[ModelFormat.ExternalApi],
)
mode: ExternalGenerationMode = InputField(
default="txt2img",
description="Generation mode. Not all modes are supported by every model; unsupported modes raise at runtime.",
)
prompt: str = InputField(description="Prompt")
seed: int | None = InputField(default=None, description=FieldDescriptions.seed)
num_images: int = InputField(default=1, gt=0, description="Number of images to generate")
width: int = InputField(default=1024, gt=0, description=FieldDescriptions.width)
height: int = InputField(default=1024, gt=0, description=FieldDescriptions.height)
image_size: str | None = InputField(default=None, description="Image size preset (e.g. 1K, 2K, 4K)")
init_image: ImageField | None = InputField(default=None, description="Init image for img2img/inpaint")
mask_image: ImageField | None = InputField(default=None, description="Mask image for inpaint")
reference_images: list[ImageField] = InputField(default=[], description="Reference images")
def _build_provider_options(self) -> dict[str, Any] | None:
"""Override in provider-specific subclasses to pass extra options."""
return None
def invoke(self, context: InvocationContext) -> ImageCollectionOutput:
model_config = context.models.get_config(self.model)
if not isinstance(model_config, ExternalApiModelConfig):
raise ValueError("Selected model is not an external API model")
if self.provider_id is not None and model_config.provider_id != self.provider_id:
raise ValueError(
f"Selected model provider '{model_config.provider_id}' does not match node provider '{self.provider_id}'"
)
init_image = None
if self.init_image is not None:
init_image = context.images.get_pil(self.init_image.image_name, mode="RGB")
mask_image = None
if self.mask_image is not None:
mask_image = context.images.get_pil(self.mask_image.image_name, mode="L")
reference_images: list[ExternalReferenceImage] = []
for image_field in self.reference_images:
reference_image = context.images.get_pil(image_field.image_name, mode="RGB")
reference_images.append(ExternalReferenceImage(image=reference_image))
request = ExternalGenerationRequest(
model=model_config,
mode=self.mode,
prompt=self.prompt,
seed=self.seed,
num_images=self.num_images,
width=self.width,
height=self.height,
image_size=self.image_size,
init_image=init_image,
mask_image=mask_image,
reference_images=reference_images,
metadata=self._build_request_metadata(),
provider_options=self._build_provider_options(),
)
result = context._services.external_generation.generate(request)
outputs: list[ImageField] = []
for generated in result.images:
metadata = self._build_output_metadata(model_config, result, generated.seed)
image_dto = context.images.save(image=generated.image, metadata=metadata)
outputs.append(ImageField(image_name=image_dto.image_name))
return ImageCollectionOutput(collection=outputs)
def invoke_internal(self, context: InvocationContext, services: "InvocationServices") -> BaseInvocationOutput:
"""Override default cache behavior so cache hits produce new gallery entries.
The standard invocation cache returns the cached output (with stale image_name
references) without re-running invoke(), which means no new images are saved
to the gallery on repeat invokes. For external API nodes — where the API call
is the expensive part — we want cache hits to skip the API call but still
produce fresh gallery entries by copying the cached images.
"""
if services.configuration.node_cache_size == 0 or not self.use_cache:
return super().invoke_internal(context, services)
key = services.invocation_cache.create_key(self)
cached_value = services.invocation_cache.get(key)
if cached_value is None:
services.logger.debug(f'Invocation cache miss for type "{self.get_type()}": {self.id}')
output = self.invoke(context)
services.invocation_cache.save(key, output)
return output
services.logger.debug(f'Invocation cache hit for type "{self.get_type()}": {self.id}, duplicating images')
if not isinstance(cached_value, ImageCollectionOutput):
return cached_value
outputs: list[ImageField] = []
for image_field in cached_value.collection:
cached_image = context.images.get_pil(image_field.image_name, mode="RGB")
image_dto = context.images.save(image=cached_image)
outputs.append(ImageField(image_name=image_dto.image_name))
return ImageCollectionOutput(collection=outputs)
def _build_request_metadata(self) -> dict[str, Any] | None:
if self.metadata is None:
return None
return self.metadata.root
def _build_output_metadata(
self,
model_config: ExternalApiModelConfig,
result: ExternalGenerationResult,
image_seed: int | None,
) -> MetadataField | None:
metadata: dict[str, Any] = {}
if self.metadata is not None:
metadata.update(self.metadata.root)
metadata.update(
{
"external_provider": model_config.provider_id,
"external_model_id": model_config.provider_model_id,
}
)
if self.image_size is not None:
metadata["image_size"] = self.image_size
provider_request_id = getattr(result, "provider_request_id", None)
if provider_request_id:
metadata["external_request_id"] = provider_request_id
provider_metadata = getattr(result, "provider_metadata", None)
if provider_metadata:
metadata["external_provider_metadata"] = provider_metadata
if image_seed is not None:
metadata["external_seed"] = image_seed
metadata.update(self._build_output_provider_metadata())
if not metadata:
return None
return MetadataField(root=metadata)
def _build_output_provider_metadata(self) -> dict[str, Any]:
"""Override in provider-specific subclasses to add recall-relevant fields to the image metadata."""
return {}
@invocation(
"openai_image_generation",
title="OpenAI Image Generation",
tags=["external", "generation", "openai"],
category="image",
version="1.0.0",
)
class OpenAIImageGenerationInvocation(BaseExternalImageGenerationInvocation):
"""Generate images using an OpenAI-hosted external model."""
provider_id = "openai"
model: ModelIdentifierField = InputField(
description=FieldDescriptions.main_model,
ui_model_base=[BaseModelType.External],
ui_model_type=[ModelType.ExternalImageGenerator],
ui_model_format=[ModelFormat.ExternalApi],
ui_model_provider_id=["openai"],
)
# OpenAI's API has no img2img/inpaint distinction — the edits endpoint is used
# automatically when reference images are provided. Hide mode and init_image
# (init_image is functionally identical to a reference image), and hide
# mask_image since no OpenAI model supports inpainting.
mode: ExternalGenerationMode = InputField(default="txt2img", description="Generation mode.", ui_hidden=True)
init_image: ImageField | None = InputField(
default=None, description="Init image (use reference_images instead)", ui_hidden=True
)
mask_image: ImageField | None = InputField(default=None, description="Mask image for inpaint", ui_hidden=True)
quality: Literal["auto", "high", "medium", "low"] = InputField(default="auto", description="Output image quality")
background: Literal["auto", "transparent", "opaque"] = InputField(
default="auto", description="Background transparency handling"
)
input_fidelity: Literal["low", "high"] | None = InputField(
default=None, description="Fidelity to source images (edits only)"
)
def _build_provider_options(self) -> dict[str, Any]:
options: dict[str, Any] = {
"quality": self.quality,
"background": self.background,
}
if self.input_fidelity is not None:
options["input_fidelity"] = self.input_fidelity
return options
def _build_output_provider_metadata(self) -> dict[str, Any]:
metadata: dict[str, Any] = {
"openai_quality": self.quality,
"openai_background": self.background,
}
if self.input_fidelity is not None:
metadata["openai_input_fidelity"] = self.input_fidelity
return metadata
@invocation(
"gemini_image_generation",
title="Gemini Image Generation",
tags=["external", "generation", "gemini"],
category="image",
version="1.0.0",
)
class GeminiImageGenerationInvocation(BaseExternalImageGenerationInvocation):
"""Generate images using a Gemini-hosted external model."""
provider_id = "gemini"
model: ModelIdentifierField = InputField(
description=FieldDescriptions.main_model,
ui_model_base=[BaseModelType.External],
ui_model_type=[ModelType.ExternalImageGenerator],
ui_model_format=[ModelFormat.ExternalApi],
ui_model_provider_id=["gemini"],
)
# Gemini only supports txt2img — hide mode/init_image/mask_image fields
# that are inherited from the base class but not usable with any Gemini model.
mode: ExternalGenerationMode = InputField(default="txt2img", description="Generation mode.", ui_hidden=True)
init_image: ImageField | None = InputField(
default=None, description="Init image for img2img/inpaint", ui_hidden=True
)
mask_image: ImageField | None = InputField(default=None, description="Mask image for inpaint", ui_hidden=True)
temperature: float | None = InputField(default=None, ge=0.0, le=2.0, description="Sampling temperature")
def _build_provider_options(self) -> dict[str, Any] | None:
options: dict[str, Any] = {}
if self.temperature is not None:
options["temperature"] = self.temperature
return options or None
def _build_output_provider_metadata(self) -> dict[str, Any]:
metadata: dict[str, Any] = {}
if self.temperature is not None:
metadata["gemini_temperature"] = self.temperature
return metadata

View File

@@ -455,6 +455,7 @@ class InputFieldJSONSchemaExtra(BaseModel):
ui_model_type: Optional[list[ModelType]] = None
ui_model_variant: Optional[list[ClipVariantType | ModelVariantType]] = None
ui_model_format: Optional[list[ModelFormat]] = None
ui_model_provider_id: Optional[list[str]] = None
model_config = ConfigDict(
validate_assignment=True,
@@ -636,6 +637,7 @@ def InputField(
ui_model_type: Optional[ModelType | list[ModelType]] = None,
ui_model_variant: Optional[ClipVariantType | ModelVariantType | list[ClipVariantType | ModelVariantType]] = None,
ui_model_format: Optional[ModelFormat | list[ModelFormat]] = None,
ui_model_provider_id: Optional[str | list[str]] = None,
) -> Any:
"""
Creates an input field for an invocation.
@@ -685,6 +687,11 @@ def InputField(
`ui_model_format=ModelFormat.Diffusers` will show only models in the diffusers format. This arg is only valid
if this Input field is annotated as a `ModelIdentifierField`.
ui_model_provider_id: Specifies the external provider id(s) to filter the model list by in the Workflow Editor.
For example, `ui_model_provider_id="openai"` will show only models registered under the OpenAI external provider.
This arg is only valid if this Input field is annotated as a `ModelIdentifierField` and the target models are
external API models.
ui_choice_labels: Specifies the labels to use for the choices in an enum field. If omitted, the enum values
will be used. This arg is only valid if the field is annotated with as a `Literal`. For example,
`Literal["choice1", "choice2", "choice3"]` with `ui_choice_labels={"choice1": "Choice 1", "choice2": "Choice 2",
@@ -724,6 +731,11 @@ def InputField(
json_schema_extra_.ui_model_format = ui_model_format
else:
json_schema_extra_.ui_model_format = [ui_model_format]
if ui_model_provider_id is not None:
if isinstance(ui_model_provider_id, list):
json_schema_extra_.ui_model_provider_id = ui_model_provider_id
else:
json_schema_extra_.ui_model_provider_id = [ui_model_provider_id]
if ui_type is not None:
json_schema_extra_.ui_type = ui_type

View File

@@ -22,6 +22,7 @@ from invokeai.backend.model_hash.model_hash import HASHING_ALGORITHMS
from invokeai.frontend.cli.arg_parser import InvokeAIArgs
INIT_FILE = Path("invokeai.yaml")
API_KEYS_FILE = Path("api_keys.yaml")
DB_FILE = Path("invokeai.db")
LEGACY_INIT_FILE = Path("invokeai.init")
PRECISION = Literal["auto", "float16", "bfloat16", "float32"]
@@ -30,6 +31,12 @@ ATTENTION_SLICE_SIZE = Literal["auto", "balanced", "max", 1, 2, 3, 4, 5, 6, 7, 8
LOG_FORMAT = Literal["plain", "color", "syslog", "legacy"]
LOG_LEVEL = Literal["debug", "info", "warning", "error", "critical"]
CONFIG_SCHEMA_VERSION = "4.0.2"
EXTERNAL_PROVIDER_CONFIG_FIELDS = (
"external_gemini_api_key",
"external_openai_api_key",
"external_gemini_base_url",
"external_openai_base_url",
)
class URLRegexTokenPair(BaseModel):
@@ -113,6 +120,10 @@ class InvokeAIAppConfig(BaseSettings):
allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.
multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.
strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.
external_gemini_api_key: API key for Gemini image generation.
external_openai_api_key: API key for OpenAI image generation.
external_gemini_base_url: Base URL override for Gemini image generation.
external_openai_base_url: Base URL override for OpenAI image generation.
"""
_root: Optional[Path] = PrivateAttr(default=None)
@@ -211,6 +222,16 @@ class InvokeAIAppConfig(BaseSettings):
multiuser: bool = Field(default=False, description="Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.")
strict_password_checking: bool = Field(default=False, description="Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.")
# EXTERNAL PROVIDERS
external_gemini_api_key: Optional[str] = Field(default=None, description="API key for Gemini image generation.")
external_openai_api_key: Optional[str] = Field(default=None, description="API key for OpenAI image generation.")
external_gemini_base_url: Optional[str] = Field(
default=None, description="Base URL override for Gemini image generation."
)
external_openai_base_url: Optional[str] = Field(
default=None, description="Base URL override for OpenAI image generation."
)
# fmt: on
model_config = SettingsConfigDict(env_prefix="INVOKEAI_", env_ignore_empty=True)
@@ -268,7 +289,7 @@ class InvokeAIAppConfig(BaseSettings):
file.write("# Internal metadata - do not edit:\n")
file.write(yaml.dump(meta_dict, sort_keys=False))
file.write("\n")
file.write("# Put user settings here - see https://invoke-ai.github.io/InvokeAI/configuration/:\n")
file.write("# Put user settings here - see https://invoke.ai/configuration/invokeai-yaml/:\n")
if len(config_dict) > 0:
file.write(yaml.dump(config_dict, sort_keys=False))
@@ -292,6 +313,13 @@ class InvokeAIAppConfig(BaseSettings):
assert resolved_path is not None
return resolved_path
@property
def api_keys_file_path(self) -> Path:
"""Path to api_keys.yaml, resolved to an absolute path.."""
resolved_path = self._resolve(API_KEYS_FILE)
assert resolved_path is not None
return resolved_path
@property
def outputs_path(self) -> Optional[Path]:
"""Path to the outputs directory, resolved to an absolute path.."""
@@ -504,6 +532,36 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig:
raise RuntimeError(f"Failed to load config file {config_path}: {e}") from e
def load_external_api_keys(api_keys_file_path: Path) -> dict[str, str]:
"""Load external provider config (API keys and base URLs) from a dedicated YAML file."""
if not api_keys_file_path.exists():
return {}
with open(api_keys_file_path, "rt", encoding=locale.getpreferredencoding()) as file:
loaded_api_keys: Any = yaml.safe_load(file)
if loaded_api_keys is None:
return {}
if not isinstance(loaded_api_keys, dict):
raise RuntimeError(f"Failed to load api keys file {api_keys_file_path}: expected a mapping")
parsed_api_keys: dict[str, str] = {}
for field_name in EXTERNAL_PROVIDER_CONFIG_FIELDS:
value = loaded_api_keys.get(field_name)
if value is None:
continue
if not isinstance(value, str):
raise RuntimeError(
f"Failed to load api keys file {api_keys_file_path}: value for '{field_name}' must be a string"
)
stripped_value = value.strip()
if stripped_value:
parsed_api_keys[field_name] = stripped_value
return parsed_api_keys
@lru_cache(maxsize=1)
def get_config() -> InvokeAIAppConfig:
"""Get the global singleton app config.
@@ -520,6 +578,7 @@ def get_config() -> InvokeAIAppConfig:
"""
# This object includes environment variables, as parsed by pydantic-settings
config = InvokeAIAppConfig()
env_fields_set = set(config.model_fields_set)
args = InvokeAIArgs.args
@@ -581,4 +640,11 @@ def get_config() -> InvokeAIAppConfig:
default_config = DefaultInvokeAIAppConfig()
default_config.write_file(config.config_file_path, as_example=False)
api_keys_from_file = load_external_api_keys(config.api_keys_file_path)
if api_keys_from_file:
# API keys file should take precedence over invokeai.yaml, but not over environment variables.
api_keys_to_apply = {key: value for key, value in api_keys_from_file.items() if key not in env_fields_set}
if api_keys_to_apply:
config.update_config(api_keys_to_apply, clobber=True)
return config

View File

@@ -0,0 +1,23 @@
from invokeai.app.services.external_generation.external_generation_base import (
ExternalGenerationServiceBase,
ExternalProvider,
)
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationRequest,
ExternalGenerationResult,
ExternalProviderStatus,
ExternalReferenceImage,
)
from invokeai.app.services.external_generation.external_generation_default import ExternalGenerationService
__all__ = [
"ExternalGenerationRequest",
"ExternalGenerationResult",
"ExternalGeneratedImage",
"ExternalGenerationService",
"ExternalGenerationServiceBase",
"ExternalProvider",
"ExternalProviderStatus",
"ExternalReferenceImage",
]

View File

@@ -0,0 +1,28 @@
class ExternalGenerationError(Exception):
"""Base error for external generation."""
class ExternalProviderNotFoundError(ExternalGenerationError):
"""Raised when no provider is registered for a model."""
class ExternalProviderNotConfiguredError(ExternalGenerationError):
"""Raised when a provider is missing required credentials."""
class ExternalProviderCapabilityError(ExternalGenerationError):
"""Raised when a request is not supported by provider capabilities."""
class ExternalProviderRequestError(ExternalGenerationError):
"""Raised when a provider rejects the request or returns an error."""
class ExternalProviderRateLimitError(ExternalProviderRequestError):
"""Raised when a provider returns HTTP 429 (rate limit exceeded)."""
retry_after: float | None
def __init__(self, message: str, retry_after: float | None = None) -> None:
super().__init__(message)
self.retry_after = retry_after

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from logging import Logger
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGenerationRequest,
ExternalGenerationResult,
ExternalProviderStatus,
)
class ExternalProvider(ABC):
provider_id: str
def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None:
self._app_config = app_config
self._logger = logger
@abstractmethod
def is_configured(self) -> bool:
raise NotImplementedError
@abstractmethod
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
raise NotImplementedError
def get_status(self) -> ExternalProviderStatus:
return ExternalProviderStatus(provider_id=self.provider_id, configured=self.is_configured())
class ExternalGenerationServiceBase(ABC):
@abstractmethod
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
raise NotImplementedError
@abstractmethod
def get_provider_statuses(self) -> dict[str, ExternalProviderStatus]:
raise NotImplementedError

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from PIL.Image import Image as PILImageType
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalGenerationMode
@dataclass(frozen=True)
class ExternalReferenceImage:
image: PILImageType
@dataclass(frozen=True)
class ExternalGenerationRequest:
model: ExternalApiModelConfig
mode: ExternalGenerationMode
prompt: str
seed: int | None
num_images: int
width: int
height: int
image_size: str | None
init_image: PILImageType | None
mask_image: PILImageType | None
reference_images: list[ExternalReferenceImage]
metadata: dict[str, Any] | None
provider_options: dict[str, Any] | None = None
@dataclass(frozen=True)
class ExternalGeneratedImage:
image: PILImageType
seed: int | None = None
@dataclass(frozen=True)
class ExternalGenerationResult:
images: list[ExternalGeneratedImage]
seed_used: int | None = None
provider_request_id: str | None = None
provider_metadata: dict[str, Any] | None = None
content_filters: dict[str, str] | None = None
@dataclass(frozen=True)
class ExternalProviderStatus:
provider_id: str
configured: bool
message: str | None = None

View File

@@ -0,0 +1,369 @@
from __future__ import annotations
import dataclasses
import time
from logging import Logger
from typing import TYPE_CHECKING
from PIL import Image
from PIL.Image import Image as PILImageType
from invokeai.app.services.external_generation.errors import (
ExternalProviderCapabilityError,
ExternalProviderNotConfiguredError,
ExternalProviderNotFoundError,
ExternalProviderRateLimitError,
)
from invokeai.app.services.external_generation.external_generation_base import (
ExternalGenerationServiceBase,
ExternalProvider,
)
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationRequest,
ExternalGenerationResult,
ExternalProviderStatus,
)
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig, ExternalImageSize
from invokeai.backend.model_manager.starter_models import STARTER_MODELS
if TYPE_CHECKING:
from invokeai.app.services.model_records import ModelRecordServiceBase
class ExternalGenerationService(ExternalGenerationServiceBase):
def __init__(
self,
providers: dict[str, ExternalProvider],
logger: Logger,
record_store: ModelRecordServiceBase | None = None,
) -> None:
self._providers = providers
self._logger = logger
self._record_store = record_store
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
provider = self._providers.get(request.model.provider_id)
if provider is None:
raise ExternalProviderNotFoundError(f"No external provider registered for '{request.model.provider_id}'")
if not provider.is_configured():
raise ExternalProviderNotConfiguredError(f"Provider '{request.model.provider_id}' is missing credentials")
request = self._refresh_model_capabilities(request)
resize_to_original_inpaint_size = _get_resize_target_for_inpaint(request)
request = self._bucket_request(request)
request = self._drop_unsupported_capabilities(request)
self._validate_request(request)
result = self._generate_with_retry(provider, request)
if resize_to_original_inpaint_size is None:
return result
width, height = resize_to_original_inpaint_size
return _resize_result_images(result, width, height)
_MAX_RETRIES = 3
_DEFAULT_RETRY_DELAY = 10.0
_MAX_RETRY_DELAY = 60.0
def _generate_with_retry(
self, provider: ExternalProvider, request: ExternalGenerationRequest
) -> ExternalGenerationResult:
for attempt in range(self._MAX_RETRIES):
try:
return provider.generate(request)
except ExternalProviderRateLimitError as exc:
if attempt == self._MAX_RETRIES - 1:
raise
delay = min(exc.retry_after or self._DEFAULT_RETRY_DELAY, self._MAX_RETRY_DELAY)
self._logger.warning(
"Rate limited by %s (attempt %d/%d), retrying in %.0fs",
request.model.provider_id,
attempt + 1,
self._MAX_RETRIES,
delay,
)
time.sleep(delay)
raise ExternalProviderRateLimitError("Rate limit exceeded after all retries")
def get_provider_statuses(self) -> dict[str, ExternalProviderStatus]:
return {provider_id: provider.get_status() for provider_id, provider in self._providers.items()}
def _validate_request(self, request: ExternalGenerationRequest) -> None:
capabilities = request.model.capabilities
self._logger.debug(
"Validating external request provider=%s model=%s mode=%s supported=%s",
request.model.provider_id,
request.model.provider_model_id,
request.mode,
capabilities.modes,
)
if request.mode not in capabilities.modes:
raise ExternalProviderCapabilityError(f"Mode '{request.mode}' is not supported by {request.model.name}")
if request.reference_images and not capabilities.supports_reference_images:
raise ExternalProviderCapabilityError(f"Reference images are not supported by {request.model.name}")
if capabilities.max_reference_images is not None:
if len(request.reference_images) > capabilities.max_reference_images:
raise ExternalProviderCapabilityError(
f"{request.model.name} supports at most {capabilities.max_reference_images} reference images"
)
if capabilities.max_images_per_request is not None and request.num_images > capabilities.max_images_per_request:
raise ExternalProviderCapabilityError(
f"{request.model.name} supports at most {capabilities.max_images_per_request} images per request"
)
if capabilities.max_image_size is not None:
if request.width > capabilities.max_image_size.width or request.height > capabilities.max_image_size.height:
raise ExternalProviderCapabilityError(
f"{request.model.name} supports a maximum size of {capabilities.max_image_size.width}x{capabilities.max_image_size.height}"
)
if capabilities.allowed_aspect_ratios:
aspect_ratio = _format_aspect_ratio(request.width, request.height)
if aspect_ratio not in capabilities.allowed_aspect_ratios:
size_ratio = None
if capabilities.aspect_ratio_sizes:
size_ratio = _ratio_for_size(request.width, request.height, capabilities.aspect_ratio_sizes)
if size_ratio is None or size_ratio not in capabilities.allowed_aspect_ratios:
ratio_label = size_ratio or aspect_ratio
raise ExternalProviderCapabilityError(
f"{request.model.name} does not support aspect ratio {ratio_label}"
)
required_modes = capabilities.input_image_required_for or ["img2img", "inpaint"]
if request.mode in required_modes and request.init_image is None:
raise ExternalProviderCapabilityError(
f"Mode '{request.mode}' requires an init image for {request.model.name}"
)
if request.mode == "inpaint" and request.mask_image is None:
raise ExternalProviderCapabilityError(
f"Mode '{request.mode}' requires a mask image for {request.model.name}"
)
def _drop_unsupported_capabilities(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest:
"""Silently drop request fields the selected model does not support so workflow-editor runs don't fail
when users wire them in regardless."""
capabilities = request.model.capabilities
updates: dict[str, object] = {}
if request.seed is not None and not capabilities.supports_seed:
self._logger.debug(
"Dropping seed for %s: model does not support seed control",
request.model.name,
)
updates["seed"] = None
if updates:
return dataclasses.replace(request, **updates)
return request
def _refresh_model_capabilities(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest:
if self._record_store is None:
return request
try:
record = self._record_store.get_model(request.model.key)
except Exception:
record = None
if not isinstance(record, ExternalApiModelConfig):
return request
if record.key != request.model.key:
return request
if record.provider_id != request.model.provider_id:
return request
if record.provider_model_id != request.model.provider_model_id:
return request
record = _apply_starter_overrides(record)
if record == request.model:
return request
return ExternalGenerationRequest(
model=record,
mode=request.mode,
prompt=request.prompt,
seed=request.seed,
num_images=request.num_images,
width=request.width,
height=request.height,
image_size=request.image_size,
init_image=request.init_image,
mask_image=request.mask_image,
reference_images=request.reference_images,
metadata=request.metadata,
provider_options=request.provider_options,
)
def _bucket_request(self, request: ExternalGenerationRequest) -> ExternalGenerationRequest:
capabilities = request.model.capabilities
if not capabilities.allowed_aspect_ratios:
return request
aspect_ratio = _format_aspect_ratio(request.width, request.height)
size = None
if capabilities.aspect_ratio_sizes:
size = capabilities.aspect_ratio_sizes.get(aspect_ratio)
if size is not None:
if request.width == size.width and request.height == size.height:
return request
return self._bucket_to_size(request, size.width, size.height, aspect_ratio)
if aspect_ratio in capabilities.allowed_aspect_ratios:
return request
if not capabilities.aspect_ratio_sizes:
return request
closest = _select_closest_ratio(
request.width,
request.height,
capabilities.allowed_aspect_ratios,
)
if closest is None:
return request
size = capabilities.aspect_ratio_sizes.get(closest)
if size is None:
return request
return self._bucket_to_size(request, size.width, size.height, closest)
def _bucket_to_size(
self,
request: ExternalGenerationRequest,
width: int,
height: int,
ratio: str,
) -> ExternalGenerationRequest:
self._logger.info(
"Bucketing external request provider=%s model=%s %sx%s -> %sx%s (ratio %s)",
request.model.provider_id,
request.model.provider_model_id,
request.width,
request.height,
width,
height,
ratio,
)
return ExternalGenerationRequest(
model=request.model,
mode=request.mode,
prompt=request.prompt,
seed=request.seed,
num_images=request.num_images,
width=width,
height=height,
image_size=request.image_size,
init_image=_resize_image(request.init_image, width, height, "RGB"),
mask_image=_resize_image(request.mask_image, width, height, "L"),
reference_images=request.reference_images,
metadata=request.metadata,
provider_options=request.provider_options,
)
def _format_aspect_ratio(width: int, height: int) -> str:
divisor = _gcd(width, height)
return f"{width // divisor}:{height // divisor}"
def _select_closest_ratio(width: int, height: int, ratios: list[str]) -> str | None:
ratio = width / height
parsed: list[tuple[str, float]] = []
for value in ratios:
parsed_ratio = _parse_ratio(value)
if parsed_ratio is not None:
parsed.append((value, parsed_ratio))
if not parsed:
return None
return min(parsed, key=lambda item: abs(item[1] - ratio))[0]
def _ratio_for_size(width: int, height: int, sizes: dict[str, ExternalImageSize]) -> str | None:
for ratio, size in sizes.items():
if size.width == width and size.height == height:
return ratio
return None
def _parse_ratio(value: str) -> float | None:
if ":" not in value:
return None
left, right = value.split(":", 1)
try:
numerator = float(left)
denominator = float(right)
except ValueError:
return None
if denominator == 0:
return None
return numerator / denominator
def _gcd(a: int, b: int) -> int:
while b:
a, b = b, a % b
return a
def _resize_image(image: PILImageType | None, width: int, height: int, mode: str) -> PILImageType | None:
if image is None:
return None
if image.width == width and image.height == height:
return image
return image.convert(mode).resize((width, height), Image.Resampling.LANCZOS)
def _get_resize_target_for_inpaint(request: ExternalGenerationRequest) -> tuple[int, int] | None:
if request.mode != "inpaint" or request.init_image is None:
return None
return request.init_image.width, request.init_image.height
def _resize_result_images(result: ExternalGenerationResult, width: int, height: int) -> ExternalGenerationResult:
resized_images = [
ExternalGeneratedImage(
image=generated.image
if generated.image.width == width and generated.image.height == height
else generated.image.resize((width, height), Image.Resampling.LANCZOS),
seed=generated.seed,
)
for generated in result.images
]
return ExternalGenerationResult(
images=resized_images,
seed_used=result.seed_used,
provider_request_id=result.provider_request_id,
provider_metadata=result.provider_metadata,
content_filters=result.content_filters,
)
def _apply_starter_overrides(model: ExternalApiModelConfig) -> ExternalApiModelConfig:
source = model.source or f"external://{model.provider_id}/{model.provider_model_id}"
starter_match = next((starter for starter in STARTER_MODELS if starter.source == source), None)
if starter_match is None:
return model
updates: dict[str, object] = {}
if starter_match.capabilities is not None:
updates["capabilities"] = starter_match.capabilities
if starter_match.default_settings is not None:
updates["default_settings"] = starter_match.default_settings
if not updates:
return model
return model.model_copy(update=updates)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
import base64
import io
from PIL import Image
from PIL.Image import Image as PILImageType
def encode_image_base64(image: PILImageType, format: str = "PNG") -> str:
buffer = io.BytesIO()
image.save(buffer, format=format)
return base64.b64encode(buffer.getvalue()).decode("ascii")
def decode_image_base64(encoded: str) -> PILImageType:
data = base64.b64decode(encoded)
image = Image.open(io.BytesIO(data))
return image.convert("RGB")

View File

@@ -0,0 +1,4 @@
from invokeai.app.services.external_generation.providers.gemini import GeminiProvider
from invokeai.app.services.external_generation.providers.openai import OpenAIProvider
__all__ = ["GeminiProvider", "OpenAIProvider"]

View File

@@ -0,0 +1,246 @@
from __future__ import annotations
import requests
from invokeai.app.services.external_generation.errors import (
ExternalProviderRateLimitError,
ExternalProviderRequestError,
)
from invokeai.app.services.external_generation.external_generation_base import ExternalProvider
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationRequest,
ExternalGenerationResult,
)
from invokeai.app.services.external_generation.image_utils import decode_image_base64, encode_image_base64
class GeminiProvider(ExternalProvider):
provider_id = "gemini"
_SYSTEM_INSTRUCTION = (
"You are an image generation model. Always respond with an image based on the user's prompt. "
"Do not return text-only responses. If the user input is not an edit instruction, "
"interpret it as a request to create a new image."
)
def is_configured(self) -> bool:
return bool(self._app_config.external_gemini_api_key)
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
api_key = self._app_config.external_gemini_api_key
if not api_key:
raise ExternalProviderRequestError("Gemini API key is not configured")
base_url = (self._app_config.external_gemini_base_url or "https://generativelanguage.googleapis.com").rstrip(
"/"
)
if not base_url.endswith("/v1") and not base_url.endswith("/v1beta"):
base_url = f"{base_url}/v1beta"
model_id = request.model.provider_model_id.removeprefix("models/")
endpoint = f"{base_url}/models/{model_id}:generateContent"
request_parts: list[dict[str, object]] = []
if request.init_image is not None:
request_parts.append(
{
"inlineData": {
"mimeType": "image/png",
"data": encode_image_base64(request.init_image),
}
}
)
request_parts.append({"text": request.prompt})
for reference in request.reference_images:
request_parts.append(
{
"inlineData": {
"mimeType": "image/png",
"data": encode_image_base64(reference.image),
}
}
)
opts = request.provider_options or {}
generation_config: dict[str, object] = {
"candidateCount": request.num_images,
"responseModalities": ["IMAGE"],
}
if "temperature" in opts:
generation_config["temperature"] = opts["temperature"]
aspect_ratio = _select_aspect_ratio(
request.width,
request.height,
request.model.capabilities.allowed_aspect_ratios,
)
uses_image_config = request.model.capabilities.resolution_presets is not None
if uses_image_config:
image_config: dict[str, str] = {}
if aspect_ratio is not None:
image_config["aspectRatio"] = aspect_ratio
if request.image_size is not None:
image_config["imageSize"] = request.image_size
if image_config:
generation_config["imageConfig"] = image_config
system_instruction = self._SYSTEM_INSTRUCTION
if request.init_image is not None:
system_instruction = (
f"{system_instruction} An input image is provided. "
"Treat the prompt as an edit instruction and modify the image accordingly. "
"Do not return the original image unchanged."
)
if not uses_image_config and aspect_ratio is not None:
system_instruction = f"{system_instruction} Use an aspect ratio of {aspect_ratio}."
payload: dict[str, object] = {
"systemInstruction": {"parts": [{"text": system_instruction}]},
"contents": [{"role": "user", "parts": request_parts}],
"generationConfig": generation_config,
}
response = requests.post(
endpoint,
params={"key": api_key},
json=payload,
timeout=120,
)
if not response.ok:
if response.status_code == 429:
retry_after = _parse_retry_after(response.headers.get("retry-after"))
raise ExternalProviderRateLimitError(
f"Gemini rate limit exceeded. {f'Retry after {retry_after:.0f}s.' if retry_after else 'Please try again later.'}",
retry_after=retry_after,
)
raise ExternalProviderRequestError(
f"Gemini request failed with status {response.status_code} for model '{model_id}': {response.text}"
)
data = response.json()
if not isinstance(data, dict):
raise ExternalProviderRequestError("Gemini response payload was not a JSON object")
images: list[ExternalGeneratedImage] = []
text_parts: list[str] = []
finish_messages: list[str] = []
candidates = data.get("candidates")
if not isinstance(candidates, list):
raise ExternalProviderRequestError("Gemini response payload missing candidates")
for candidate in candidates:
if not isinstance(candidate, dict):
continue
finish_message = candidate.get("finishMessage")
finish_reason = candidate.get("finishReason")
if isinstance(finish_message, str):
finish_messages.append(finish_message)
elif isinstance(finish_reason, str):
finish_messages.append(f"Finish reason: {finish_reason}")
for part in _iter_response_parts(candidate):
inline_data = part.get("inline_data") or part.get("inlineData")
if isinstance(inline_data, dict):
encoded = inline_data.get("data")
if encoded:
image = decode_image_base64(encoded)
images.append(ExternalGeneratedImage(image=image, seed=request.seed))
continue
file_data = part.get("fileData") or part.get("file_data")
if isinstance(file_data, dict):
file_uri = file_data.get("fileUri") or file_data.get("file_uri")
if isinstance(file_uri, str) and file_uri:
raise ExternalProviderRequestError(
f"Gemini returned fileUri instead of inline image data: {file_uri}"
)
text = part.get("text")
if isinstance(text, str):
text_parts.append(text)
if not images:
self._logger.error("Gemini response contained no images: %s", data)
detail = ""
if finish_messages:
combined = " ".join(message.strip() for message in finish_messages if message.strip())
if combined:
detail = f" Response status: {combined[:500]}"
elif text_parts:
combined = " ".join(text_parts).strip()
if combined:
detail = f" Response text: {combined[:500]}"
raise ExternalProviderRequestError(f"Gemini response contained no images.{detail}")
return ExternalGenerationResult(
images=images,
seed_used=request.seed,
provider_metadata={"model": request.model.provider_model_id},
)
def _iter_response_parts(candidate: dict[str, object]) -> list[dict[str, object]]:
content = candidate.get("content")
if isinstance(content, dict):
content_parts = content.get("parts")
if isinstance(content_parts, list):
return [part for part in content_parts if isinstance(part, dict)]
contents = candidate.get("contents")
if isinstance(contents, list):
parts: list[dict[str, object]] = []
for item in contents:
if not isinstance(item, dict):
continue
item_parts = item.get("parts")
if isinstance(item_parts, list):
parts.extend([part for part in item_parts if isinstance(part, dict)])
if parts:
return parts
return []
def _select_aspect_ratio(width: int, height: int, allowed: list[str] | None) -> str | None:
if width <= 0 or height <= 0:
return None
ratio = width / height
default_ratio = _format_aspect_ratio(width, height)
if not allowed:
return default_ratio
parsed = [(value, _parse_ratio(value)) for value in allowed]
filtered = [(value, parsed_ratio) for value, parsed_ratio in parsed if parsed_ratio is not None]
if not filtered:
return default_ratio
return min(filtered, key=lambda item: abs(item[1] - ratio))[0]
def _format_aspect_ratio(width: int, height: int) -> str | None:
if width <= 0 or height <= 0:
return None
divisor = _gcd(width, height)
return f"{width // divisor}:{height // divisor}"
def _parse_ratio(value: str) -> float | None:
if ":" not in value:
return None
left, right = value.split(":", 1)
try:
numerator = float(left)
denominator = float(right)
except ValueError:
return None
if denominator == 0:
return None
return numerator / denominator
def _parse_retry_after(value: str | None) -> float | None:
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _gcd(a: int, b: int) -> int:
while b:
a, b = b, a % b
return a

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
import io
import requests
from PIL.Image import Image as PILImageType
from invokeai.app.services.external_generation.errors import (
ExternalProviderRateLimitError,
ExternalProviderRequestError,
)
from invokeai.app.services.external_generation.external_generation_base import ExternalProvider
from invokeai.app.services.external_generation.external_generation_common import (
ExternalGeneratedImage,
ExternalGenerationRequest,
ExternalGenerationResult,
)
from invokeai.app.services.external_generation.image_utils import decode_image_base64
class OpenAIProvider(ExternalProvider):
provider_id = "openai"
_GPT_IMAGE_MODELS = {"gpt-image-1", "gpt-image-1.5", "gpt-image-1-mini"}
def is_configured(self) -> bool:
return bool(self._app_config.external_openai_api_key)
def generate(self, request: ExternalGenerationRequest) -> ExternalGenerationResult:
api_key = self._app_config.external_openai_api_key
if not api_key:
raise ExternalProviderRequestError("OpenAI API key is not configured")
model_id = request.model.provider_model_id
is_gpt_image = model_id in self._GPT_IMAGE_MODELS
size = f"{request.width}x{request.height}"
base_url = (self._app_config.external_openai_base_url or "https://api.openai.com").rstrip("/")
headers = {"Authorization": f"Bearer {api_key}"}
use_edits_endpoint = request.mode != "txt2img" or bool(request.reference_images)
opts = request.provider_options or {}
if not use_edits_endpoint:
payload: dict[str, object] = {
"model": model_id,
"prompt": request.prompt,
"n": request.num_images,
"size": size,
}
# GPT Image models use output_format; DALL-E uses response_format
if is_gpt_image:
payload["output_format"] = "png"
else:
payload["response_format"] = "b64_json"
if is_gpt_image:
if opts.get("quality") and opts["quality"] != "auto":
payload["quality"] = opts["quality"]
if opts.get("background") and opts["background"] != "auto":
payload["background"] = opts["background"]
response = requests.post(
f"{base_url}/v1/images/generations",
headers=headers,
json=payload,
timeout=120,
)
else:
images: list[PILImageType] = []
if request.init_image is not None:
images.append(request.init_image)
images.extend(reference.image for reference in request.reference_images)
if not images:
raise ExternalProviderRequestError(
"OpenAI image edits require at least one image (init image or reference image)"
)
files: list[tuple[str, tuple[str, io.BytesIO, str]]] = []
image_field_name = "image" if len(images) == 1 else "image[]"
for index, image in enumerate(images):
image_buffer = io.BytesIO()
image.save(image_buffer, format="PNG")
image_buffer.seek(0)
files.append((image_field_name, (f"image_{index}.png", image_buffer, "image/png")))
if request.mask_image is not None:
mask_buffer = io.BytesIO()
request.mask_image.save(mask_buffer, format="PNG")
mask_buffer.seek(0)
files.append(("mask", ("mask.png", mask_buffer, "image/png")))
data: dict[str, object] = {
"model": model_id,
"prompt": request.prompt,
"n": request.num_images,
"size": size,
}
if is_gpt_image:
data["output_format"] = "png"
else:
data["response_format"] = "b64_json"
if is_gpt_image:
if opts.get("quality") and opts["quality"] != "auto":
data["quality"] = opts["quality"]
if opts.get("background") and opts["background"] != "auto":
data["background"] = opts["background"]
if opts.get("input_fidelity"):
data["input_fidelity"] = opts["input_fidelity"]
response = requests.post(
f"{base_url}/v1/images/edits",
headers=headers,
data=data,
files=files,
timeout=120,
)
if not response.ok:
if response.status_code == 429:
retry_after = _parse_retry_after(response.headers.get("retry-after"))
raise ExternalProviderRateLimitError(
f"OpenAI rate limit exceeded. {f'Retry after {retry_after:.0f}s.' if retry_after else 'Please try again later.'}",
retry_after=retry_after,
)
raise ExternalProviderRequestError(
f"OpenAI request failed with status {response.status_code}: {response.text}"
)
response_payload = response.json()
if not isinstance(response_payload, dict):
raise ExternalProviderRequestError("OpenAI response payload was not a JSON object")
images: list[ExternalGeneratedImage] = []
data_items = response_payload.get("data")
if not isinstance(data_items, list):
raise ExternalProviderRequestError("OpenAI response payload missing image data")
for item in data_items:
if not isinstance(item, dict):
continue
encoded = item.get("b64_json")
if not encoded:
continue
images.append(ExternalGeneratedImage(image=decode_image_base64(encoded), seed=request.seed))
if not images:
raise ExternalProviderRequestError("OpenAI response contained no images")
return ExternalGenerationResult(
images=images,
seed_used=request.seed,
provider_request_id=response.headers.get("x-request-id"),
provider_metadata={"model": model_id},
)
def _parse_retry_after(value: str | None) -> float | None:
if not value:
return None
try:
return float(value)
except ValueError:
return None

View File

@@ -0,0 +1,59 @@
from logging import Logger
from typing import TYPE_CHECKING
from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig
from invokeai.backend.model_manager.starter_models import STARTER_MODELS
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType
if TYPE_CHECKING:
from invokeai.app.services.model_manager.model_manager_base import ModelManagerServiceBase
def sync_configured_external_starter_models(
configured_provider_ids: set[str],
model_manager: "ModelManagerServiceBase",
logger: Logger,
) -> list[str]:
"""Queue missing external starter models for configured providers."""
if not configured_provider_ids:
return []
installed_sources = {
model.source
for model in model_manager.store.search_by_attr(
base_model=BaseModelType.External,
model_type=ModelType.ExternalImageGenerator,
)
if isinstance(model, ExternalApiModelConfig) and model.source
}
queued_sources: list[str] = []
for starter_model in STARTER_MODELS:
if not starter_model.source.startswith("external://"):
continue
provider_id = starter_model.source.removeprefix("external://").split("/", 1)[0]
if provider_id not in configured_provider_ids:
continue
if starter_model.source in installed_sources:
continue
model_manager.install.heuristic_import(
starter_model.source,
config=ModelRecordChanges(
name=starter_model.name,
base=starter_model.base,
type=starter_model.type,
description=starter_model.description,
format=starter_model.format,
capabilities=starter_model.capabilities,
default_settings=starter_model.default_settings,
),
)
queued_sources.append(starter_model.source)
logger.info("Queued external starter model sync for %s", starter_model.source)
return queued_sources

View File

@@ -21,6 +21,7 @@ if TYPE_CHECKING:
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.app.services.download import DownloadQueueServiceBase
from invokeai.app.services.events.events_base import EventServiceBase
from invokeai.app.services.external_generation.external_generation_base import ExternalGenerationServiceBase
from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase
from invokeai.app.services.images.images_base import ImageServiceABC
@@ -63,6 +64,7 @@ class InvocationServices:
model_relationships: "ModelRelationshipsServiceABC",
model_relationship_records: "ModelRelationshipRecordStorageBase",
download_queue: "DownloadQueueServiceBase",
external_generation: "ExternalGenerationServiceBase",
performance_statistics: "InvocationStatsServiceBase",
session_queue: "SessionQueueBase",
session_processor: "SessionProcessorBase",
@@ -94,6 +96,7 @@ class InvocationServices:
self.model_relationships = model_relationships
self.model_relationship_records = model_relationship_records
self.download_queue = download_queue
self.external_generation = external_generation
self.performance_statistics = performance_statistics
self.session_queue = session_queue
self.session_processor = session_processor

View File

@@ -139,12 +139,27 @@ class URLModelSource(StringLikeSource):
return str(self.url)
ModelSource = Annotated[Union[LocalModelSource, HFModelSource, URLModelSource], Field(discriminator="type")]
class ExternalModelSource(StringLikeSource):
"""An external provider model identifier."""
provider_id: str
provider_model_id: str
type: Literal["external"] = "external"
def __str__(self) -> str:
return f"external://{self.provider_id}/{self.provider_model_id}"
ModelSource = Annotated[
Union[LocalModelSource, HFModelSource, URLModelSource, ExternalModelSource],
Field(discriminator="type"),
]
MODEL_SOURCE_TO_TYPE_MAP = {
URLModelSource: ModelSourceType.Url,
HFModelSource: ModelSourceType.HFRepoID,
LocalModelSource: ModelSourceType.Path,
ExternalModelSource: ModelSourceType.External,
}

View File

@@ -28,6 +28,7 @@ from invokeai.app.services.invoker import Invoker
from invokeai.app.services.model_install.model_install_base import ModelInstallServiceBase
from invokeai.app.services.model_install.model_install_common import (
MODEL_SOURCE_TO_TYPE_MAP,
ExternalModelSource,
HFModelSource,
InstallStatus,
InvalidModelConfigException,
@@ -37,10 +38,15 @@ from invokeai.app.services.model_install.model_install_common import (
StringLikeSource,
URLModelSource,
)
from invokeai.app.services.model_records import DuplicateModelException, ModelRecordServiceBase
from invokeai.app.services.model_records import DuplicateModelException, ModelRecordServiceBase, UnknownModelException
from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
from invokeai.app.util.misc import get_iso_timestamp
from invokeai.backend.model_manager.configs.base import Checkpoint_Config_Base
from invokeai.backend.model_manager.configs.external_api import (
ExternalApiModelConfig,
ExternalApiModelDefaultSettings,
ExternalModelCapabilities,
)
from invokeai.backend.model_manager.configs.factory import (
AnyModelConfig,
ModelConfigFactory,
@@ -55,7 +61,13 @@ from invokeai.backend.model_manager.metadata import (
)
from invokeai.backend.model_manager.metadata.metadata_base import HuggingFaceMetadata
from invokeai.backend.model_manager.search import ModelSearch
from invokeai.backend.model_manager.taxonomy import ModelRepoVariant, ModelSourceType
from invokeai.backend.model_manager.taxonomy import (
BaseModelType,
ModelFormat,
ModelRepoVariant,
ModelSourceType,
ModelType,
)
from invokeai.backend.model_manager.util.lora_metadata_extractor import apply_lora_metadata
from invokeai.backend.util import InvokeAILogger
from invokeai.backend.util.catch_sigint import catch_sigint
@@ -459,6 +471,9 @@ class ModelInstallService(ModelInstallServiceBase):
install_job = self._import_from_hf(source, config)
elif isinstance(source, URLModelSource):
install_job = self._import_from_url(source, config)
elif isinstance(source, ExternalModelSource):
install_job = self._import_external_model(source, config)
self._put_in_queue(install_job)
else:
raise ValueError(f"Unsupported model source: '{type(source)}'")
@@ -758,7 +773,13 @@ class ModelInstallService(ModelInstallServiceBase):
source_obj: Optional[StringLikeSource] = None
source_stripped = source.strip('"')
if Path(source_stripped).exists(): # A local file or directory
if source_stripped.startswith("external://"):
external_id = source_stripped.removeprefix("external://")
provider_id, _, provider_model_id = external_id.partition("/")
if not provider_id or not provider_model_id:
raise ValueError(f"Invalid external model source: '{source_stripped}'")
source_obj = ExternalModelSource(provider_id=provider_id, provider_model_id=provider_model_id)
elif Path(source_stripped).exists(): # A local file or directory
source_obj = LocalModelSource(path=Path(source_stripped))
elif match := re.match(hf_repoid_re, source):
source_obj = HFModelSource(
@@ -850,6 +871,9 @@ class ModelInstallService(ModelInstallServiceBase):
self._logger.info(f"Installer thread {threading.get_ident()} exiting")
def _register_or_install(self, job: ModelInstallJob) -> None:
if isinstance(job.source, ExternalModelSource):
self._register_external_model(job)
return
# local jobs will be in waiting state, remote jobs will be downloading state
job.total_bytes = self._stat_size(job.local_path)
job.bytes = job.total_bytes
@@ -870,6 +894,71 @@ class ModelInstallService(ModelInstallServiceBase):
job.config_out = self.record_store.get_model(key)
self._signal_job_completed(job)
def _register_external_model(self, job: ModelInstallJob) -> None:
job.total_bytes = 0
job.bytes = 0
self._signal_job_running(job)
job.config_in.source = str(job.source)
job.config_in.source_type = MODEL_SOURCE_TO_TYPE_MAP[job.source.__class__]
provider_id = job.source.provider_id
provider_model_id = job.source.provider_model_id
capabilities = job.config_in.capabilities or ExternalModelCapabilities()
default_settings = (
job.config_in.default_settings
if isinstance(job.config_in.default_settings, ExternalApiModelDefaultSettings)
else None
)
name = job.config_in.name or f"{provider_id} {provider_model_id}"
key = job.config_in.key or slugify(f"{provider_id}-{provider_model_id}")
existing_external = next(
(
model
for model in self.record_store.search_by_attr(
base_model=BaseModelType.External, model_type=ModelType.ExternalImageGenerator
)
if isinstance(model, ExternalApiModelConfig)
and model.provider_id == provider_id
and model.provider_model_id == provider_model_id
),
None,
)
if existing_external is not None:
key = existing_external.key
else:
try:
self.record_store.get_model(key)
raise DuplicateModelException(
f"Model key '{key}' already exists. Provide a different key to install this external model."
)
except UnknownModelException:
pass
config = ExternalApiModelConfig(
key=key,
name=name,
description=job.config_in.description,
provider_id=provider_id,
provider_model_id=provider_model_id,
capabilities=capabilities,
default_settings=default_settings,
source=str(job.source),
source_type=MODEL_SOURCE_TO_TYPE_MAP[job.source.__class__],
path="",
hash="",
file_size=0,
)
if existing_external is not None:
self.record_store.replace_model(existing_external.key, config)
else:
self.record_store.add_model(config)
job.config_out = self.record_store.get_model(config.key)
self._signal_job_completed(job)
def _set_error(self, install_job: ModelInstallJob, excp: Exception) -> None:
multifile_download_job = install_job._multifile_job
if multifile_download_job and any(
@@ -905,6 +994,8 @@ class ModelInstallService(ModelInstallServiceBase):
"""Scan the models directory for missing models and return a list of them."""
missing_models: list[AnyModelConfig] = []
for model_config in self.record_store.all_models():
if model_config.base == BaseModelType.External or model_config.format == ModelFormat.ExternalApi:
continue
if not (self.app_config.models_path / model_config.path).resolve().exists():
missing_models.append(model_config)
return missing_models
@@ -1046,6 +1137,19 @@ class ModelInstallService(ModelInstallServiceBase):
remote_files=remote_files,
)
def _import_external_model(
self,
source: ExternalModelSource,
config: Optional[ModelRecordChanges] = None,
) -> ModelInstallJob:
return ModelInstallJob(
id=self._next_id(),
source=source,
config_in=config or ModelRecordChanges(),
local_path=self._app_config.models_path,
inplace=True,
)
def _import_remote_model(
self,
source: HFModelSource | URLModelSource,

View File

@@ -13,6 +13,10 @@ from pydantic import BaseModel, Field
from invokeai.app.services.shared.pagination import PaginatedResults
from invokeai.app.util.model_exclude_null import BaseModelExcludeNull
from invokeai.backend.model_manager.configs.controlnet import ControlAdapterDefaultSettings
from invokeai.backend.model_manager.configs.external_api import (
ExternalApiModelDefaultSettings,
ExternalModelCapabilities,
)
from invokeai.backend.model_manager.configs.factory import AnyModelConfig
from invokeai.backend.model_manager.configs.lora import LoraModelDefaultSettings
from invokeai.backend.model_manager.configs.main import MainModelDefaultSettings
@@ -87,8 +91,19 @@ class ModelRecordChanges(BaseModelExcludeNull):
file_size: Optional[int] = Field(description="Size of model file", default=None)
format: Optional[str] = Field(description="format of model file", default=None)
trigger_phrases: Optional[set[str]] = Field(description="Set of trigger phrases for this model", default=None)
default_settings: Optional[MainModelDefaultSettings | LoraModelDefaultSettings | ControlAdapterDefaultSettings] = (
Field(description="Default settings for this model", default=None)
default_settings: Optional[
MainModelDefaultSettings
| LoraModelDefaultSettings
| ControlAdapterDefaultSettings
| ExternalApiModelDefaultSettings
] = Field(description="Default settings for this model", default=None)
# External API model changes
provider_id: Optional[str] = Field(description="External provider identifier", default=None)
provider_model_id: Optional[str] = Field(description="External provider model identifier", default=None)
capabilities: Optional[ExternalModelCapabilities] = Field(
description="External model capabilities",
default=None,
)
cpu_only: Optional[bool] = Field(description="Whether this model should run on CPU only", default=None)

View File

@@ -388,6 +388,8 @@ class ModelsInterface(InvocationContextInterface):
submodel_type = submodel_type or identifier.submodel_type
model = self._services.model_manager.store.get_model(identifier.key)
self._raise_if_external(model)
message = f"Loading model {model.name}"
if submodel_type:
message += f" ({submodel_type.value})"
@@ -417,12 +419,18 @@ class ModelsInterface(InvocationContextInterface):
if len(configs) > 1:
raise ValueError(f"More than one model found with name {name}, base {base}, and type {type}")
self._raise_if_external(configs[0])
message = f"Loading model {name}"
if submodel_type:
message += f" ({submodel_type.value})"
self._util.signal_progress(message)
return self._services.model_manager.load.load_model(configs[0], submodel_type)
@staticmethod
def _raise_if_external(model: AnyModelConfig) -> None:
if model.base == BaseModelType.External or model.format == ModelFormat.ExternalApi:
raise ValueError("External API models cannot be loaded from disk")
def get_config(self, identifier: Union[str, "ModelIdentifierField"]) -> AnyModelConfig:
"""Get a model's config.

View File

@@ -14,43 +14,61 @@ def _get_processor_invocation_class(processor_type: str):
"""Get the invocation class for a processor type."""
# Import processor invocation classes on demand
processor_class_map = {
"canny_image_processor": lambda: __import__(
"invokeai.app.invocations.canny", fromlist=["CannyEdgeDetectionInvocation"]
).CannyEdgeDetectionInvocation,
"hed_image_processor": lambda: __import__(
"invokeai.app.invocations.hed", fromlist=["HEDEdgeDetectionInvocation"]
).HEDEdgeDetectionInvocation,
"mlsd_image_processor": lambda: __import__(
"invokeai.app.invocations.mlsd", fromlist=["MLSDDetectionInvocation"]
).MLSDDetectionInvocation,
"depth_anything_image_processor": lambda: __import__(
"invokeai.app.invocations.depth_anything", fromlist=["DepthAnythingDepthEstimationInvocation"]
).DepthAnythingDepthEstimationInvocation,
"normalbae_image_processor": lambda: __import__(
"invokeai.app.invocations.normal_bae", fromlist=["NormalMapInvocation"]
).NormalMapInvocation,
"pidi_image_processor": lambda: __import__(
"invokeai.app.invocations.pidi", fromlist=["PiDiNetEdgeDetectionInvocation"]
).PiDiNetEdgeDetectionInvocation,
"lineart_image_processor": lambda: __import__(
"invokeai.app.invocations.lineart", fromlist=["LineartEdgeDetectionInvocation"]
).LineartEdgeDetectionInvocation,
"lineart_anime_image_processor": lambda: __import__(
"invokeai.app.invocations.lineart_anime", fromlist=["LineartAnimeEdgeDetectionInvocation"]
).LineartAnimeEdgeDetectionInvocation,
"content_shuffle_image_processor": lambda: __import__(
"invokeai.app.invocations.content_shuffle", fromlist=["ContentShuffleInvocation"]
).ContentShuffleInvocation,
"dw_openpose_image_processor": lambda: __import__(
"invokeai.app.invocations.dw_openpose", fromlist=["DWOpenposeDetectionInvocation"]
).DWOpenposeDetectionInvocation,
"mediapipe_face_processor": lambda: __import__(
"invokeai.app.invocations.mediapipe_face", fromlist=["MediaPipeFaceDetectionInvocation"]
).MediaPipeFaceDetectionInvocation,
"canny_image_processor": lambda: (
__import__(
"invokeai.app.invocations.canny", fromlist=["CannyEdgeDetectionInvocation"]
).CannyEdgeDetectionInvocation
),
"hed_image_processor": lambda: (
__import__(
"invokeai.app.invocations.hed", fromlist=["HEDEdgeDetectionInvocation"]
).HEDEdgeDetectionInvocation
),
"mlsd_image_processor": lambda: (
__import__("invokeai.app.invocations.mlsd", fromlist=["MLSDDetectionInvocation"]).MLSDDetectionInvocation
),
"depth_anything_image_processor": lambda: (
__import__(
"invokeai.app.invocations.depth_anything", fromlist=["DepthAnythingDepthEstimationInvocation"]
).DepthAnythingDepthEstimationInvocation
),
"normalbae_image_processor": lambda: (
__import__("invokeai.app.invocations.normal_bae", fromlist=["NormalMapInvocation"]).NormalMapInvocation
),
"pidi_image_processor": lambda: (
__import__(
"invokeai.app.invocations.pidi", fromlist=["PiDiNetEdgeDetectionInvocation"]
).PiDiNetEdgeDetectionInvocation
),
"lineart_image_processor": lambda: (
__import__(
"invokeai.app.invocations.lineart", fromlist=["LineartEdgeDetectionInvocation"]
).LineartEdgeDetectionInvocation
),
"lineart_anime_image_processor": lambda: (
__import__(
"invokeai.app.invocations.lineart_anime", fromlist=["LineartAnimeEdgeDetectionInvocation"]
).LineartAnimeEdgeDetectionInvocation
),
"content_shuffle_image_processor": lambda: (
__import__(
"invokeai.app.invocations.content_shuffle", fromlist=["ContentShuffleInvocation"]
).ContentShuffleInvocation
),
"dw_openpose_image_processor": lambda: (
__import__(
"invokeai.app.invocations.dw_openpose", fromlist=["DWOpenposeDetectionInvocation"]
).DWOpenposeDetectionInvocation
),
"mediapipe_face_processor": lambda: (
__import__(
"invokeai.app.invocations.mediapipe_face", fromlist=["MediaPipeFaceDetectionInvocation"]
).MediaPipeFaceDetectionInvocation
),
# Note: zoe_depth_image_processor doesn't have a processor invocation implementation
"color_map_image_processor": lambda: __import__(
"invokeai.app.invocations.color_map", fromlist=["ColorMapInvocation"]
).ColorMapInvocation,
"color_map_image_processor": lambda: (
__import__("invokeai.app.invocations.color_map", fromlist=["ColorMapInvocation"]).ColorMapInvocation
),
}
if processor_type in processor_class_map:

View File

@@ -0,0 +1,113 @@
from typing import Literal, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
from invokeai.backend.model_manager.configs.base import Config_Base
from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
from invokeai.backend.model_manager.model_on_disk import ModelOnDisk
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelSourceType, ModelType
ExternalGenerationMode = Literal["txt2img", "img2img", "inpaint"]
ExternalMaskFormat = Literal["alpha", "binary", "none"]
ExternalPanelControlName = Literal["reference_images", "dimensions", "seed"]
class ExternalImageSize(BaseModel):
width: int = Field(gt=0)
height: int = Field(gt=0)
model_config = ConfigDict(extra="forbid")
class ExternalResolutionPreset(BaseModel):
label: str = Field(min_length=1, description="Display label, e.g. '1:1 (1K)'")
aspect_ratio: str = Field(min_length=1, description="Aspect ratio string, e.g. '1:1'")
image_size: str = Field(min_length=1, description="Image size preset, e.g. '1K'")
width: int = Field(gt=0)
height: int = Field(gt=0)
model_config = ConfigDict(extra="forbid")
class ExternalModelCapabilities(BaseModel):
modes: list[ExternalGenerationMode] = Field(default_factory=lambda: ["txt2img"])
supports_reference_images: bool = Field(default=False)
supports_negative_prompt: bool = Field(default=True)
supports_seed: bool = Field(default=False)
supports_guidance: bool = Field(default=False)
supports_steps: bool = Field(default=False)
max_images_per_request: int | None = Field(default=None, gt=0)
max_image_size: ExternalImageSize | None = Field(default=None)
allowed_aspect_ratios: list[str] | None = Field(default=None)
aspect_ratio_sizes: dict[str, ExternalImageSize] | None = Field(default=None)
resolution_presets: list[ExternalResolutionPreset] | None = Field(default=None)
max_reference_images: int | None = Field(default=None, gt=0)
mask_format: ExternalMaskFormat = Field(default="none")
input_image_required_for: list[ExternalGenerationMode] | None = Field(default=None)
model_config = ConfigDict(extra="forbid")
class ExternalApiModelDefaultSettings(BaseModel):
width: int | None = Field(default=None, gt=0)
height: int | None = Field(default=None, gt=0)
num_images: int | None = Field(default=None, gt=0)
model_config = ConfigDict(extra="forbid")
class ExternalModelPanelControl(BaseModel):
name: ExternalPanelControlName
slider_min: float | None = Field(default=None)
slider_max: float | None = Field(default=None)
number_input_min: float | None = Field(default=None)
number_input_max: float | None = Field(default=None)
fine_step: float | None = Field(default=None)
coarse_step: float | None = Field(default=None)
marks: list[float] | None = Field(default=None)
model_config = ConfigDict(extra="forbid")
class ExternalModelPanelSchema(BaseModel):
prompts: list[ExternalModelPanelControl] = Field(default_factory=list)
image: list[ExternalModelPanelControl] = Field(default_factory=list)
generation: list[ExternalModelPanelControl] = Field(default_factory=list)
model_config = ConfigDict(extra="forbid")
class ExternalApiModelConfig(Config_Base):
base: Literal[BaseModelType.External] = Field(default=BaseModelType.External)
type: Literal[ModelType.ExternalImageGenerator] = Field(default=ModelType.ExternalImageGenerator)
format: Literal[ModelFormat.ExternalApi] = Field(default=ModelFormat.ExternalApi)
provider_id: str = Field(min_length=1, description="External provider ID")
provider_model_id: str = Field(min_length=1, description="Provider-specific model ID")
capabilities: ExternalModelCapabilities = Field(description="Provider capability matrix")
default_settings: ExternalApiModelDefaultSettings | None = Field(default=None)
panel_schema: ExternalModelPanelSchema | None = Field(default=None)
tags: list[str] | None = Field(default=None)
is_default: bool = Field(default=False)
source_type: ModelSourceType = Field(default=ModelSourceType.External)
path: str = Field(default="")
source: str = Field(default="")
hash: str = Field(default="")
file_size: int = Field(default=0, ge=0)
model_config = ConfigDict(extra="forbid")
@model_validator(mode="after")
def _populate_external_fields(self) -> "ExternalApiModelConfig":
if not self.path:
self.path = f"external://{self.provider_id}/{self.provider_model_id}"
if not self.source:
self.source = self.path
if not self.hash:
self.hash = f"external:{self.provider_id}:{self.provider_model_id}"
return self
@classmethod
def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, object]) -> Self:
raise NotAMatchError("external API models are not probed from disk")

View File

@@ -26,6 +26,7 @@ from invokeai.backend.model_manager.configs.controlnet import (
ControlNet_Diffusers_SD2_Config,
ControlNet_Diffusers_SDXL_Config,
)
from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig
from invokeai.backend.model_manager.configs.flux_redux import FLUXRedux_Checkpoint_Config
from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError
from invokeai.backend.model_manager.configs.ip_adapter import (
@@ -268,6 +269,7 @@ AnyModelConfig = Annotated[
Annotated[SigLIP_Diffusers_Config, SigLIP_Diffusers_Config.get_tag()],
Annotated[FLUXRedux_Checkpoint_Config, FLUXRedux_Checkpoint_Config.get_tag()],
Annotated[LlavaOnevision_Diffusers_Config, LlavaOnevision_Diffusers_Config.get_tag()],
Annotated[ExternalApiModelConfig, ExternalApiModelConfig.get_tag()],
# Unknown model (fallback)
Annotated[Unknown_Config, Unknown_Config.get_tag()],
],

View File

@@ -7,12 +7,25 @@ from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.custo
from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.custom_modules.utils import (
add_nullable_tensors,
)
from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor
class CustomConv2d(torch.nn.Conv2d, CustomModuleMixin):
def _cast_tensor_for_input(self, tensor: torch.Tensor | None, input: torch.Tensor) -> torch.Tensor | None:
tensor = cast_to_device(tensor, input.device)
if (
tensor is not None
and input.is_floating_point()
and tensor.is_floating_point()
and not isinstance(tensor, GGMLTensor)
and tensor.dtype != input.dtype
):
tensor = tensor.to(dtype=input.dtype)
return tensor
def _autocast_forward_with_patches(self, input: torch.Tensor) -> torch.Tensor:
weight = cast_to_device(self.weight, input.device)
bias = cast_to_device(self.bias, input.device)
weight = self._cast_tensor_for_input(self.weight, input)
bias = self._cast_tensor_for_input(self.bias, input)
# Prepare the original parameters for the patch aggregation.
orig_params = {"weight": weight, "bias": bias}
@@ -25,13 +38,15 @@ class CustomConv2d(torch.nn.Conv2d, CustomModuleMixin):
device=input.device,
)
weight = add_nullable_tensors(weight, aggregated_param_residuals.get("weight", None))
bias = add_nullable_tensors(bias, aggregated_param_residuals.get("bias", None))
residual_weight = self._cast_tensor_for_input(aggregated_param_residuals.get("weight", None), input)
residual_bias = self._cast_tensor_for_input(aggregated_param_residuals.get("bias", None), input)
weight = add_nullable_tensors(weight, residual_weight)
bias = add_nullable_tensors(bias, residual_bias)
return self._conv_forward(input, weight, bias)
def _autocast_forward(self, input: torch.Tensor) -> torch.Tensor:
weight = cast_to_device(self.weight, input.device)
bias = cast_to_device(self.bias, input.device)
weight = self._cast_tensor_for_input(self.weight, input)
bias = self._cast_tensor_for_input(self.bias, input)
return self._conv_forward(input, weight, bias)
def forward(self, input: torch.Tensor) -> torch.Tensor:
@@ -39,5 +54,21 @@ class CustomConv2d(torch.nn.Conv2d, CustomModuleMixin):
return self._autocast_forward_with_patches(input)
elif self._device_autocasting_enabled:
return self._autocast_forward(input)
elif input.is_floating_point() and (
(
self.weight.is_floating_point()
and not isinstance(self.weight, GGMLTensor)
and self.weight.dtype != input.dtype
)
or (
self.bias is not None
and self.bias.is_floating_point()
and not isinstance(self.bias, GGMLTensor)
and self.bias.dtype != input.dtype
)
):
weight = self._cast_tensor_for_input(self.weight, input)
bias = self._cast_tensor_for_input(self.bias, input)
return self._conv_forward(input, weight, bias)
else:
return super().forward(input)

View File

@@ -9,6 +9,7 @@ from invokeai.backend.model_manager.load.model_cache.torch_module_autocast.custo
from invokeai.backend.patches.layers.base_layer_patch import BaseLayerPatch
from invokeai.backend.patches.layers.flux_control_lora_layer import FluxControlLoRALayer
from invokeai.backend.patches.layers.lora_layer import LoRALayer
from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor
def linear_lora_forward(input: torch.Tensor, lora_layer: LoRALayer, lora_weight: float) -> torch.Tensor:
@@ -57,28 +58,47 @@ def autocast_linear_forward_sidecar_patches(
# Finally, apply any remaining patches.
if len(unprocessed_patches_and_weights) > 0:
weight, bias = orig_module._cast_weight_bias_for_input(input)
# Prepare the original parameters for the patch aggregation.
orig_params = {"weight": orig_module.weight, "bias": orig_module.bias}
orig_params = {"weight": weight, "bias": bias}
# Filter out None values.
orig_params = {k: v for k, v in orig_params.items() if v is not None}
aggregated_param_residuals = orig_module._aggregate_patch_parameters(
unprocessed_patches_and_weights, orig_params=orig_params, device=input.device
)
output += torch.nn.functional.linear(
input, aggregated_param_residuals["weight"], aggregated_param_residuals.get("bias", None)
)
residual_weight = orig_module._cast_tensor_for_input(aggregated_param_residuals["weight"], input)
residual_bias = orig_module._cast_tensor_for_input(aggregated_param_residuals.get("bias", None), input)
assert residual_weight is not None
output += torch.nn.functional.linear(input, residual_weight, residual_bias)
return output
class CustomLinear(torch.nn.Linear, CustomModuleMixin):
def _cast_tensor_for_input(self, tensor: torch.Tensor | None, input: torch.Tensor) -> torch.Tensor | None:
tensor = cast_to_device(tensor, input.device)
if (
tensor is not None
and input.is_floating_point()
and tensor.is_floating_point()
and not isinstance(tensor, GGMLTensor)
and tensor.dtype != input.dtype
):
tensor = tensor.to(dtype=input.dtype)
return tensor
def _cast_weight_bias_for_input(self, input: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor | None]:
weight = self._cast_tensor_for_input(self.weight, input)
bias = self._cast_tensor_for_input(self.bias, input)
assert weight is not None
return weight, bias
def _autocast_forward_with_patches(self, input: torch.Tensor) -> torch.Tensor:
return autocast_linear_forward_sidecar_patches(self, input, self._patches_and_weights)
def _autocast_forward(self, input: torch.Tensor) -> torch.Tensor:
weight = cast_to_device(self.weight, input.device)
bias = cast_to_device(self.bias, input.device)
weight, bias = self._cast_weight_bias_for_input(input)
return torch.nn.functional.linear(input, weight, bias)
def forward(self, input: torch.Tensor) -> torch.Tensor:
@@ -86,5 +106,16 @@ class CustomLinear(torch.nn.Linear, CustomModuleMixin):
return self._autocast_forward_with_patches(input)
elif self._device_autocasting_enabled:
return self._autocast_forward(input)
elif input.is_floating_point() and (
(self.weight.is_floating_point() and self.weight.dtype != input.dtype)
or (
self.bias is not None
and self.bias.is_floating_point()
and not isinstance(self.bias, GGMLTensor)
and self.bias.dtype != input.dtype
)
):
weight, bias = self._cast_weight_bias_for_input(input)
return torch.nn.functional.linear(input, weight, bias)
else:
return super().forward(input)

View File

@@ -49,7 +49,9 @@ class CustomModuleMixin:
# parameters. But, of course, any sub-layers that need to access the actual values of the parameters will fail.
for param_name in orig_params.keys():
param = orig_params[param_name]
if type(param) is torch.nn.Parameter and type(param.data) is torch.Tensor:
if isinstance(param, torch.nn.Parameter) and type(param.data) is torch.Tensor:
pass
elif type(param) is torch.Tensor:
pass
elif type(param) is GGMLTensor:
# Move to device and dequantize here. Doing it in the patch layer can result in redundant casts /

View File

@@ -2,6 +2,13 @@ from typing import Optional
from pydantic import BaseModel
from invokeai.backend.model_manager.configs.external_api import (
ExternalApiModelDefaultSettings,
ExternalImageSize,
ExternalModelCapabilities,
ExternalModelPanelSchema,
ExternalResolutionPreset,
)
from invokeai.backend.model_manager.taxonomy import (
AnyVariant,
BaseModelType,
@@ -20,6 +27,9 @@ class StarterModelWithoutDependencies(BaseModel):
format: Optional[ModelFormat] = None
variant: Optional[AnyVariant] = None
is_installed: bool = False
capabilities: ExternalModelCapabilities | None = None
default_settings: ExternalApiModelDefaultSettings | None = None
panel_schema: ExternalModelPanelSchema | None = None
# allows us to track what models a user has installed across name changes within starter models
# if you update a starter model name, please add the old one to this list for that starter model
previous_names: list[str] = []
@@ -1001,6 +1011,226 @@ z_image_controlnet_tile = StarterModel(
)
# endregion
# region External API
GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS = [
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
]
GEMINI_3_IMAGE_MAX_SIZE = ExternalImageSize(width=4096, height=4096)
def _gemini_3_resolution_presets(
image_sizes: list[str],
aspect_ratios: list[str] | None = None,
) -> list[ExternalResolutionPreset]:
"""Build resolution presets for Gemini 3 models.
Each preset combines an aspect ratio with an image size preset (512/1K/2K/4K).
Pixel dimensions are approximations based on the preset name (longest side).
"""
if aspect_ratios is None:
aspect_ratios = GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS
base_pixels = {"512": 512, "1K": 1024, "2K": 2048, "4K": 4096}
presets: list[ExternalResolutionPreset] = []
for image_size in image_sizes:
base = base_pixels[image_size]
for ratio_str in aspect_ratios:
w_part, h_part = (int(x) for x in ratio_str.split(":"))
if w_part >= h_part:
w = base
h = max(1, round(base * h_part / w_part))
else:
h = base
w = max(1, round(base * w_part / h_part))
presets.append(
ExternalResolutionPreset(
label=f"{ratio_str} ({image_size}) — {w}\u00d7{h}",
aspect_ratio=ratio_str,
image_size=image_size,
width=w,
height=h,
)
)
return presets
GEMINI_3_PRO_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["1K", "2K", "4K"])
GEMINI_3_1_FLASH_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["512", "1K", "2K", "4K"])
gemini_flash_image = StarterModel(
name="Gemini 2.5 Flash Image",
base=BaseModelType.External,
source="external://gemini/gemini-2.5-flash-image",
description="Google Gemini 2.5 Flash image generation model (external API). Requires a configured Gemini API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_seed=True,
supports_reference_images=True,
max_images_per_request=1,
allowed_aspect_ratios=[
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
],
aspect_ratio_sizes={
"1:1": ExternalImageSize(width=1024, height=1024),
"2:3": ExternalImageSize(width=832, height=1248),
"3:2": ExternalImageSize(width=1248, height=832),
"3:4": ExternalImageSize(width=864, height=1184),
"4:3": ExternalImageSize(width=1184, height=864),
"4:5": ExternalImageSize(width=896, height=1152),
"5:4": ExternalImageSize(width=1152, height=896),
"9:16": ExternalImageSize(width=768, height=1344),
"16:9": ExternalImageSize(width=1344, height=768),
"21:9": ExternalImageSize(width=1536, height=672),
},
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]),
)
gemini_pro_image_preview = StarterModel(
name="Gemini 3 Pro Image Preview",
base=BaseModelType.External,
source="external://gemini/gemini-3-pro-image-preview",
description="Google Gemini 3 Pro image generation preview model (external API). Supports up to 14 reference images, including up to 6 object references and up to 5 character references. Supports 1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_seed=True,
supports_reference_images=True,
max_reference_images=14,
max_images_per_request=1,
max_image_size=GEMINI_3_IMAGE_MAX_SIZE,
allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS,
resolution_presets=GEMINI_3_PRO_RESOLUTION_PRESETS,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]),
)
gemini_3_1_flash_image_preview = StarterModel(
name="Gemini 3.1 Flash Image Preview",
base=BaseModelType.External,
source="external://gemini/gemini-3.1-flash-image-preview",
description="Google Gemini 3.1 Flash image generation preview model (external API). Supports up to 14 reference images, including up to 10 object references and up to 4 character references. Supports 512/1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
supports_seed=True,
supports_reference_images=True,
max_reference_images=14,
max_images_per_request=1,
max_image_size=GEMINI_3_IMAGE_MAX_SIZE,
allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS,
resolution_presets=GEMINI_3_1_FLASH_RESOLUTION_PRESETS,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]),
)
OPENAI_GPT_IMAGE_ASPECT_RATIOS = ["1:1", "3:2", "2:3"]
OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES = {
"1:1": ExternalImageSize(width=1024, height=1024),
"3:2": ExternalImageSize(width=1536, height=1024),
"2:3": ExternalImageSize(width=1024, height=1536),
}
OPENAI_GPT_IMAGE_PANEL_SCHEMA = ExternalModelPanelSchema(
prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]
)
openai_gpt_image_1_5 = StarterModel(
name="GPT Image 1.5",
base=BaseModelType.External,
source="external://openai/gpt-image-1.5",
description="OpenAI GPT-Image-1.5 image generation model. Fastest and most affordable GPT image model. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img"],
supports_reference_images=True,
max_images_per_request=10,
allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS,
aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA,
)
openai_gpt_image_1 = StarterModel(
name="GPT Image 1",
base=BaseModelType.External,
source="external://openai/gpt-image-1",
description="OpenAI GPT-Image-1 image generation model. High quality image generation. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img"],
supports_reference_images=True,
max_images_per_request=10,
allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS,
aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA,
)
openai_gpt_image_1_mini = StarterModel(
name="GPT Image 1 Mini",
base=BaseModelType.External,
source="external://openai/gpt-image-1-mini",
description="OpenAI GPT-Image-1-Mini image generation model. Cost-efficient option, 80%% cheaper than GPT-Image-1. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img", "img2img"],
supports_reference_images=True,
max_images_per_request=10,
allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS,
aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES,
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA,
)
openai_dall_e_3 = StarterModel(
name="DALL-E 3",
base=BaseModelType.External,
source="external://openai/dall-e-3",
description="OpenAI DALL-E 3 image generation model. Supports vivid and natural styles. Only text-to-image, no editing. Requires a configured OpenAI API key and may incur provider usage costs.",
type=ModelType.ExternalImageGenerator,
format=ModelFormat.ExternalApi,
capabilities=ExternalModelCapabilities(
modes=["txt2img"],
max_images_per_request=1,
allowed_aspect_ratios=["1:1", "7:4", "4:7"],
aspect_ratio_sizes={
"1:1": ExternalImageSize(width=1024, height=1024),
"7:4": ExternalImageSize(width=1792, height=1024),
"4:7": ExternalImageSize(width=1024, height=1792),
},
),
default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1),
panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]),
)
# DALL-E 2 removed — deprecated by OpenAI, shutdown May 12, 2026.
# region Anima
anima_qwen3_encoder = StarterModel(
name="Anima Qwen3 0.6B Text Encoder",
@@ -1140,6 +1370,13 @@ STARTER_MODELS: list[StarterModel] = [
z_image_qwen3_encoder_quantized,
z_image_controlnet_union,
z_image_controlnet_tile,
gemini_flash_image,
gemini_pro_image_preview,
gemini_3_1_flash_image_preview,
openai_gpt_image_1_5,
openai_gpt_image_1,
openai_gpt_image_1_mini,
openai_dall_e_3,
anima_preview3,
anima_qwen3_encoder,
anima_vae,

View File

@@ -52,6 +52,8 @@ class BaseModelType(str, Enum):
"""Indicates the model is associated with CogView 4 model architecture."""
ZImage = "z-image"
"""Indicates the model is associated with Z-Image model architecture, including Z-Image-Turbo."""
External = "external"
"""Indicates the model is hosted by an external provider."""
QwenImage = "qwen-image"
"""Indicates the model is associated with Qwen Image Edit 2511 model architecture."""
Anima = "anima"
@@ -80,6 +82,7 @@ class ModelType(str, Enum):
SigLIP = "siglip"
FluxRedux = "flux_redux"
LlavaOnevision = "llava_onevision"
ExternalImageGenerator = "external_image_generator"
Unknown = "unknown"
@@ -187,6 +190,7 @@ class ModelFormat(str, Enum):
BnbQuantizedLlmInt8b = "bnb_quantized_int8b"
BnbQuantizednf4b = "bnb_quantized_nf4b"
GGUFQuantized = "gguf_quantized"
ExternalApi = "external_api"
Unknown = "unknown"
@@ -215,6 +219,7 @@ class ModelSourceType(str, Enum):
Path = "path"
Url = "url"
HFRepoID = "hf_repo_id"
External = "external"
class FluxLoRAFormat(str, Enum):

View File

@@ -1,3 +1,3 @@
# Invoke UI
<https://invoke-ai.github.io/InvokeAI/contributing/frontend/>
<https://invoke.ai/development/front-end/>

View File

@@ -185,7 +185,7 @@
"imagesSettings": "Galeriebildereinstellungen"
},
"hotkeys": {
"hotkeys": "Tastenkombinationen",
"hotkeys": "Tastaturbefehle",
"noHotkeysFound": "Kein Hotkey gefunden",
"searchHotkeys": "Hotkeys durchsuchen",
"clearSearch": "Suche leeren",
@@ -433,7 +433,6 @@
"desc": "Zeigen oder verbergen Sie beide Panels auf einmal."
}
},
"hotkeys": "Tastaturbefehle",
"gallery": {
"title": "Galerie",
"selectAllOnPage": {
@@ -804,7 +803,7 @@
},
"boards": {
"autoAddBoard": "Board automatisch erstellen",
"topMessage": "Dieser Ordner enthält Bilder, die in den folgenden Funktionen verwendet werden:",
"topMessage": "Diese Auswahl enthält Bilder, die in den folgenden Funktionen verwendet werden:",
"move": "Bewegen",
"menuItemAutoAdd": "Auto-Hinzufügen zu diesem Ordner",
"myBoard": "Meine Ordner",
@@ -818,7 +817,7 @@
"changeBoard": "Ordner wechseln",
"loading": "Laden...",
"clearSearch": "Suche leeren",
"bottomMessage": "Löschen des Boards und seiner Bilder setzt alle Funktionen zurück, die sie gerade verwenden.",
"bottomMessage": "Durch das Löschen von Bildern werden alle Funktionen zurückgesetzt, die diese Bilder derzeit verwenden.",
"deleteBoardOnly": "Nur Ordner löschen",
"deleteBoard": "Lösche Ordner",
"deleteBoardAndImages": "Lösche Ordner und Bilder",
@@ -836,13 +835,25 @@
"archiveBoard": "Ordner archivieren",
"archived": "Archiviert",
"noBoards": "Kein {{boardType}} Ordner",
"deletedPrivateBoardsCannotbeRestored": "Gelöschte Boards können nicht wiederhergestellt werden. Wenn Sie „Nur Board löschen“ wählen, werden die Bilder in einen privaten, nicht kategorisierten Status für den Ersteller des Bildes versetzt.",
"deletedPrivateBoardsCannotbeRestored": "Gelöschte Pinnwände und Bilder können nicht wiederhergestellt werden. Wenn Sie „Nur Pinnwand löschen“ auswählen, werden die Bilder in einen privaten, nicht kategorisierten Zustand verschoben, der nur dem Ersteller des Bildes zugänglich ist.",
"assetsWithCount_one": "{{count}} in der Sammlung",
"assetsWithCount_other": "{{count}} in der Sammlung",
"deletedBoardsCannotbeRestored": "Gelöschte Ordner können nicht wiederhergestellt werden. Die Auswahl von \"Nur Ordner löschen\" verschiebt Bilder in einen unkategorisierten Zustand.",
"deletedBoardsCannotbeRestored": "Gelöschte Boards und Bilder können nicht wiederhergestellt werden. Durch Auswahl von Nur Board löschen“ werden die Bilder in den nicht kategorisierten Zustand verschoben.",
"updateBoardError": "Fehler beim Aktualisieren des Ordners",
"uncategorizedImages": "Nicht kategorisierte Bilder",
"deleteAllUncategorizedImages": "Alle nicht kategorisierten Bilder löschen"
"deleteAllUncategorizedImages": "Alle nicht kategorisierten Bilder löschen",
"pause": "Pause",
"resume": "Weiter",
"restartFailed": "Neustart fehlgeschlagen",
"restartFile": "Datei erneut starten",
"restartRequired": "Neustart erforderlich",
"resumeRefused": "Der Server hat die Fortsetzung des Vorgangs abgelehnt. Ein Neustart ist erforderlich.",
"deletedImagesCannotBeRestored": "Gelöschte Bilder können nicht wiederhergestellt werden.",
"hideBoards": "Ordner verstecken",
"locateInGalery": "In der Galerie finden",
"viewBoards": "Ordner anzeigen",
"setBoardVisibility": "Sichtbarkeit des Ordner",
"setVisibilityPrivate": "Privat einstellen"
},
"queue": {
"status": "Status",
@@ -1658,5 +1669,101 @@
"creativity": "Kreativität",
"structure": "Struktur",
"scale": "Maßstab"
},
"auth": {
"login": {
"title": "Einloggen in InvokeAi",
"email": "Email",
"emailPlaceholder": "Email",
"password": "Password",
"passwordPlaceholder": "Password",
"rememberMe": "Eingeloggt bleiben für 7 Tage",
"signIn": "Einloggen",
"signingIn": "einloggen...",
"loginFailed": "Fehler beim einloggen. Daten prüfen.",
"sessionExpired": "Session ist abgelaufen. Bitte erneuert einloggen."
},
"setup": {
"title": "Willkommen zu InvokeAI",
"subtitle": "Admin Account anlegen um zu starten",
"email": "Email",
"emailPlaceholder": "admin@beispiel.de",
"emailHelper": "Das wird dein Username sein zum einloggen",
"displayName": "Anzeige Name",
"displayNamePlaceholder": "Administrator",
"displayNameHelper": "Der Anzeigename in der Anwendung",
"password": "Password",
"passwordPlaceholder": "Passwort",
"passwordHelper": "Muss mindestens 8 Zeichen lang sein und Großbuchstaben, Kleinbuchstaben und Zahlen enthalten",
"passwordTooShort": "Das Passwort muss mindestens 8 Zeichen lang sein",
"passwordMissingRequirements": "Das Passwort muss Großbuchstaben, Kleinbuchstaben und Zahlen enthalten",
"confirmPassword": "Passwort bestätigen",
"confirmPasswordPlaceholder": "Passwort bestätigen",
"passwordsDoNotMatch": "Die Passwörter stimmen nicht überein",
"createAccount": "Administratorkonto erstellen",
"creatingAccount": "Einrichtung läuft...",
"setupFailed": "Die Einrichtung ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"passwordHelperRelaxed": "Geben Sie ein Passwort ein (die Stärke wird angezeigt)"
},
"userMenu": "User Menü",
"admin": "Admin",
"logout": "Ausloggen",
"adminOnlyFeature": "Diese Funktion steht nur Administratoren zur Verfügung.",
"profile": {
"menuItem": "Mein Profile",
"title": "Mein Profile",
"email": "Email",
"emailReadOnly": "Die E-Mail-Adresse kann nicht geändert werden",
"displayName": "Anzeige Name",
"displayNamePlaceholder": "Dein Name",
"changePassword": "Passwort ändern",
"currentPassword": "Aktuelle Passwort",
"currentPasswordPlaceholder": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"newPasswordPlaceholder": "Neues Passwort",
"confirmPassword": "Neues Passwort bestätigen",
"confirmPasswordPlaceholder": "Neues Passwort bestätigen",
"passwordsDoNotMatch": "Die Passwörter stimmen nicht überein",
"saveSuccess": "Profil erfolgreich aktualisiert",
"saveFailed": "Profil konnte nicht gespeichert werden. Bitte versuchen Sie es erneut."
},
"userManagement": {
"menuItem": "Benutzerverwaltung",
"title": "Benutzerverwaltung",
"email": "Email",
"emailPlaceholder": "user@beispiel.de",
"displayName": "Anzeige Name",
"displayNamePlaceholder": "Anzeige Name",
"password": "Passwort",
"passwordPlaceholder": "Passwort",
"newPassword": "Neues Passwort",
"newPasswordPlaceholder": "Lassen Sie dieses Feld leer, um das aktuelle Passwort beizubehalten",
"role": "Rolle",
"status": "Status",
"actions": "Aktionen",
"isAdmin": "Administrator",
"user": "Benutzer",
"you": "Du",
"createUser": "Benutzer anlegen",
"editUser": "Benutzer bearbeiten",
"deleteUser": "Benutzer löschen",
"deleteConfirm": "Möchten Sie \"{{name}}\" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"generatePassword": "Generieren sicheres Passwort",
"showPassword": "Passwort zeigen",
"hidePassword": "Passwort verstecken",
"activate": "Aktivieren",
"deactivate": "Deaktivieren",
"saveFailed": "Benutzer konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"deleteFailed": "Benutzer konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"loadFailed": "Benutzer konnten nicht geladen werden.",
"back": "Zurück",
"cannotDeleteSelf": "Sie können Ihr eigenes Konto nicht löschen",
"cannotDeactivateSelf": "Sie können Ihr eigenes Konto nicht löschen"
},
"passwordStrength": {
"weak": "schwaches Passwort",
"moderate": "Moderates Passwort",
"strong": "Starkes Passwort"
}
}
}

View File

@@ -994,10 +994,16 @@
"dypeScale": "$t(parameters.dypeScale)",
"dypeExponent": "$t(parameters.dypeExponent)",
"generationMode": "Generation Mode",
"geminiTemperature": "Gemini Temperature",
"geminiThinkingLevel": "Gemini Thinking Level",
"openaiQuality": "OpenAI Quality",
"openaiBackground": "OpenAI Background",
"openaiInputFidelity": "OpenAI Input Fidelity",
"guidance": "Guidance",
"height": "Height",
"imageDetails": "Image Details",
"imageDimensions": "Image Dimensions",
"imageSize": "Image Size",
"metadata": "Metadata",
"model": "Model",
"negativePrompt": "Negative Prompt",
@@ -1115,6 +1121,22 @@
"fileSize": "File Size",
"filterModels": "Filter models",
"fluxRedux": "FLUX Redux",
"externalImageGenerator": "External Image Generator",
"externalProviders": "External Providers",
"externalSetupTitle": "External Providers Setup",
"externalSetupDescription": "Connect an API key to enable external image generation. External starter models auto-install when a provider is configured.",
"externalInstallDefaults": "Auto-install starter models",
"externalProvidersUnavailable": "External providers are not available in this build.",
"externalSetupFooter": "An API key is required. External providers use remote APIs; usage may incur provider-side costs.",
"externalProviderCardDescription": "Configure {{providerId}} credentials for external image generation.",
"externalApiKey": "API Key",
"externalApiKeyPlaceholder": "Paste your API key",
"externalApiKeyPlaceholderSet": "API key configured",
"externalApiKeyHelper": "Stored in your InvokeAI config file.",
"externalBaseUrl": "Base URL (optional)",
"externalBaseUrlPlaceholder": "https://...",
"externalBaseUrlHelper": "Override the default API base URL if needed.",
"externalResetHelper": "Clear API key and base URL.",
"height": "Height",
"huggingFace": "HuggingFace",
"huggingFacePlaceholder": "owner/model-name",
@@ -1182,6 +1204,21 @@
"modelUpdated": "Model Updated",
"modelUpdateFailed": "Model Update Failed",
"name": "Name",
"externalProvider": "External Provider",
"externalCapabilities": "External Capabilities",
"externalDefaults": "External Defaults",
"providerId": "Provider ID",
"providerModelId": "Provider Model ID",
"supportedModes": "Supported Modes",
"supportsNegativePrompt": "Supports Negative Prompt",
"supportsReferenceImages": "Supports Reference Images",
"supportsSeed": "Supports Seed",
"supportsGuidance": "Supports Guidance",
"maxImagesPerRequest": "Max Images Per Request",
"maxReferenceImages": "Max Reference Images",
"maxImageWidth": "Max Image Width",
"maxImageHeight": "Max Image Height",
"numImages": "Num Images",
"modelPickerFallbackNoModelsInstalled": "No models installed.",
"modelPickerFallbackNoModelsInstalled2": "Visit the <LinkComponent>Model Manager</LinkComponent> to install models.",
"modelPickerFallbackNoModelsInstalledNonAdmin": "No models installed. Ask your InvokeAI administrator (<AdminEmailLink />) to install some models.",
@@ -1226,6 +1263,7 @@
"urlDescription": "Install models from a URL or local file path. Perfect for specific models you want to add.",
"huggingFaceDescription": "Browse and install models directly from HuggingFace repositories.",
"scanFolderDescription": "Scan a local folder to automatically detect and install models.",
"externalDescription": "Connect a Gemini or OpenAI API key to enable external generation. Usage may incur provider-side costs.",
"recommendedModels": "Recommended Models",
"exploreStarter": "Or browse all available starter models",
"quickStart": "Quick Start Bundles",
@@ -1544,6 +1582,7 @@
"copyImage": "Copy Image",
"denoisingStrength": "Denoising Strength",
"disabledNoRasterContent": "Disabled (No Raster Content)",
"disabledNotSupported": "Not supported by model",
"downloadImage": "Download Image",
"general": "General",
"guidance": "Guidance",
@@ -1661,6 +1700,7 @@
"boxBlur": "Box Blur",
"staged": "Staged",
"resolution": "Resolution",
"imageSize": "Image Size",
"modelDisabledForTrial": "Generating with {{modelName}} is not available on trial accounts. Visit your <LinkComponent>account settings</LinkComponent> to upgrade."
},
"dynamicPrompts": {
@@ -1709,6 +1749,7 @@
"informationalPopoversDisabledDesc": "Informational popovers have been disabled. Enable them in Settings.",
"enableModelDescriptions": "Enable Model Descriptions in Dropdowns",
"enableHighlightFocusedRegions": "Highlight Focused Regions",
"middleClickOpenInNewTab": "Use Middle Click to Open Images in New Tab",
"modelDescriptionsDisabled": "Model Descriptions in Dropdowns Disabled",
"modelDescriptionsDisabledDesc": "Model descriptions in dropdowns have been disabled. Enable them in Settings.",
"enableInvisibleWatermark": "Enable Invisible Watermark",
@@ -1737,7 +1778,11 @@
"intermediatesCleared_one": "Cleared {{count}} Intermediate",
"intermediatesCleared_other": "Cleared {{count}} Intermediates",
"intermediatesClearedFailed": "Problem Clearing Intermediates",
"reloadingIn": "Reloading in"
"reloadingIn": "Reloading in",
"externalProviders": "External Providers",
"externalProviderConfigured": "Configured",
"externalProviderNotConfigured": "API Key Required",
"externalProviderNotConfiguredHint": "Add your API key in Model Manager or the server config to enable this provider."
},
"toast": {
"addedToBoard": "Added to board {{name}}'s assets",
@@ -2679,6 +2724,8 @@
"logDebugInfo": "Log Debug Info",
"locked": "Locked",
"unlocked": "Unlocked",
"transparencyLocked": "Transparency Locked",
"transparencyUnlocked": "Transparency Unlocked",
"deleteSelected": "Delete Selected",
"stagingOnCanvas": "Staging images on",
"replaceLayer": "Replace Layer",

View File

@@ -146,7 +146,9 @@
"settings": "Impostazioni",
"toggleRgbHex": "Attiva/disattiva RGB/HEX",
"unpin": "Sblocca",
"openSlider": "Apri il cursore"
"openSlider": "Apri il cursore",
"collapseAll": "Comprimi tutto",
"expandAll": "Espandi tutto"
},
"gallery": {
"galleryImageSize": "Dimensione dell'immagine",
@@ -462,6 +464,10 @@
"toggleFillColor": {
"title": "Attiva/disattiva colore di riempimento",
"desc": "Attiva/disattiva il colore di riempimento dello strumento corrente."
},
"selectLassoTool": {
"title": "Strumento Lazo",
"desc": "Seleziona lo strumento lazo."
}
},
"workflows": {
@@ -888,7 +894,9 @@
"qwenImageComponentSourcePlaceholder": "Necessario per i modelli GGUF",
"qwenImageComponentSource": "VAE/Sorgente Encoder (Diffusori)",
"qwenImageQuantization": "Quantizzazione dell'encoder",
"qwenImageQuantizationNone": "Nessuna (bf16)"
"qwenImageQuantizationNone": "Nessuna (bf16)",
"modelPickerFallbackNoModelsInstalledNonAdmin": "Nessun modello installato. Chiedi al tuo amministratore di InvokeAI (<AdminEmailLink />) di installare alcuni modelli.",
"noModelsInstalledAskAdmin": "Chiedi al tuo amministratore di installarne alcuni."
},
"parameters": {
"images": "Immagini",
@@ -983,7 +991,8 @@
"noAnimaVaeModelSelected": "Nessun modello VAE Anima selezionato",
"noAnimaQwen3EncoderModelSelected": "Nessun modello di encoder Anima Qwen3 selezionato",
"noAnimaT5EncoderModelSelected": "Nessun modello di encoder Anima T5 selezionato",
"noQwenImageComponentSourceSelected": "I modelli GGUF Qwen Image richiedono una sorgente componente diffusori per VAE/encoder"
"noQwenImageComponentSourceSelected": "I modelli GGUF Qwen Image richiedono una sorgente componente diffusori per VAE/encoder",
"boardNotWritable": "Non hai i permessi di scrittura per la bacheca \"{{boardName}}\". Seleziona una bacheca di tua proprietà oppure passa a Non categorizzata."
},
"useCpuNoise": "Usa la CPU per generare rumore",
"iterations": "Iterazioni",
@@ -1064,7 +1073,9 @@
"enableHighlightFocusedRegions": "Evidenzia le regioni interessate",
"modelDescriptionsDisabled": "Descrizioni dei modelli nei menu a discesa disabilitate",
"modelDescriptionsDisabledDesc": "Le descrizioni dei modelli nei menu a discesa sono state disattivate. Abilitale nelle Impostazioni.",
"preferAttentionStyleNumeric": "Preferisci lo stile di attenzione numerico"
"preferAttentionStyleNumeric": "Preferisci lo stile di attenzione numerico",
"maxQueueHistory": "Cronologia massima della coda",
"maxQueueHistorySaveFailed": "Impossibile salvare la cronologia della coda massima"
},
"toast": {
"uploadFailed": "Caricamento fallito",
@@ -1386,7 +1397,11 @@
"floatRangeGenerator": "Generatore di intervallo di numeri decimali",
"integerRangeGenerator": "Generatore di intervallo di numeri interi",
"noWorkflowToSave": "Nessun flusso di lavoro da salvare",
"nodeData": "Dati del nodo"
"nodeData": "Dati del nodo",
"groupNodesByCategory": "Raggruppa i nodi per categoria",
"groupNodesByCategoryHelp": "Raggruppa i nodi per categoria nella finestra di dialogo \"Aggiungi nodo\"",
"addConnector": "Aggiungi connettore",
"deleteConnector": "Elimina connettore"
},
"boards": {
"autoAddBoard": "Aggiungi automaticamente bacheca",
@@ -1441,7 +1456,17 @@
"restartFailed": "Riavvio non riuscito",
"restartFile": "Riavvia il file",
"restartRequired": "Riavvio richiesto",
"resumeRefused": "Ripristino rifiutato dal server. Riavvio richiesto."
"resumeRefused": "Ripristino rifiutato dal server. Riavvio richiesto.",
"setBoardVisibility": "Visibilità della bacheca",
"setVisibilityPrivate": "Imposta come privata",
"setVisibilityShared": "Imposta come condivisa",
"setVisibilityPublic": "Imposta come pubblica",
"visibilityPrivate": "Privata",
"visibilityShared": "Condivisa",
"visibilityPublic": "Pubblica",
"visibilityBadgeShared": "Bacheca condivisa",
"visibilityBadgePublic": "Bacheca pubblica",
"updateBoardVisibilityError": "Errore durante l'aggiornamento della visibilità della bacheca"
},
"queue": {
"queueFront": "Aggiungi all'inizio della coda",
@@ -2285,7 +2310,9 @@
"searchWorkflows": "Ricerca flussi di lavoro",
"clearWorkflowSearchFilter": "Cancella filtro di ricerca del flusso di lavoro",
"openLibrary": "Apri libreria",
"tags": "Etichette"
"tags": "Etichette",
"sharedWorkflows": "Flussi di lavoro condivisi",
"shareWorkflow": "Flusso di lavoro condiviso"
},
"accordions": {
"compositing": {
@@ -2920,7 +2947,25 @@
"invertRegion": "Inverti la regione",
"invalidReferenceImage": "Immagine di riferimento non valida:",
"removeImageFromCollection": "Rimuovi l'immagine dalla raccolta",
"selectRefImage": "Seleziona l'immagine di riferimento"
"selectRefImage": "Seleziona l'immagine di riferimento",
"canvasProject": {
"project": "Progetto",
"saveProject": "Salva il progetto Tela",
"loadProject": "Carica il progetto Tela",
"saveSuccess": "Progetto salvato",
"saveSuccessDesc": "Progetto salvato con {{count}} immagini",
"saveError": "Impossibile salvare il progetto",
"loadSuccess": "Progetto caricato",
"loadSuccessDesc": "Stato della tela ripristinato dal file di progetto",
"loadError": "Impossibile caricare il progetto",
"loadWarning": "Il caricamento di un progetto sostituirà l'area di lavoro corrente, inclusi tutti i livelli, le maschere, le immagini di riferimento e i parametri di generazione. Questa operazione è irreversibile.",
"projectName": "Nome del progetto"
},
"lasso": {
"freehand": "A mano libera",
"polygon": "Poligono",
"polygonHint": "Fai clic per aggiungere punti, fai clic sul primo punto per chiudere."
}
},
"ui": {
"tabs": {
@@ -2956,7 +3001,8 @@
"loadFromFile": {
"title": "Carica flusso di lavoro da file",
"description": "Carica un flusso di lavoro per iniziare con una configurazione esistente"
}
},
"descriptionMultiuser": "I flussi di lavoro sono modelli riutilizzabili che automatizzano le attività di generazione di immagini, consentendo di eseguire rapidamente operazioni complesse e ottenere risultati coerenti. È possibile condividere i flussi di lavoro con altri utenti del sistema selezionando \"Flusso di lavoro condiviso\" durante la creazione o la modifica."
},
"upscaling": {
"uploadImage": {
@@ -3041,7 +3087,8 @@
"incompatibleBaseModelDesc": "L'ampliamento è supportato solo per i modelli di architettura SD1.5 e SDXL. Cambia il modello principale per abilitare l'ampliamento.",
"tileControl": "Controllo del riquadro",
"tileSize": "Dimensione del riquadro",
"tileOverlap": "Sovrapposizione riquadro"
"tileOverlap": "Sovrapposizione riquadro",
"missingModelsWarningNonAdmin": "Chiedi al tuo amministratore di InvokeAI (<AdminEmailLink />) di installare i modelli richiesti:"
},
"stylePresets": {
"active": "Attivo",
@@ -3096,7 +3143,9 @@
"noModelsInstalled": "Sembra che non hai installato alcun modello! Puoi <DownloadStarterModelsButton>scaricare un pacchetto di modelli di avvio</DownloadStarterModelsButton> o <ImportModelsButton>importare modelli</ImportModelsButton>.",
"toGetStartedLocal": "Per iniziare, assicurati di scaricare o importare i modelli necessari per eseguire Invoke. Quindi, inserisci un prompt nella casella e fai clic su <StrongComponent>Invoke</StrongComponent> per generare la tua prima immagine. Seleziona un modello di prompt per migliorare i risultati. Puoi scegliere di salvare le tue immagini direttamente nella <StrongComponent>Galleria</StrongComponent> o modificarle nella <StrongComponent>Tela</StrongComponent>.",
"lowVRAMMode": "Per prestazioni ottimali, segui la nostra <LinkComponent>guida per bassa VRAM</LinkComponent>.",
"toGetStartedWorkflow": "Per iniziare, compila i campi a sinistra e premi <StrongComponent>Invoke</StrongComponent> per generare la tua immagine. Vuoi esplorare altri flussi di lavoro? Fai clic sull'<StrongComponent>icona della cartella</StrongComponent> accanto al titolo del flusso di lavoro per visualizzare un elenco di altri modelli che puoi provare."
"toGetStartedWorkflow": "Per iniziare, compila i campi a sinistra e premi <StrongComponent>Invoke</StrongComponent> per generare la tua immagine. Vuoi esplorare altri flussi di lavoro? Fai clic sull'<StrongComponent>icona della cartella</StrongComponent> accanto al titolo del flusso di lavoro per visualizzare un elenco di altri modelli che puoi provare.",
"toGetStartedNonAdmin": "Per iniziare, chiedi al tuo amministratore di InvokeAI (<AdminEmailLink />) di installare i modelli AI necessari per eseguire Invoke. Quindi, inserisci un prompt nella casella e fai clic su <StrongComponent>Invoke</StrongComponent> per generare la tua prima immagine. Seleziona un modello di prompt per migliorare i risultati. Puoi scegliere di salvare le immagini direttamente nella <StrongComponent>Galleria</StrongComponent> o modificarle nella <StrongComponent>Tela</StrongComponent>.",
"noModelsInstalledAskAdmin": "Chiedi al tuo amministratore di installarne alcuni."
},
"whatsNew": {
"whatsNewInInvoke": "Novità in Invoke",

View File

@@ -130,7 +130,26 @@
"notInstalled": "$t(common.installed) ではありません",
"prevPage": "前のページ",
"nextPage": "次のページ",
"resetToDefaults": "デフォルトをリセット"
"resetToDefaults": "デフォルトをリセット",
"collapseAll": "すべて畳む",
"editName": "名前を編集",
"expandAll": "すべてを展開",
"fitView": "ビューをフィット",
"hex": "16進数",
"minimize": "最小化",
"next": "次へ",
"noMatchingItems": "一致するアイテムがありません",
"notifications": "通知",
"openSlider": "スライダーを開く",
"previous": "前へ",
"removeFromCollection": "コレクションから削除",
"resetView": "ビューをリセット",
"saveToAssets": "アセットに保存",
"settings": "設定",
"toggleRgbHex": "RGB/16進数を切り替え",
"unpin": "ピンを外す",
"zoomIn": "ズームイン",
"zoomOut": "ズームアウト"
},
"gallery": {
"galleryImageSize": "画像のサイズ",
@@ -199,7 +218,12 @@
"selectAnImageToCompare": "比較する画像を選択",
"openViewer": "ビューアーを開く",
"closeViewer": "ビューアーを閉じる",
"usePagedGalleryView": "ページ型ギャラリービューを使う"
"usePagedGalleryView": "ページ型ギャラリービューを使う",
"loadingGallery": "ギャラリーをロード中...",
"loadingMetadata": "メタデータをロード中...",
"noImagesFound": "画像が見つかりません",
"bulkDownloadReady": "ダウンロード準備完了",
"clickToDownload": "クリックしてダウンロード"
},
"hotkeys": {
"searchHotkeys": "ホットキーを検索",
@@ -409,6 +433,10 @@
"title": "セグメントをキャンセル",
"desc": "現在のSegment Anything操作をキャンセルします。",
"key": "エスケープ"
},
"selectLassoTool": {
"title": "投げ縄ツール",
"desc": "投げ縄ツールを選択します。"
}
},
"workflows": {
@@ -518,7 +546,12 @@
"key": "1"
},
"promptWeightUp": {
"title": "選択したプロンプトの重みを増加"
"title": "選択したプロンプトの重みを増加",
"desc": "プロンプトのテキストが選択されている際に、選択されているプロンプトの重みを増やします。"
},
"promptWeightDown": {
"title": "選択されているプロンプトの重みを減らす",
"desc": "プロンプトのテキストが選択されている際に、選択されているプロンプトの重みを減らします。"
}
},
"hotkeys": "ホットキー",
@@ -595,7 +628,9 @@
"clearAll": "全てをクリア",
"duplicateWarning": "このホットキーはすでに記録済みです",
"conflictWarning": "はすでに \"{{hotkeyTitle}}\" で使われています",
"thisHotkey": "このホットキー"
"thisHotkey": "このホットキー",
"combineWith": "組み合わせ +",
"validKeys": "有効なキー"
},
"modelManager": {
"modelManager": "モデルマネージャ",
@@ -772,7 +807,65 @@
"starterModelsInModelManager": "スターターモデルはモデルマネージャーにあります",
"actions": "一括操作",
"selectAll": "全て選択",
"deselectAll": "全て選択解除"
"deselectAll": "全て選択解除",
"deleteModelsConfirm": "本当に {{count}} 個のモデルを削除しますか? このアクションは取り消せません。",
"deleteWarning": "Invokeのモデルディレクトリにあるモデルは、ディスクから完全に削除されます。",
"modelsDeleted": "{{count}} 個のモデルの削除に成功しました",
"modelsDeleteFailed": "モデルの削除に失敗しました",
"someModelsFailedToDelete": "{{count}}個のモデルを削除できませんでした",
"modelsDeletedPartial": "一部完了",
"someModelsDeleted": "{{deleted}} を削除, {{failed}} が失敗",
"modelsDeleteError": "モデルの削除中にエラーが発生しました",
"pause": "一時停止",
"pauseAll": "全て一時停止",
"pauseAllTooltip": "アクティブなダウンロードを全て一時停止",
"resume": "再開",
"resumeAll": "全て再開",
"resumeAllTooltip": "一時停止したダウンロードを全て再開",
"restartFailed": "再開に失敗しました",
"restartFile": "ファイルを再開",
"restartRequired": "リスタートが必要です",
"resumeRefused": "再開がサーバーに拒否されました。再開が必要です。",
"backendDisconnected": "バックエンドとの接続が切れました",
"cancelAll": "すべてキャンセル",
"cancelAllTooltip": "アクティブなダウンロードを全てキャンセル",
"reidentify": "再識別",
"reidentifyTooltip": "モデルが正しくインストールされなかった場合(例えば、タイプが間違っている、または動作しない場合)、モデルを再識別してみてください。これにより、適用したカスタム設定がすべてリセットされます。",
"reidentifySuccess": "モデルの再識別に成功",
"reidentifyUnknown": "モデルの識別ができません",
"reidentifyError": "モデルの識別中にエラーが発生",
"reidentifyModels": "モデルの再識別",
"reidentifyModelsConfirm": "{{count}} 個のモデルを再識別しますか? これにより、モデルが再度解析され、正しい形式と設定が特定されます。",
"reidentifyWarning": "これにより、これらのモデルに適用したカスタム設定はすべてリセットされます。",
"modelsReidentified": "{{count}} 個のモデルの再識別に成功",
"modelsReidentifyFailed": "モデルの再識別に失敗",
"someModelsFailedToReidentify": "{{count}} このモデルを再識別できませんでした",
"modelsReidentifiedPartial": "一部完了",
"someModelsReidentified": "{{succeeded}} 再識別完了, {{failed}} 失敗",
"modelsReidentifyError": "モデルの再識別中にエラー",
"updatePath": "パスを更新",
"updatePathTooltip": "モデルファイルを新しい場所に移動した場合は、このモデルのファイルパスを更新してください。",
"updatePathDescription": "モデルファイルまたはディレクトリへの新しいパスを入力してください。モデルファイルをディスク上で手動で移動した場合に使用してください。",
"currentPath": "現在のパス",
"newPath": "新しいパス",
"newPathPlaceholder": "新しいパスを入力...",
"pathUpdated": "モデルのパス更新に成功しました",
"pathUpdateFailed": "モデルのパス更新に失敗しました",
"invalidPathFormat": "パスは絶対パスである必要があります (例 C:\\Models\\... or /home/user/models/...)",
"cpuOnly": "CPUのみ",
"runOnCpu": "テキストエンコーダーモデルをCPUのみで実行",
"deleteModels": "モデルを削除",
"unidentifiedModelTitle": "モデルの識別ができません",
"unidentifiedModelMessage": "インストールされているモデルの種類、ベース、および/またはフォーマットを特定できませんでした。モデルを編集して、モデルに適した設定を選択してください。",
"unidentifiedModelMessage2": "正しい設定が表示されない場合、または設定を変更してもモデルが動作しない場合は、<DiscordLink />でヘルプを求めるか、<GitHubIssuesLink />で問題を報告してください。",
"missingFiles": "見つからないファイル",
"missingFilesTooltip": "ディスクにモデルファイルが見つかりません",
"modelFormat": "モデルフォーマット",
"modelSettingsWarning": "これらの設定は、Invokeにモデルの種類と読み込み方法を指示します。モデルのインストール時にInvokeがこれらの設定を正しく検出できなかった場合、またはモデルが「不明」と分類されている場合は、手動で編集する必要があるかもしれません。",
"modelPickerFallbackNoModelsInstalledNonAdmin": "モデルがインストールされていません。InvokeAI管理者<AdminEmailLink />)にモデルのインストールを依頼してください。",
"noModelsInstalledAskAdmin": "管理者にインストールを依頼してください。",
"syncModelsTooltip": "InvokeAIのルートディレクトリにある未使用のモデルファイルを特定し、削除します。",
"syncModelsDirectory": "モデルディレクトリを同期する"
},
"parameters": {
"images": "画像",
@@ -1078,7 +1171,13 @@
"noImageDetails": "画像の詳細が見つかりません",
"clipSkip": "$t(parameters.clipSkip)",
"parsingFailed": "解析に失敗しました",
"recallParameter": "{{label}} をリコール"
"recallParameter": "{{label}} をリコール",
"qwen3Encoder": "Qwen3 エンコーダー",
"qwen3Source": "Qwen3 ソース",
"seedVarianceEnabled": "シードバリアンスが有効",
"seedVarianceStrength": "シードバリアンス強度",
"seedVarianceRandomizePercent": "シードバリアンスのランダム化パーセンテージ",
"zImageShift": "Z-Image シフト"
},
"queue": {
"queueEmpty": "キューが空です",
@@ -1147,7 +1246,7 @@
"cancelAllExceptCurrentQueueItemAlertDialog2": "すべての保留中のキュー項目をキャンセルしてもよいですか?",
"cancelAllExceptCurrentTooltip": "現在の項目を除いてすべてキャンセル",
"origin": "先頭",
"destination": "先",
"destination": "出力先",
"confirm": "確認",
"retryItem": "項目をリトライ",
"batchSize": "バッチサイズ",
@@ -1168,7 +1267,9 @@
"paused": "一時停止中",
"user": "ユーザー",
"fieldValuesHidden": "<非表示>",
"cannotViewDetails": "このキューアイテムを閲覧する権限がありません"
"cannotViewDetails": "このキューアイテムを閲覧する権限がありません",
"queueActionsMenu": "アクションメニューをキュー",
"queueItem": "アイテムをキュー"
},
"models": {
"noMatchingModels": "一致するモデルがありません",
@@ -1420,7 +1521,17 @@
"restartFailed": "再起動に失敗しました",
"restartFile": "ファイルを再起動",
"restartRequired": "再起動が必要です",
"resumeRefused": "サーバーで再開が拒否されました。再起動が必要です。"
"resumeRefused": "サーバーで再開が拒否されました。再起動が必要です。",
"setBoardVisibility": "ボードの表示を設定",
"setVisibilityPrivate": "プライベートに設定",
"setVisibilityShared": "シェアに設定",
"setVisibilityPublic": "パブリックに設定",
"visibilityPrivate": "プライベート",
"visibilityShared": "シェア済み",
"visibilityPublic": "パブリック",
"visibilityBadgeShared": "シェア済みのボード",
"visibilityBadgePublic": "パブリックのボード",
"updateBoardVisibilityError": "ボード表示設定の変更中にエラーがありました"
},
"invocationCache": {
"invocationCache": "呼び出しキャッシュ",
@@ -1925,7 +2036,11 @@
"insert": "挿入",
"noPromptHistory": "プロンプトヒストリーが記録されていません。",
"noMatchingPrompts": "マッチするプロンプトがヒストリーにありません。",
"toSwitchBetweenPrompts": "プロンプトを切り替えます。"
"toSwitchBetweenPrompts": "プロンプトを切り替えます。",
"promptHistory": "プロンプト履歴",
"clearHistory": "履歴をクリア",
"usePrompt": "プロンプトを使用",
"searchPrompts": "検索..."
},
"ui": {
"tabs": {
@@ -2824,7 +2939,8 @@
}
},
"lora": {
"weight": "重み"
"weight": "重み",
"removeLoRA": "LoRAを解除"
},
"auth": {
"login": {
@@ -2836,7 +2952,8 @@
"rememberMe": "7日間は記憶",
"signIn": "サインイン",
"signingIn": "サインイン中...",
"loginFailed": "ログインに失敗しました。正しい内容かを確認してください。"
"loginFailed": "ログインに失敗しました。正しい内容かを確認してください。",
"sessionExpired": "認証情報が期限切れです。再度ログインして再開してください。"
},
"setup": {
"title": "Invokeへようこそ",

View File

@@ -0,0 +1,360 @@
import { zModelIdentifierField } from 'features/nodes/types/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock model configs returned by selectors - these simulate what RTK Query provides
const mockAnimaQwen3Encoder = {
key: 'qwen3-06b-key',
hash: 'qwen3-06b-hash',
name: 'Qwen3 0.6B Encoder',
base: 'any' as const,
type: 'qwen3_encoder' as const,
variant: 'qwen3_06b' as const,
format: 'qwen3_encoder' as const,
};
const mockAnimaVAE = {
key: 'anima-vae-key',
hash: 'anima-vae-hash',
name: 'Anima VAE',
base: 'anima' as const,
type: 'vae' as const,
format: 'diffusers' as const,
};
const mockT5Encoder = {
key: 't5-xxl-key',
hash: 't5-xxl-hash',
name: 'T5-XXL Encoder',
base: 'any' as const,
type: 't5_encoder' as const,
format: 't5_encoder' as const,
};
const mockAnimaMainModel = {
key: 'anima-main-key',
hash: 'anima-main-hash',
name: 'Anima Generate',
base: 'anima' as const,
type: 'main' as const,
};
const mockFluxMainModel = {
key: 'flux-main-key',
hash: 'flux-main-hash',
name: 'FLUX.1 Dev',
base: 'flux' as const,
type: 'main' as const,
};
// Track dispatched actions
const dispatched: Array<{ type: string; payload: unknown }> = [];
const mockDispatch = vi.fn((action: { type: string; payload: unknown }) => {
dispatched.push(action);
});
// Mock logger
vi.mock('app/logging/logger', () => ({
logger: () => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
}),
}));
// Mock toast
vi.mock('features/toast/toast', () => ({
toast: vi.fn(),
}));
// Mock i18next
vi.mock('i18next', () => ({
t: (key: string) => key,
}));
// Mock model selectors from RTK Query hooks
const mockSelectAnimaQwen3EncoderModels = vi.fn((_state: unknown) => [mockAnimaQwen3Encoder]);
const mockSelectAnimaVAEModels = vi.fn((_state: unknown) => [mockAnimaVAE]);
const mockSelectT5EncoderModels = vi.fn((_state: unknown) => [mockT5Encoder]);
vi.mock('services/api/hooks/modelsByType', () => ({
selectAnimaQwen3EncoderModels: (state: unknown) => mockSelectAnimaQwen3EncoderModels(state),
selectAnimaVAEModels: (state: unknown) => mockSelectAnimaVAEModels(state),
selectT5EncoderModels: (state: unknown) => mockSelectT5EncoderModels(state),
selectQwen3EncoderModels: vi.fn(() => []),
selectZImageDiffusersModels: vi.fn(() => []),
selectFluxVAEModels: vi.fn(() => []),
selectGlobalRefImageModels: vi.fn(() => []),
selectRegionalRefImageModels: vi.fn(() => []),
}));
// Mock model configs adapter
vi.mock('services/api/endpoints/models', () => ({
modelConfigsAdapterSelectors: { selectById: vi.fn() },
selectModelConfigsQuery: vi.fn(() => ({ data: undefined })),
}));
vi.mock('services/api/types', () => ({
isFluxKontextModelConfig: vi.fn(() => false),
isFluxReduxModelConfig: vi.fn(() => false),
}));
// Mock canvas selectors
vi.mock('features/controlLayers/store/canvasStagingAreaSlice', () => ({
buildSelectIsStaging: vi.fn(() => vi.fn(() => false)),
selectCanvasSessionId: vi.fn(() => null),
}));
vi.mock('features/controlLayers/store/selectors', () => ({
selectAllEntitiesOfType: vi.fn(() => []),
selectBboxModelBase: vi.fn(() => 'anima'),
selectCanvasSlice: vi.fn(() => ({})),
}));
vi.mock('features/controlLayers/store/refImagesSlice', () => ({
refImageConfigChanged: vi.fn(),
refImageModelChanged: vi.fn(),
selectReferenceImageEntities: vi.fn(() => []),
}));
vi.mock('features/controlLayers/store/types', async () => {
const actual = await vi.importActual('features/controlLayers/store/types');
return {
...(actual as Record<string, unknown>),
getEntityIdentifier: vi.fn(),
isFlux2ReferenceImageConfig: vi.fn(() => false),
};
});
vi.mock('features/controlLayers/store/util', () => ({
initialFlux2ReferenceImage: {},
initialFluxKontextReferenceImage: {},
initialFLUXRedux: {},
initialIPAdapter: {},
}));
vi.mock('features/modelManagerV2/models', () => ({
SUPPORTS_REF_IMAGES_BASE_MODELS: ['sd-1', 'sdxl', 'flux', 'flux2'],
}));
vi.mock('features/controlLayers/store/canvasSlice', () => ({
bboxSyncedToOptimalDimension: vi.fn(() => ({ type: 'bboxSyncedToOptimalDimension' })),
rgRefImageModelChanged: vi.fn(),
}));
vi.mock('features/controlLayers/store/lorasSlice', () => ({
loraIsEnabledChanged: vi.fn((payload: unknown) => ({ type: 'loraIsEnabledChanged', payload })),
}));
// Capture the listener effect so we can call it directly
let capturedEffect: ((action: unknown, api: unknown) => void) | null = null;
// Import actual action creators for assertion matching
const paramsSliceActual = (await vi.importActual('features/controlLayers/store/paramsSlice')) as {
animaQwen3EncoderModelSelected: { type: string };
animaT5EncoderModelSelected: { type: string };
animaVaeModelSelected: { type: string };
};
const { animaQwen3EncoderModelSelected, animaT5EncoderModelSelected, animaVaeModelSelected } = paramsSliceActual;
// Import after mocks are set up
const { addModelSelectedListener } = await import('./modelSelected');
const { modelSelected } = await import('features/parameters/store/actions');
const { zParameterModel } = await import('features/parameters/types/parameterSchemas');
// Capture the effect
addModelSelectedListener(((config: { effect: typeof capturedEffect }) => {
capturedEffect = config.effect;
}) as never);
function buildMockState(overrides: Record<string, unknown> = {}) {
return {
params: {
model: null,
vae: null,
zImageVaeModel: null,
zImageQwen3EncoderModel: null,
zImageQwen3SourceModel: null,
animaVaeModel: null,
animaQwen3EncoderModel: null,
animaT5EncoderModel: null,
animaScheduler: 'euler',
kleinVaeModel: null,
kleinQwen3EncoderModel: null,
zImageScheduler: 'euler',
...overrides,
},
loras: { loras: [] },
canvas: {},
};
}
describe('modelSelected listener - Anima defaulting', () => {
beforeEach(() => {
dispatched.length = 0;
mockDispatch.mockClear();
mockSelectAnimaQwen3EncoderModels.mockReturnValue([mockAnimaQwen3Encoder]);
mockSelectAnimaVAEModels.mockReturnValue([mockAnimaVAE]);
mockSelectT5EncoderModels.mockReturnValue([mockT5Encoder]);
});
it('should dispatch encoder models with full ModelIdentifierField payloads when switching to Anima', () => {
const state = buildMockState({ model: mockFluxMainModel });
const action = modelSelected(zParameterModel.parse(mockAnimaMainModel));
capturedEffect!(action, {
getState: () => state,
dispatch: mockDispatch,
});
// Find the dispatched actions for Anima encoders
const qwen3Dispatch = dispatched.find((a) => a.type === animaQwen3EncoderModelSelected.type);
const t5Dispatch = dispatched.find((a) => a.type === animaT5EncoderModelSelected.type);
const vaeDispatch = dispatched.find((a) => a.type === animaVaeModelSelected.type);
// All three should have been dispatched
expect(qwen3Dispatch).toBeDefined();
expect(t5Dispatch).toBeDefined();
expect(vaeDispatch).toBeDefined();
// The payloads must pass zModelIdentifierField validation (the actual schema used by reducers)
expect(zModelIdentifierField.safeParse(qwen3Dispatch!.payload).success).toBe(true);
expect(zModelIdentifierField.safeParse(t5Dispatch!.payload).success).toBe(true);
expect(zModelIdentifierField.safeParse(vaeDispatch!.payload).success).toBe(true);
});
it('should include hash and type in Qwen3 encoder payload', () => {
const state = buildMockState({ model: mockFluxMainModel });
const action = modelSelected(zParameterModel.parse(mockAnimaMainModel));
capturedEffect!(action, {
getState: () => state,
dispatch: mockDispatch,
});
const qwen3Dispatch = dispatched.find((a) => a.type === animaQwen3EncoderModelSelected.type);
expect(qwen3Dispatch!.payload).toMatchObject({
key: mockAnimaQwen3Encoder.key,
hash: mockAnimaQwen3Encoder.hash,
name: mockAnimaQwen3Encoder.name,
base: mockAnimaQwen3Encoder.base,
type: mockAnimaQwen3Encoder.type,
});
});
it('should include hash and type in T5 encoder payload', () => {
const state = buildMockState({ model: mockFluxMainModel });
const action = modelSelected(zParameterModel.parse(mockAnimaMainModel));
capturedEffect!(action, {
getState: () => state,
dispatch: mockDispatch,
});
const t5Dispatch = dispatched.find((a) => a.type === animaT5EncoderModelSelected.type);
expect(t5Dispatch!.payload).toMatchObject({
key: mockT5Encoder.key,
hash: mockT5Encoder.hash,
name: mockT5Encoder.name,
base: mockT5Encoder.base,
type: mockT5Encoder.type,
});
});
it('should not dispatch encoder defaults when Anima models are already set', () => {
const existingQwen3 = { key: 'existing', hash: 'h', name: 'Existing', base: 'any', type: 'qwen3_encoder' };
const existingT5 = { key: 'existing-t5', hash: 'h', name: 'Existing T5', base: 'any', type: 't5_encoder' };
const existingVae = { key: 'existing-vae', hash: 'h', name: 'Existing VAE', base: 'anima', type: 'vae' };
const state = buildMockState({
model: mockFluxMainModel,
animaQwen3EncoderModel: existingQwen3,
animaT5EncoderModel: existingT5,
animaVaeModel: existingVae,
});
const action = modelSelected(zParameterModel.parse(mockAnimaMainModel));
capturedEffect!(action, {
getState: () => state,
dispatch: mockDispatch,
});
// Should NOT dispatch any encoder model selections since they're already set
const qwen3Dispatch = dispatched.find((a) => a.type === animaQwen3EncoderModelSelected.type);
const t5Dispatch = dispatched.find((a) => a.type === animaT5EncoderModelSelected.type);
const vaeDispatch = dispatched.find((a) => a.type === animaVaeModelSelected.type);
expect(qwen3Dispatch).toBeUndefined();
expect(t5Dispatch).toBeUndefined();
expect(vaeDispatch).toBeUndefined();
});
it('should not dispatch encoder defaults when no encoder models are available', () => {
mockSelectAnimaQwen3EncoderModels.mockReturnValue([]);
mockSelectAnimaVAEModels.mockReturnValue([]);
const state = buildMockState({ model: mockFluxMainModel });
const action = modelSelected(zParameterModel.parse(mockAnimaMainModel));
capturedEffect!(action, {
getState: () => state,
dispatch: mockDispatch,
});
const qwen3Dispatch = dispatched.find((a) => a.type === animaQwen3EncoderModelSelected.type);
const t5Dispatch = dispatched.find((a) => a.type === animaT5EncoderModelSelected.type);
const vaeDispatch = dispatched.find((a) => a.type === animaVaeModelSelected.type);
expect(qwen3Dispatch).toBeUndefined();
expect(t5Dispatch).toBeUndefined();
expect(vaeDispatch).toBeUndefined();
});
it('should clear Anima models when switching away from Anima', () => {
const existingQwen3 = { key: 'existing', hash: 'h', name: 'Existing', base: 'any', type: 'qwen3_encoder' };
const existingT5 = { key: 'existing-t5', hash: 'h', name: 'Existing T5', base: 'any', type: 't5_encoder' };
const existingVae = { key: 'existing-vae', hash: 'h', name: 'Existing VAE', base: 'anima', type: 'vae' };
const state = buildMockState({
model: mockAnimaMainModel,
animaQwen3EncoderModel: existingQwen3,
animaT5EncoderModel: existingT5,
animaVaeModel: existingVae,
});
const action = modelSelected(zParameterModel.parse(mockFluxMainModel));
capturedEffect!(action, {
getState: () => state,
dispatch: mockDispatch,
});
// Should dispatch null for all three
const qwen3Dispatch = dispatched.find((a) => a.type === animaQwen3EncoderModelSelected.type);
const t5Dispatch = dispatched.find((a) => a.type === animaT5EncoderModelSelected.type);
const vaeDispatch = dispatched.find((a) => a.type === animaVaeModelSelected.type);
expect(qwen3Dispatch).toBeDefined();
expect(qwen3Dispatch!.payload).toBeNull();
expect(t5Dispatch).toBeDefined();
expect(t5Dispatch!.payload).toBeNull();
expect(vaeDispatch).toBeDefined();
expect(vaeDispatch!.payload).toBeNull();
});
});
describe('zModelIdentifierField schema validation', () => {
it('should reject payloads missing hash and type', () => {
const incomplete = { key: 'some-key', name: 'Some Model', base: 'any' };
expect(zModelIdentifierField.safeParse(incomplete).success).toBe(false);
});
it('should accept payloads with all required fields', () => {
const complete = { key: 'some-key', hash: 'some-hash', name: 'Some Model', base: 'any', type: 'qwen3_encoder' };
expect(zModelIdentifierField.safeParse(complete).success).toBe(true);
});
});

View File

@@ -7,10 +7,12 @@ import {
animaQwen3EncoderModelSelected,
animaT5EncoderModelSelected,
animaVaeModelSelected,
aspectRatioIdChanged,
kleinQwen3EncoderModelSelected,
kleinVaeModelSelected,
modelChanged,
qwenImageComponentSourceSelected,
resolutionPresetSelected,
setZImageScheduler,
syncedToOptimalDimension,
vaeSelected,
@@ -30,6 +32,7 @@ import {
} from 'features/controlLayers/store/selectors';
import {
getEntityIdentifier,
isAspectRatioID,
isFlux2ReferenceImageConfig,
isQwenImageReferenceImageConfig,
} from 'features/controlLayers/store/types';
@@ -59,7 +62,7 @@ import {
selectZImageDiffusersModels,
} from 'services/api/hooks/modelsByType';
import type { FLUXKontextModelConfig, FLUXReduxModelConfig, IPAdapterModelConfig } from 'services/api/types';
import { isFluxKontextModelConfig, isFluxReduxModelConfig } from 'services/api/types';
import { isExternalApiModelConfig, isFluxKontextModelConfig, isFluxReduxModelConfig } from 'services/api/types';
const log = logger('models');
@@ -200,8 +203,10 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
dispatch(
animaQwen3EncoderModelSelected({
key: qwen3Encoder.key,
hash: qwen3Encoder.hash,
name: qwen3Encoder.name,
base: qwen3Encoder.base,
type: qwen3Encoder.type,
})
);
}
@@ -221,8 +226,10 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
dispatch(
animaT5EncoderModelSelected({
key: t5Encoder.key,
hash: t5Encoder.hash,
name: t5Encoder.name,
base: t5Encoder.base,
type: t5Encoder.type,
})
);
}
@@ -281,7 +288,7 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
}
}
if (SUPPORTS_REF_IMAGES_BASE_MODELS.includes(newModel.base)) {
if (newModel.base !== 'external' && SUPPORTS_REF_IMAGES_BASE_MODELS.includes(newModel.base)) {
// Handle incompatible reference image models - switch to first compatible model, with some smart logic
// to choose the best available model based on the new main model.
const allRefImageModels = selectGlobalRefImageModels(state).filter(({ base }) => base === newBase);
@@ -529,6 +536,34 @@ export const addModelSelectedListener = (startAppListening: AppStartListening) =
dispatch(bboxSyncedToOptimalDimension());
}
}
// When switching to an external model, sync bbox to the model's first preset dimensions
if (newBase === 'external') {
const modelConfigsResult = selectModelConfigsQuery(getState());
if (modelConfigsResult.data) {
const newModelConfig = modelConfigsAdapterSelectors.selectById(modelConfigsResult.data, newModel.key);
if (newModelConfig && isExternalApiModelConfig(newModelConfig)) {
const { aspect_ratio_sizes, resolution_presets } = newModelConfig.capabilities;
if (resolution_presets && resolution_presets.length > 0) {
const firstPreset = resolution_presets[0]!;
dispatch(
resolutionPresetSelected({
imageSize: firstPreset.image_size,
aspectRatio: firstPreset.aspect_ratio,
width: firstPreset.width,
height: firstPreset.height,
})
);
} else if (aspect_ratio_sizes) {
const firstRatio = Object.keys(aspect_ratio_sizes)[0];
const firstSize = firstRatio ? aspect_ratio_sizes[firstRatio] : undefined;
if (firstRatio && firstSize && isAspectRatioID(firstRatio)) {
dispatch(aspectRatioIdChanged({ id: firstRatio, fixedSize: firstSize }));
}
}
}
}
}
},
});
};

View File

@@ -6,7 +6,7 @@ import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { Param0 } from 'tsafe';
const CLIPBOARD_FAQ_URL = 'https://invoke-ai.github.io/InvokeAI/faq/#unable-to-copy-on-firefox';
const CLIPBOARD_FAQ_URL = 'https://invoke.ai/troubleshooting/faq/#unable-to-copy-on-firefox';
export const useClipboard = () => {
const { t } = useTranslation();

View File

@@ -0,0 +1,72 @@
import { useAppSelector } from 'app/store/storeHooks';
import { openImageInNewTab } from 'common/util/openImageInNewTab';
import { selectSystemShouldUseMiddleClickToOpenInNewTab } from 'features/system/store/systemSlice';
import type { RefObject } from 'react';
import { useEffect } from 'react';
type Options = {
requireDirectTarget?: boolean;
};
const shouldHandleMiddleClick = <T extends HTMLElement>(
event: MouseEvent,
element: T,
requireDirectTarget: boolean
) => {
if (event.button !== 1) {
return false;
}
if (requireDirectTarget && event.target !== element) {
return false;
}
return true;
};
export const useMiddleClickOpenInNewTab = <T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
imageUrl: string,
{ requireDirectTarget = false }: Options = {}
) => {
const shouldUseMiddleClickToOpenInNewTab = useAppSelector(selectSystemShouldUseMiddleClickToOpenInNewTab);
useEffect(() => {
const element = ref.current;
if (!element || !shouldUseMiddleClickToOpenInNewTab) {
return;
}
// If auxclick is unsupported, leave the browser's default middle-click behavior intact.
if (!('onauxclick' in element)) {
return;
}
const onMouseDown = (event: MouseEvent) => {
if (!shouldHandleMiddleClick(event, element, requireDirectTarget)) {
return;
}
event.preventDefault();
};
const onAuxClick = (event: MouseEvent) => {
if (!shouldHandleMiddleClick(event, element, requireDirectTarget)) {
return;
}
event.preventDefault();
event.stopPropagation();
openImageInNewTab(imageUrl);
};
element.addEventListener('mousedown', onMouseDown);
element.addEventListener('auxclick', onAuxClick);
return () => {
element.removeEventListener('mousedown', onMouseDown);
element.removeEventListener('auxclick', onAuxClick);
};
}, [imageUrl, ref, requireDirectTarget, shouldUseMiddleClickToOpenInNewTab]);
};

View File

@@ -0,0 +1,3 @@
export const openImageInNewTab = (imageUrl: string) => {
window.open(imageUrl, '_blank', 'noopener,noreferrer');
};

View File

@@ -11,7 +11,7 @@ import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import WavyLine from 'common/components/WavyLine';
import { selectImg2imgStrength, setImg2imgStrength } from 'features/controlLayers/store/paramsSlice';
import { selectImg2imgStrength, selectIsExternal, setImg2imgStrength } from 'features/controlLayers/store/paramsSlice';
import { selectActiveRasterLayerEntities } from 'features/controlLayers/store/selectors';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -37,6 +37,7 @@ export const ParamDenoisingStrength = memo(() => {
const img2imgStrength = useAppSelector(selectImg2imgStrength);
const dispatch = useAppDispatch();
const hasRasterLayersWithContent = useAppSelector(selectHasRasterLayersWithContent);
const isExternal = useAppSelector(selectIsExternal);
const selectedModelConfig = useSelectedModelConfig();
const onChange = useCallback(
@@ -55,12 +56,16 @@ export const ParamDenoisingStrength = memo(() => {
// Denoising strength does nothing if there are no raster layers w/ content
return true;
}
if (isExternal) {
// External models don't support denoise strength - they handle img2img via prompt
return true;
}
if (selectedModelConfig && isFluxFillMainModelModelConfig(selectedModelConfig)) {
// Denoising strength is ignored by FLUX Fill, which is indicated by the variant being 'inpaint'
return true;
}
return false;
}, [hasRasterLayersWithContent, selectedModelConfig]);
}, [hasRasterLayersWithContent, isExternal, selectedModelConfig]);
return (
<FormControl isDisabled={isDisabled} p={1} justifyContent="space-between" h={8}>
@@ -96,7 +101,9 @@ export const ParamDenoisingStrength = memo(() => {
</>
) : (
<Flex alignItems="center">
<Badge opacity="0.6">{t('parameters.disabledNoRasterContent')}</Badge>
<Badge opacity="0.6">
{isExternal ? t('parameters.disabledNotSupported') : t('parameters.disabledNoRasterContent')}
</Badge>
</Flex>
)}
</FormControl>

View File

@@ -5,6 +5,7 @@ import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/componen
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { RasterLayerAdjustmentsPanel } from 'features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel';
import { RasterLayerIsTransparencyLockedToggle } from 'features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
@@ -38,6 +39,7 @@ export const RasterLayer = memo(({ id }: Props) => {
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<RasterLayerIsTransparencyLockedToggle />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<RasterLayerAdjustmentsPanel />

View File

@@ -0,0 +1,50 @@
import { IconButton } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
import { rasterLayerIsTransparencyLockedToggled } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDropHalfBold, PiDropHalfFill } from 'react-icons/pi';
export const RasterLayerIsTransparencyLockedToggle = memo(() => {
const { t } = useTranslation();
const entityIdentifier = useEntityIdentifierContext('raster_layer');
const isBusy = useCanvasIsBusy();
const dispatch = useAppDispatch();
const selectIsTransparencyLocked = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
const entity = selectEntity(canvas, entityIdentifier);
if (!entity) {
return false;
}
return entity.isTransparencyLocked ?? false;
}),
[entityIdentifier]
);
const isTransparencyLocked = useAppSelector(selectIsTransparencyLocked);
const onClick = useCallback(() => {
dispatch(rasterLayerIsTransparencyLockedToggled({ entityIdentifier }));
}, [dispatch, entityIdentifier]);
return (
<IconButton
size="sm"
aria-label={t(isTransparencyLocked ? 'controlLayers.transparencyLocked' : 'controlLayers.transparencyUnlocked')}
tooltip={t(isTransparencyLocked ? 'controlLayers.transparencyLocked' : 'controlLayers.transparencyUnlocked')}
variant="link"
alignSelf="stretch"
icon={isTransparencyLocked ? <PiDropHalfFill /> : <PiDropHalfBold />}
onClick={onClick}
isDisabled={isBusy}
/>
);
});
RasterLayerIsTransparencyLockedToggle.displayName = 'RasterLayerIsTransparencyLockedToggle';

View File

@@ -16,6 +16,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiExclamationMarkBold, PiEyeSlashBold, PiImageBold } from 'react-icons/pi';
import { useImageDTOFromCroppableImage } from 'services/api/endpoints/images';
import { isExternalApiModelConfig } from 'services/api/types';
import { RefImageWarningTooltipContent } from './RefImageWarningTooltipContent';
@@ -73,18 +74,19 @@ export const RefImagePreview = memo(() => {
const selectedEntityId = useAppSelector(selectSelectedRefEntityId);
const isPanelOpen = useAppSelector(selectIsRefImagePanelOpen);
const [showWeightDisplay, setShowWeightDisplay] = useState(false);
const isExternalModel = !!mainModelConfig && isExternalApiModelConfig(mainModelConfig);
const imageDTO = useImageDTOFromCroppableImage(entity.config.image);
const sx = useMemo(() => {
if (!isIPAdapterConfig(entity.config)) {
if (!isIPAdapterConfig(entity.config) || isExternalModel) {
return baseSx;
}
return getImageSxWithWeight(entity.config.weight);
}, [entity.config]);
}, [entity.config, isExternalModel]);
useEffect(() => {
if (!isIPAdapterConfig(entity.config)) {
if (!isIPAdapterConfig(entity.config) || isExternalModel) {
return;
}
setShowWeightDisplay(true);
@@ -94,7 +96,7 @@ export const RefImagePreview = memo(() => {
return () => {
window.clearTimeout(timeout);
};
}, [entity.config]);
}, [entity.config, isExternalModel]);
const warnings = useMemo(() => {
return getGlobalReferenceImageWarnings(entity, mainModelConfig);
@@ -156,7 +158,7 @@ export const RefImagePreview = memo(() => {
) : (
<Skeleton h="full" aspectRatio="1/1" />
)}
{isIPAdapterConfig(entity.config) && (
{isIPAdapterConfig(entity.config) && !isExternalModel && (
<Flex
position="absolute"
inset={0}

View File

@@ -15,7 +15,7 @@ import {
useCanvasManagerSafe,
} from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useRefImageIdContext } from 'features/controlLayers/contexts/RefImageIdContext';
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import { selectIsFLUX, selectMainModelConfig } from 'features/controlLayers/store/paramsSlice';
import {
refImageFLUXReduxImageInfluenceChanged,
refImageImageChanged,
@@ -50,6 +50,7 @@ import type {
FLUXReduxModelConfig,
IPAdapterModelConfig,
} from 'services/api/types';
import { isExternalApiModelConfig } from 'services/api/types';
import { RefImageImage } from './RefImageImage';
@@ -65,6 +66,7 @@ const RefImageSettingsContent = memo(() => {
const selectConfig = useMemo(() => buildSelectConfig(id), [id]);
const config = useAppSelector(selectConfig);
const tab = useAppSelector(selectActiveTab);
const mainModelConfig = useAppSelector(selectMainModelConfig);
const onChangeBeginEndStepPct = useCallback(
(beginEndStepPct: [number, number]) => {
@@ -125,9 +127,11 @@ const RefImageSettingsContent = memo(() => {
);
const isFLUX = useAppSelector(selectIsFLUX);
const isExternalModel = !!mainModelConfig && isExternalApiModelConfig(mainModelConfig);
// FLUX.2 Klein and Qwen Image Edit have built-in reference image support - no model selector needed
const showModelSelector = !isFlux2ReferenceImageConfig(config) && !isQwenImageReferenceImageConfig(config);
// FLUX.2 Klein, Qwen Image Edit and external API models do not require a ref image model selection.
const showModelSelector =
!isFlux2ReferenceImageConfig(config) && !isQwenImageReferenceImageConfig(config) && !isExternalModel;
return (
<Flex flexDir="column" gap={2} position="relative" w="full">
@@ -155,14 +159,14 @@ const RefImageSettingsContent = memo(() => {
</Flex>
)}
<Flex gap={2} w="full">
{isIPAdapterConfig(config) && (
{isIPAdapterConfig(config) && !isExternalModel && (
<Flex flexDir="column" gap={2} w="full">
{!isFLUX && <IPAdapterMethod method={config.method} onChange={onChangeIPMethod} />}
<Weight weight={config.weight} onChange={onChangeWeight} />
<BeginEndStepPct beginEndStepPct={config.beginEndStepPct} onChange={onChangeBeginEndStepPct} />
</Flex>
)}
{isFLUXReduxConfig(config) && (
{isFLUXReduxConfig(config) && !isExternalModel && (
<Flex flexDir="column" gap={2} w="full" alignItems="flex-start">
<FLUXReduxImageInfluence
imageInfluence={config.imageInfluence ?? 'lowest'}

View File

@@ -75,11 +75,18 @@ export const StagingAreaContextProvider = memo(({ children, sessionId }: PropsWi
onAccept: (item, imageDTO) => {
const bboxRect = selectBboxRect(store.getState());
const { x, y } = bboxRect;
const imageObject = imageDTOToImageObject(imageDTO, { usePixelBbox: false });
const scale = Math.min(bboxRect.width / imageDTO.width, bboxRect.height / imageDTO.height);
const scaledWidth = Math.round(imageDTO.width * scale);
const scaledHeight = Math.round(imageDTO.height * scale);
const position = {
x: x + Math.round((bboxRect.width - scaledWidth) / 2),
y: y + Math.round((bboxRect.height - scaledHeight) / 2),
};
const selectedEntityIdentifier = selectSelectedEntityIdentifier(store.getState());
const imageObject = imageDTOToImageObject(imageDTO);
const overrides: Partial<CanvasRasterLayerState> = {
position: { x, y },
position,
objects: [imageObject],
};
store.dispatch(rasterLayerAdded({ overrides, isSelected: selectedEntityIdentifier?.type === 'raster_layer' }));

View File

@@ -183,6 +183,33 @@ describe('StagingAreaApi Utility Functions', () => {
expect(result).toEqual(['first-image.png', 'second-image.png']);
});
it('should return first image from image collections', () => {
const queueItem: S['SessionQueueItem'] = {
item_id: 1,
status: 'completed',
priority: 0,
destination: 'test-session',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
started_at: '2024-01-01T00:00:01Z',
completed_at: '2024-01-01T00:01:00Z',
error: null,
session: {
id: 'test-session',
source_prepared_mapping: {
canvas_output: ['output-node-id'],
},
results: {
'output-node-id': {
images: [{ image_name: 'first.png' }, { image_name: 'second.png' }],
},
},
},
} as unknown as S['SessionQueueItem'];
expect(getOutputImageNames(queueItem)).toEqual(['first.png', 'second.png']);
});
it('should handle empty session mapping', () => {
const queueItem: S['SessionQueueItem'] = {
item_id: 1,

View File

@@ -1,4 +1,4 @@
import { isImageField } from 'features/nodes/types/common';
import { isImageField, isImageFieldCollection } from 'features/nodes/types/common';
import { isCanvasOutputNodeId } from 'features/nodes/util/graph/graphBuilderUtils';
import type { S } from 'services/api/types';
import { formatProgressMessage } from 'services/events/stores';
@@ -32,6 +32,11 @@ export const getOutputImageNames = (item: S['SessionQueueItem']): string[] => {
if (isImageField(value)) {
imageNames.push(value.image_name);
}
if (isImageFieldCollection(value)) {
for (const img of value) {
imageNames.push(img.image_name);
}
}
}
}

View File

@@ -45,7 +45,7 @@ import { toast } from 'features/toast/toast';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
import type { ImageDTO } from 'services/api/types';
import { type ImageDTO, isExternalApiModelConfig } from 'services/api/types';
import type { JsonObject } from 'type-fest';
const log = logger('canvas');
@@ -90,7 +90,7 @@ const useSaveCanvas = ({ region, saveToGallery, toastOk, toastError, onSave, wit
metadata.negative_prompt = selectNegativePrompt(state);
metadata.seed = selectSeed(state);
const model = selectMainModelConfig(state);
if (model) {
if (model && !isExternalApiModelConfig(model)) {
metadata.model = Graph.getModelMetadataField(model);
}
}

View File

@@ -1,5 +1,10 @@
import { useAppSelector } from 'app/store/storeHooks';
import { selectIsCogView4, selectIsFluxKontext, selectIsSD3 } from 'features/controlLayers/store/paramsSlice';
import {
selectIsCogView4,
selectIsExternal,
selectIsFluxKontext,
selectIsSD3,
} from 'features/controlLayers/store/paramsSlice';
import type { CanvasEntityType } from 'features/controlLayers/store/types';
import { useMemo } from 'react';
import type { Equals } from 'tsafe';
@@ -9,23 +14,24 @@ export const useIsEntityTypeEnabled = (entityType: CanvasEntityType) => {
const isSD3 = useAppSelector(selectIsSD3);
const isCogView4 = useAppSelector(selectIsCogView4);
const isFluxKontext = useAppSelector(selectIsFluxKontext);
const isExternal = useAppSelector(selectIsExternal);
// TODO(psyche): consider using a constant to define which entity types are supported by which model,
// see invokeai/frontend/web/src/features/modelManagerV2/models.ts for ref
const isEntityTypeEnabled = useMemo<boolean>(() => {
switch (entityType) {
case 'regional_guidance':
return !isSD3 && !isCogView4 && !isFluxKontext;
return !isSD3 && !isCogView4 && !isFluxKontext && !isExternal;
case 'control_layer':
return !isSD3 && !isCogView4 && !isFluxKontext;
return !isSD3 && !isCogView4 && !isFluxKontext && !isExternal;
case 'inpaint_mask':
return !isFluxKontext;
return !isFluxKontext && !isExternal;
case 'raster_layer':
return !isFluxKontext;
default:
assert<Equals<typeof entityType, never>>(false);
}
}, [entityType, isSD3, isCogView4, isFluxKontext]);
}, [entityType, isSD3, isCogView4, isFluxKontext, isExternal]);
return isEntityTypeEnabled;
};

View File

@@ -39,9 +39,9 @@ type CanvasCacheModuleConfig = {
const DEFAULT_CONFIG: CanvasCacheModuleConfig = {
imageNameCacheSize: 1000,
imageDataCacheSize: 32,
imageDataCacheSize: 64,
transparencyCalculationCacheSize: 1000,
canvasElementCacheSize: 32,
canvasElementCacheSize: 128,
generationModeCacheSize: 100,
};

View File

@@ -325,7 +325,7 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
*/
selectPosition = createSelector(this.selectState, (entity) => entity?.position);
syncIsOnscreen = () => {
syncIsOnscreen = rafThrottle(() => {
const stageRect = this.manager.stage.getScaledStageRect();
const isOnScreen = this.checkIntersection(stageRect);
const prevIsOnScreen = this.$isOnScreen.get();
@@ -334,9 +334,9 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
this.log.trace(`Moved ${isOnScreen ? 'on-screen' : 'off-screen'}`);
}
this.syncVisibility();
};
});
syncIntersectsBbox = () => {
syncIntersectsBbox = rafThrottle(() => {
const bboxRect = this.manager.stateApi.getBbox().rect;
const intersectsBbox = this.checkIntersection(bboxRect);
const prevIntersectsBbox = this.$intersectsBbox.get();
@@ -344,7 +344,7 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
if (prevIntersectsBbox !== intersectsBbox) {
this.log.trace(`Moved ${intersectsBbox ? 'into bbox' : 'out of bbox'}`);
}
};
});
checkIntersection = (rect: Rect): boolean => {
const entityRect = this.transformer.$pixelRect.get();
@@ -526,8 +526,13 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
return;
}
this.log.trace(isVisible ? 'Showing' : 'Hiding');
this.konva.layer.visible(isVisible);
if (isVisible) {
// Re-attach the layer to the stage before making it visible. The layer was detached from the DOM when hidden
// to free browser compositing resources (each Konva.Layer is a separate <canvas> element).
if (!this.konva.layer.getParent()) {
this.manager.stage.addLayer(this.konva.layer);
}
this.konva.layer.visible(true);
/**
* When a layer is created and initially not visible, its compositing rect won't be set up properly. Then, when
* we show it in this method, it the layer will not render as it should.
@@ -543,6 +548,13 @@ export abstract class CanvasEntityAdapterBase<T extends CanvasEntityState, U ext
this.renderer.updateCompositingRectPosition();
this.renderer.updateCompositingRectFill();
this.renderer.updateOpacity();
// Restore correct z-order after re-attaching
this.manager.entityRenderer.arrangeEntities(this.manager.stateApi.runSelector(selectCanvasSlice), null);
} else {
this.konva.layer.visible(false);
// Detach the layer from the stage to remove its <canvas> element from the DOM. This frees browser compositing
// resources. The layer object is kept alive and can be re-attached when shown again.
this.konva.layer.remove();
}
this.renderer.syncKonvaCache();
};

View File

@@ -6,8 +6,11 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasBrushLineState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { NodeConfig } from 'konva/lib/Node';
import type { Logger } from 'roarr';
type GlobalCompositeOperation = NonNullable<NodeConfig['globalCompositeOperation']>;
export class CanvasObjectBrushLine extends CanvasModuleBase {
readonly type = 'object_brush_line';
readonly id: string;
@@ -46,7 +49,7 @@ export class CanvasObjectBrushLine extends CanvasModuleBase {
tension: 0.3,
lineCap: 'round',
lineJoin: 'round',
globalCompositeOperation: 'source-over',
globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
perfectDrawEnabled: false,
}),
};
@@ -57,12 +60,13 @@ export class CanvasObjectBrushLine extends CanvasModuleBase {
update(state: CanvasBrushLineState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating brush line');
const { points, color, strokeWidth } = state;
const { points, color, strokeWidth, globalCompositeOperation } = state;
this.konva.line.setAttrs({
// A line with only one point will not be rendered, so we duplicate the points to make it visible
points: points.length === 2 ? [...points, ...points] : points,
stroke: rgbaColorToString(color),
strokeWidth,
globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
});
this.state = state;
return true;

View File

@@ -7,8 +7,11 @@ import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'
import { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util';
import type { CanvasBrushLineWithPressureState } from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { NodeConfig } from 'konva/lib/Node';
import type { Logger } from 'roarr';
type GlobalCompositeOperation = NonNullable<NodeConfig['globalCompositeOperation']>;
export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
readonly type = 'object_brush_line_with_pressure';
readonly id: string;
@@ -47,7 +50,7 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
name: `${this.type}:path`,
listening: false,
shadowForStrokeEnabled: false,
globalCompositeOperation: 'source-over',
globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
perfectDrawEnabled: false,
}),
};
@@ -58,8 +61,9 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
update(state: CanvasBrushLineWithPressureState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating brush line with pressure');
const { points, color, strokeWidth } = state;
const { points, color, strokeWidth, globalCompositeOperation } = state;
this.konva.line.setAttrs({
globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,

View File

@@ -10,7 +10,7 @@ import {
getPrefixedId,
} from 'features/controlLayers/konva/util';
import { selectBboxOverlay } from 'features/controlLayers/store/canvasSettingsSlice';
import { selectModel } from 'features/controlLayers/store/paramsSlice';
import { selectHasFixedDimensionSizes, selectModel } from 'features/controlLayers/store/paramsSlice';
import { selectBbox } from 'features/controlLayers/store/selectors';
import type { Coordinate, Rect, Tool } from 'features/controlLayers/store/types';
import Konva from 'konva';
@@ -191,6 +191,9 @@ export class CanvasBboxToolModule extends CanvasModuleBase {
// Listen for the model changing - some model types constraint the bbox to a certain size or aspect ratio.
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectModel, this.render));
// Listen for fixed dimension sizes changes - external models may lock bbox resizing
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectHasFixedDimensionSizes, this.render));
// Update on busy state changes
this.subscriptions.add(this.manager.$isBusy.listen(this.render));
@@ -246,6 +249,10 @@ export class CanvasBboxToolModule extends CanvasModuleBase {
if (tool !== 'bbox') {
return NO_ANCHORS;
}
// External models with fixed dimension presets don't allow free bbox resizing
if (this.manager.stateApi.runSelector(selectHasFixedDimensionSizes)) {
return NO_ANCHORS;
}
return ALL_ANCHORS;
};

View File

@@ -211,6 +211,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
// When transparency is locked on a raster layer, use 'source-atop' to only paint on existing opaque pixels
const isTransparencyLocked =
selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked;
const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined;
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
// If the pen is down and pressure sensitivity is enabled, add the point with pressure
await selectedEntity.bufferRenderer.setBuffer({
@@ -220,6 +225,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.parent.getClip(selectedEntity.state),
globalCompositeOperation,
});
} else {
// Else, add the point without pressure
@@ -230,6 +236,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.parent.getClip(selectedEntity.state),
globalCompositeOperation,
});
}
};
@@ -268,6 +275,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
// When transparency is locked on a raster layer, use 'source-atop' to only paint on existing opaque pixels
const isTransparencyLocked =
selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked;
const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined;
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
// We need to get the last point of the last line to create a straight line if shift is held
const lastLinePoint = getLastPointOfLastLineWithPressure(
@@ -304,6 +316,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
// When shift is held, the line may extend beyond the clip region. Clip only if we are clipping to bbox. If we
// are clipping to stage, we don't need to clip at all.
clip: isShiftDraw && !settings.clipToBbox ? null : this.parent.getClip(selectedEntity.state),
globalCompositeOperation,
});
} else {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
@@ -329,6 +342,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
// When shift is held, the line may extend beyond the clip region. Clip only if we are clipping to bbox. If we
// are clipping to stage, we don't need to clip at all.
clip: isShiftDraw && !settings.clipToBbox ? null : this.parent.getClip(selectedEntity.state),
globalCompositeOperation,
});
}
};

View File

@@ -7,7 +7,7 @@ import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMul
import { merge } from 'es-toolkit/compat';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { canvasReset } from 'features/controlLayers/store/actions';
import { modelChanged } from 'features/controlLayers/store/paramsSlice';
import { aspectRatioIdChanged, modelChanged, resolutionPresetSelected } from 'features/controlLayers/store/paramsSlice';
import {
selectAllEntities,
selectAllEntitiesOfType,
@@ -31,6 +31,7 @@ import type {
RgbColor,
SimpleAdjustmentsConfig,
} from 'features/controlLayers/store/types';
import { isAspectRatioID } from 'features/controlLayers/store/types';
import {
calculateNewSize,
getScaledBoundingBoxDimensions,
@@ -217,6 +218,17 @@ const slice = createSlice({
layer.globalCompositeOperation = globalCompositeOperation;
}
},
rasterLayerIsTransparencyLockedToggled: (
state,
action: PayloadAction<EntityIdentifierPayload<void, 'raster_layer'>>
) => {
const { entityIdentifier } = action.payload;
const layer = selectEntity(state, entityIdentifier);
if (!layer) {
return;
}
layer.isTransparencyLocked = !layer.isTransparencyLocked;
},
rasterLayerAdded: {
reducer: (
state,
@@ -1288,21 +1300,31 @@ const slice = createSlice({
state.bbox.aspectRatio.isLocked = !state.bbox.aspectRatio.isLocked;
syncScaledSize(state);
},
bboxAspectRatioIdChanged: (state, action: PayloadAction<{ id: AspectRatioID }>) => {
const { id } = action.payload;
bboxAspectRatioIdChanged: (
state,
action: PayloadAction<{ id: AspectRatioID; fixedSize?: { width: number; height: number } }>
) => {
const { id, fixedSize } = action.payload;
state.bbox.aspectRatio.id = id;
if (id === 'Free') {
state.bbox.aspectRatio.isLocked = false;
} else {
state.bbox.aspectRatio.isLocked = true;
state.bbox.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio;
const { width, height } = calculateNewSize(
state.bbox.aspectRatio.value,
state.bbox.rect.width * state.bbox.rect.height,
state.bbox.modelBase
);
state.bbox.rect.width = width;
state.bbox.rect.height = height;
if (fixedSize) {
// External models provide fixed dimensions for each aspect ratio
state.bbox.aspectRatio.value = fixedSize.width / fixedSize.height;
state.bbox.rect.width = fixedSize.width;
state.bbox.rect.height = fixedSize.height;
} else {
state.bbox.aspectRatio.value = ASPECT_RATIO_MAP[id].ratio;
const { width, height } = calculateNewSize(
state.bbox.aspectRatio.value,
state.bbox.rect.width * state.bbox.rect.height,
state.bbox.modelBase
);
state.bbox.rect.width = width;
state.bbox.rect.height = height;
}
}
syncScaledSize(state);
@@ -1800,6 +1822,29 @@ const slice = createSlice({
syncScaledSize(state);
}
});
// Sync bbox when external model resolution preset is selected (aspect_ratio_sizes)
builder.addCase(aspectRatioIdChanged, (state, action) => {
const { id, fixedSize } = action.payload;
// Only sync when fixedSize is provided (external models with aspect_ratio_sizes)
if (fixedSize) {
state.bbox.rect.width = fixedSize.width;
state.bbox.rect.height = fixedSize.height;
state.bbox.aspectRatio.value = fixedSize.width / fixedSize.height;
state.bbox.aspectRatio.id = id;
state.bbox.aspectRatio.isLocked = true;
syncScaledSize(state);
}
});
// Sync bbox when external model resolution preset is selected (resolution_presets)
builder.addCase(resolutionPresetSelected, (state, action) => {
const { width, height, aspectRatio } = action.payload;
state.bbox.rect.width = width;
state.bbox.rect.height = height;
state.bbox.aspectRatio.value = width / height;
state.bbox.aspectRatio.id = isAspectRatioID(aspectRatio) ? aspectRatio : 'Free';
state.bbox.aspectRatio.isLocked = true;
syncScaledSize(state);
});
},
});
@@ -1856,6 +1901,7 @@ export const {
rasterLayerAdjustmentsSimpleUpdated,
rasterLayerAdjustmentsCurvesUpdated,
rasterLayerGlobalCompositeOperationChanged,
rasterLayerIsTransparencyLockedToggled,
entityDeleted,
entityArrangedForwardOne,
entityArrangedToFront,

View File

@@ -0,0 +1,133 @@
import type {
ExternalApiModelConfig,
ExternalApiModelDefaultSettings,
ExternalImageSize,
ExternalModelCapabilities,
ExternalModelPanelSchema,
} from 'services/api/types';
import { describe, expect, it } from 'vitest';
import {
selectModelSupportsDimensions,
selectModelSupportsGuidance,
selectModelSupportsNegativePrompt,
selectModelSupportsRefImages,
selectModelSupportsSeed,
selectModelSupportsSteps,
} from './paramsSlice';
const buildExternalModelIdentifier = (config: ExternalApiModelConfig) =>
({
key: config.key,
hash: config.hash,
name: config.name,
base: config.base,
type: config.type,
}) as const;
const createExternalConfig = (
capabilities: ExternalModelCapabilities,
panelSchema?: ExternalModelPanelSchema
): ExternalApiModelConfig => {
const maxImageSize: ExternalImageSize = { width: 1024, height: 1024 };
const defaultSettings: ExternalApiModelDefaultSettings = { width: 1024, height: 1024 };
return {
key: 'external-test',
hash: 'external:openai:gpt-image-1',
path: 'external://openai/gpt-image-1',
file_size: 0,
name: 'External Test',
description: null,
source: 'external://openai/gpt-image-1',
source_type: 'url',
source_api_response: null,
cover_image: null,
base: 'external',
type: 'external_image_generator',
format: 'external_api',
provider_id: 'openai',
provider_model_id: 'gpt-image-1',
capabilities: { ...capabilities, max_image_size: maxImageSize },
default_settings: defaultSettings,
panel_schema: panelSchema,
tags: ['external'],
is_default: false,
};
};
describe('paramsSlice selectors for external models', () => {
it('returns false for negative prompt support on external models', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_reference_images: false,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsNegativePrompt.resultFunc(model)).toBe(false);
});
it('uses external capabilities for ref image support', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_reference_images: false,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsRefImages.resultFunc(model, config)).toBe(false);
});
it('returns false for guidance support on external models', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_reference_images: false,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsGuidance.resultFunc(model)).toBe(false);
});
it('uses external capabilities for seed support', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_reference_images: false,
supports_seed: false,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsSeed.resultFunc(model, config)).toBe(false);
});
it('returns false for steps support on external models', () => {
const config = createExternalConfig({
modes: ['txt2img'],
supports_reference_images: false,
});
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsSteps.resultFunc(model)).toBe(false);
});
it('prefers panel schema over capabilities for control visibility', () => {
const config = createExternalConfig(
{
modes: ['txt2img'],
supports_reference_images: true,
supports_seed: true,
},
{
prompts: [{ name: 'reference_images' }],
image: [{ name: 'dimensions' }],
generation: [],
}
);
const model = buildExternalModelIdentifier(config);
expect(selectModelSupportsNegativePrompt.resultFunc(model)).toBe(false);
expect(selectModelSupportsRefImages.resultFunc(model, config)).toBe(true);
expect(selectModelSupportsGuidance.resultFunc(model)).toBe(false);
expect(selectModelSupportsSeed.resultFunc(model, config)).toBe(false);
expect(selectModelSupportsSteps.resultFunc(model)).toBe(false);
expect(selectModelSupportsDimensions.resultFunc(model, config)).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More